Skip to content

Commit 6fd40ba

Browse files
committed
feat(overlay): show Angular Native apps in their own panel view
Angular Native pages now report their platform, so the panel opens an Angular Native view with Components, Signals, Injectors and Store for the connected app, and an empty state with the setup link when none is connected. The CLI prints the view's URL, list-pages shows each page's platform, and browser-only controls are hidden for Angular Native pages. Adds examples/angular-native, kept outside the pnpm workspace because it installs the package from a local tarball. Refs pangular-inspector#198
1 parent 194f6b6 commit 6fd40ba

43 files changed

Lines changed: 1075 additions & 103 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import type { DevframeRpcClient } from 'devframe/client';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { angularNativePage } from '../native-page';
5+
import { hostPageId, scopeToPage } from '../page-id';
6+
7+
const connection = vi.hoisted(() => ({
8+
resolve: (_client: unknown) => {},
9+
}));
10+
11+
vi.mock('devframe/client', () => ({
12+
connectDevframe: () =>
13+
new Promise((resolve) => {
14+
connection.resolve = resolve;
15+
}),
16+
}));
17+
18+
const { App } = await import('../app');
19+
20+
type Pages = Record<string, { pageId: string; reportedAt: number; platform?: string }>;
21+
22+
function fakeClient(initial: Pages) {
23+
let pages = initial;
24+
const listeners = new Set<(value: unknown) => void>();
25+
const componentTree = {
26+
value: () => ({ pages }),
27+
on: (_event: string, listener: (value: unknown) => void) => {
28+
listeners.add(listener);
29+
return () => listeners.delete(listener);
30+
},
31+
};
32+
const rpc = {
33+
call: () => new Promise(() => {}),
34+
callEvent: () => Promise.resolve(),
35+
sharedState: (name: string) =>
36+
Promise.resolve(
37+
name === 'component-tree' ? componentTree : { value: () => null, on: () => () => {} },
38+
),
39+
};
40+
const client = {
41+
connectionMeta: { configs: {} },
42+
scope: () => ({ rpc }),
43+
events: { on: () => () => {} },
44+
} as unknown as DevframeRpcClient;
45+
const report = (next: Pages) => {
46+
pages = next;
47+
for (const listener of listeners) listener({ pages });
48+
};
49+
return { client, report };
50+
}
51+
52+
async function settle(fixture: ComponentFixture<unknown>) {
53+
for (let i = 0; i < 3; i++) {
54+
await new Promise((resolve) => setTimeout(resolve));
55+
await fixture.whenStable();
56+
}
57+
}
58+
59+
function host(fixture: ComponentFixture<unknown>): HTMLElement {
60+
return fixture.nativeElement as HTMLElement;
61+
}
62+
63+
const tabNames = (fixture: ComponentFixture<unknown>) =>
64+
[...host(fixture).querySelectorAll('nav button')].map((b) => b.textContent?.trim());
65+
66+
const page = { roots: [], count: 0, detail: null };
67+
const browser = { ...page, pageId: 'web1', reportedAt: 3000 };
68+
const native = { ...page, pageId: 'an42', reportedAt: 2000, platform: 'angular-native' };
69+
70+
describe('angularNativePage', () => {
71+
it('picks the newest Angular Native page and ignores browser pages', () => {
72+
const older = { pageId: 'an01', reportedAt: 1000, platform: 'angular-native' };
73+
expect(angularNativePage({ web1: browser, an42: native, an01: older }, null)).toBe('an42');
74+
expect(angularNativePage({ web1: browser }, null)).toBeNull();
75+
expect(angularNativePage(undefined, null)).toBeNull();
76+
});
77+
78+
it('keeps the page shown before while it still reports', () => {
79+
const newer = { pageId: 'an99', reportedAt: 9000, platform: 'angular-native' };
80+
expect(angularNativePage({ an42: native, an99: newer }, 'an42')).toBe('an42');
81+
expect(angularNativePage({ an99: newer }, 'an42')).toBe('an99');
82+
});
83+
});
84+
85+
describe('Angular Native view', () => {
86+
beforeEach(() => history.replaceState(null, '', '/?view=angular-native'));
87+
afterEach(() => {
88+
document.body.innerHTML = '';
89+
history.replaceState(null, '', '/');
90+
scopeToPage(null);
91+
});
92+
93+
it('shows the setup empty state while no Angular Native app reports', async () => {
94+
const fixture = TestBed.createComponent(App);
95+
await settle(fixture);
96+
connection.resolve(fakeClient({ web1: browser }).client);
97+
await settle(fixture);
98+
const text = host(fixture).textContent ?? '';
99+
expect(host(fixture).querySelector('h1')?.textContent).toContain('Angular Native');
100+
expect(text).toContain('No Angular Native app is connected');
101+
const link = host(fixture).querySelector<HTMLAnchorElement>('a.cta');
102+
expect(link?.textContent).toContain('Set up Angular Native');
103+
expect(link?.href).toContain('getting-started/angular-native.md');
104+
expect(tabNames(fixture)).toEqual([]);
105+
});
106+
107+
it('opens the live tabs scoped to the app once it reports, and goes back when it leaves', async () => {
108+
const fixture = TestBed.createComponent(App);
109+
await settle(fixture);
110+
const { client, report } = fakeClient({ web1: browser });
111+
connection.resolve(client);
112+
await settle(fixture);
113+
114+
report({ web1: browser, an42: native });
115+
await settle(fixture);
116+
expect(tabNames(fixture)).toEqual(['Components', 'Signals', 'Injectors', 'Store']);
117+
expect(host(fixture).querySelector('nav button.active')?.textContent).toContain('Components');
118+
expect(host(fixture).textContent).not.toContain('No Angular Native app is connected');
119+
expect(hostPageId()).toBe('an42');
120+
121+
report({ web1: browser });
122+
await settle(fixture);
123+
expect(host(fixture).textContent).toContain('No Angular Native app is connected');
124+
});
125+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import type { DevframeRpcClient } from 'devframe/client';
3+
import { afterEach, describe, expect, it } from 'vitest';
4+
import { isAngularNativePage } from '../native-page';
5+
import { ComponentTree } from '../pages/component-tree';
6+
7+
function fakeClient(pages: Record<string, unknown>): DevframeRpcClient {
8+
const rpc = {
9+
call: () => Promise.resolve([]),
10+
callEvent: () => Promise.resolve(),
11+
sharedState: (name: string) =>
12+
Promise.resolve({
13+
value: () => (name === 'component-tree' ? { pages } : null),
14+
on: () => () => {},
15+
}),
16+
};
17+
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
18+
}
19+
20+
const root = { id: 'c1', name: 'App', tag: 'app-root', children: [] };
21+
22+
function page(pageId: string, platform?: string) {
23+
return { pageId, platform, roots: [root], count: 1, detail: null, reportedAt: 1000 };
24+
}
25+
26+
async function render(platform?: string) {
27+
const fixture = TestBed.createComponent(ComponentTree);
28+
fixture.componentRef.setInput('rpc', fakeClient({ p1: page('p1', platform) }));
29+
for (let i = 0; i < 3; i++) {
30+
await new Promise((resolve) => setTimeout(resolve));
31+
await fixture.whenStable();
32+
}
33+
return fixture;
34+
}
35+
36+
function buttons(fixture: ComponentFixture<unknown>): string[] {
37+
return [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].map(
38+
(button) => button.textContent?.trim() ?? '',
39+
);
40+
}
41+
42+
function liveClient(initial: Record<string, unknown>) {
43+
let pages = initial;
44+
const listeners = new Set<(value: unknown) => void>();
45+
const calls: { name: string; arg: unknown }[] = [];
46+
const rpc = {
47+
call: (name: string, arg: unknown) => {
48+
calls.push({ name, arg });
49+
return name === 'request-component-pick' ? new Promise(() => {}) : Promise.resolve([]);
50+
},
51+
callEvent: () => Promise.resolve(),
52+
sharedState: (name: string) =>
53+
Promise.resolve({
54+
value: () => (name === 'component-tree' ? { pages } : null),
55+
on: (_event: string, listener: (value: unknown) => void) => {
56+
if (name === 'component-tree') listeners.add(listener);
57+
return () => listeners.delete(listener);
58+
},
59+
}),
60+
};
61+
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
62+
const report = (next: Record<string, unknown>) => {
63+
pages = next;
64+
for (const listener of listeners) listener({ pages });
65+
};
66+
return { client, calls, report };
67+
}
68+
69+
async function settle(fixture: ComponentFixture<unknown>) {
70+
for (let i = 0; i < 3; i++) {
71+
await new Promise((resolve) => setTimeout(resolve));
72+
await fixture.whenStable();
73+
}
74+
}
75+
76+
describe('browser-only controls', () => {
77+
afterEach(() => {
78+
document.body.innerHTML = '';
79+
});
80+
81+
it('knows an Angular Native page by its platform', () => {
82+
expect(isAngularNativePage({ platform: 'angular-native' })).toBe(true);
83+
expect(isAngularNativePage({})).toBe(false);
84+
expect(isAngularNativePage(null)).toBe(false);
85+
});
86+
87+
it('shows the component picker and change detection recording for a browser page', async () => {
88+
const fixture = await render();
89+
expect(buttons(fixture)).toContain('Pick component on page');
90+
expect(buttons(fixture)).toContain('Record');
91+
expect((fixture.nativeElement as HTMLElement).querySelector('#cd-heading')).not.toBeNull();
92+
});
93+
94+
it('hides them for an Angular Native page and keeps the tree', async () => {
95+
const fixture = await render('angular-native');
96+
const host = fixture.nativeElement as HTMLElement;
97+
expect(buttons(fixture)).not.toContain('Pick component on page');
98+
expect(buttons(fixture)).not.toContain('Record');
99+
expect(host.querySelector('#cd-heading')).toBeNull();
100+
expect(host.textContent).toContain('App');
101+
expect(host.querySelector('input[type="search"]')).not.toBeNull();
102+
});
103+
104+
it('cancels a pick on its own page when the dropdown shows another page', async () => {
105+
const { client, calls } = liveClient({ p1: page('p1'), p2: page('p2') });
106+
const fixture = TestBed.createComponent(ComponentTree);
107+
fixture.componentRef.setInput('rpc', client);
108+
await settle(fixture);
109+
const tree = fixture.componentInstance;
110+
const shown = tree.page()!.pageId;
111+
const other = shown === 'p1' ? 'p2' : 'p1';
112+
113+
void tree.pick();
114+
await settle(fixture);
115+
expect(buttons(fixture)).toContain('Cancel pick');
116+
117+
tree.selectPage(other);
118+
await settle(fixture);
119+
expect(calls).toContainEqual({ name: 'cancel-component-pick', arg: { pageId: shown } });
120+
expect(tree.picking()).toBe(false);
121+
expect(buttons(fixture)).toContain('Pick component on page');
122+
});
123+
124+
it('cancels a pick on the browser page when an Angular Native page takes over', async () => {
125+
const { client, calls, report } = liveClient({ p1: page('p1') });
126+
const fixture = TestBed.createComponent(ComponentTree);
127+
fixture.componentRef.setInput('rpc', client);
128+
await settle(fixture);
129+
const tree = fixture.componentInstance;
130+
131+
void tree.pick();
132+
await settle(fixture);
133+
report({ an42: page('an42', 'angular-native') });
134+
await settle(fixture);
135+
136+
expect(tree.page()?.pageId).toBe('an42');
137+
expect(calls).toContainEqual({ name: 'cancel-component-pick', arg: { pageId: 'p1' } });
138+
expect(tree.picking()).toBe(false);
139+
expect(buttons(fixture)).not.toContain('Cancel pick');
140+
});
141+
});

0 commit comments

Comments
 (0)