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
95 changes: 95 additions & 0 deletions app/src/__tests__/analog-inspector-pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { scopeToPage } from '../page-id';
import { AnalogInspector } from '../pages/analog-inspector';

const project = { analog: true, routes: [], api: [], middleware: [], content: [] };

const page = (pageId: string, url: string) => ({ pageId, url, chain: [] });

function client(options: { state?: unknown; failures?: number } = {}): DevframeRpcClient {
let failures = options.failures ?? 0;
const rpc = {
call: (name: string) => {
if (name === 'analog-project') {
if (failures > 0) {
failures--;
return Promise.reject(new Error('scan failed'));
}
return Promise.resolve(project);
}
if (name === 'analog-render') return Promise.resolve({ rows: [], plan: null });
return Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: () =>
Promise.resolve({
value: () => options.state ?? {},
on: () => () => {},
}),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

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

async function render(rpc: DevframeRpcClient) {
const fixture = TestBed.createComponent(AnalogInspector);
fixture.componentRef.setInput('rpc', rpc);
await settle(fixture);
return fixture;
}

describe('Analog page selection', () => {
afterEach(() => {
scopeToPage(null);
document.body.innerHTML = '';
});

it('shows the tab the panel belongs to, not whichever reported last', async () => {
scopeToPage('A');
const fixture = await render(
client({ state: { pages: [page('B', '/from-b'), page('A', '/from-a')] } }),
);
expect(fixture.componentInstance.page()?.url).toBe('/from-a');
});

it('shows nothing from another tab while its own tab has not reported', async () => {
scopeToPage('A');
const fixture = await render(client({ state: { pages: [page('B', '/from-b')] } }));
expect(fixture.componentInstance.page()).toBeNull();
});

it('follows the newest report when the panel has no page id', async () => {
const fixture = await render(
client({ state: { pages: [page('B', '/from-b'), page('A', '/from-a')] } }),
);
expect(fixture.componentInstance.page()?.url).toBe('/from-b');
});
});

describe('Analog project load', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('shows an error with a retry instead of reading the project forever', async () => {
const fixture = await render(client({ failures: 2 }));
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector('[role="alert"]')?.textContent).toContain('Could not read');
expect(root.querySelector('.loading')).toBeNull();
const retry = Array.from(root.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Retry'),
);
retry!.click();
await settle(fixture);
expect(root.querySelector('[role="alert"]')).toBeNull();
expect(fixture.componentInstance.project()).not.toBeNull();
});
});
135 changes: 134 additions & 1 deletion app/src/__tests__/di-inspector-pages.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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 { scopeToPage } from '../page-id';
import { DiInspector } from '../pages/di-inspector';

Expand Down Expand Up @@ -36,6 +36,139 @@ async function render(shared: unknown) {
return fixture;
}

describe('injector tree loading and row actions', () => {
afterEach(() => {
scopeToPage(null);
vi.unstubAllGlobals();
document.body.innerHTML = '';
});

it('keeps one tree subscription when loads overlap', async () => {
const pending: ((value: unknown) => void)[] = [];
let active = 0;
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: () =>
new Promise((resolve) => {
pending.push(() =>
resolve({
value: () => ({ roots: [], environment: [] }),
on: () => {
active++;
return () => {
active--;
};
},
}),
);
}),
};
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
const fixture = TestBed.createComponent(DiInspector);
fixture.componentRef.setInput('rpc', client);
fixture.detectChanges();
fixture.componentRef.setInput('rpc', null);
fixture.detectChanges();
fixture.componentRef.setInput('rpc', client);
fixture.detectChanges();
expect(pending).toHaveLength(2);
pending.forEach((resolve) => resolve(undefined));
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
expect(active).toBe(1);
fixture.destroy();
expect(active).toBe(0);
});

it('applies the newest load when an older one for the same client resolves last', async () => {
const pending: (() => void)[] = [];
const states = [
{ roots: [node('old')], environment: [] },
{ roots: [node('new')], environment: [] },
];
let call = 0;
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: () => {
const state = states[call++];
return new Promise((resolve) => {
pending.push(() => resolve({ value: () => state, on: () => () => {} }));
});
},
};
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
const fixture = TestBed.createComponent(DiInspector);
fixture.componentRef.setInput('rpc', client);
fixture.detectChanges();
fixture.componentRef.setInput('rpc', null);
fixture.detectChanges();
fixture.componentRef.setInput('rpc', client);
fixture.detectChanges();
pending[1]();
await new Promise((resolve) => setTimeout(resolve));
pending[0]();
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
expect(fixture.componentInstance.roots().map((n) => n.injector.name)).toEqual(['new']);
});

it('scrolls to a revealed row that was collapsed and not yet rendered', async () => {
const tree = [
{
injector: { id: 'a', type: 'element', name: 'A', providerCount: 0, component: 'A' },
providers: [],
children: [
{
injector: { id: 'b', type: 'element', name: 'B', providerCount: 0, component: 'B' },
providers: [],
children: [],
},
],
},
];
const fixture = await render({ roots: tree, environment: [] });
fixture.componentInstance.collapsed.set(new Set(['a']));
fixture.detectChanges();
expect(document.querySelector('.row[data-id="b"]')).toBeNull();
const scrolled: string[] = [];
vi.stubGlobal('CSS', { escape: (value: string) => value });
Element.prototype.scrollIntoView = function (this: Element) {
scrolled.push(this.getAttribute('data-id') ?? '');
};
document.body.appendChild(fixture.nativeElement);
fixture.componentInstance.reveal('b');
await Promise.resolve();
fixture.detectChanges();
await fixture.whenStable();
expect(scrolled).toContain('b');
});

it('sends a row highlight with the page id of the tab the panel belongs to', async () => {
scopeToPage('A');
const calls: unknown[][] = [];
const rpc = {
call: (...args: unknown[]) => {
calls.push(args);
return Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: () => Promise.resolve({ value: () => ({}), on: () => () => {} }),
};
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
const fixture = TestBed.createComponent(DiInspector);
fixture.componentRef.setInput('rpc', client);
fixture.detectChanges();
fixture.componentInstance.highlight({
injector: { id: 'a', type: 'element', name: 'A', providerCount: 0, selector: 'app-card' },
providers: [],
children: [],
});
expect(calls).toContainEqual(['request-page-highlight', { pageId: 'A', selector: 'app-card' }]);
});
});

describe('injector tree page selection', () => {
afterEach(() => {
scopeToPage(null);
Expand Down
34 changes: 30 additions & 4 deletions app/src/pages/analog-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
import { time } from '../format';
import { hostPageId } from '../page-id';
import { rpcCall, rpcTry as call } from '../rpc';
import { actionAllowed, actionBlockedMessage } from '../devtools-config';
import { Select } from '../ui/select';
Expand Down Expand Up @@ -308,7 +309,15 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
selector: 'app-analog-inspector',
imports: [Select],
template: `
@if (project() === null) {
@if (project() === null && loadFailed()) {
<div class="empty" role="alert">
<h2 class="empty-title">Could not read the project.</h2>
<p class="muted">
Check that the dev server with Pangular Inspector is still running, then retry.
</p>
<button type="button" class="btn" (click)="retry()">Retry</button>
</div>
} @else if (project() === null) {
<div class="loading" role="status">
<span class="spinner" aria-hidden="true"></span>
<span>Reading the project…</span>
Expand Down Expand Up @@ -2105,6 +2114,7 @@ export class AnalogInspector {
readonly methodOptions = this.methods.map((m) => ({ value: m, label: m }));
readonly view = signal<View>('routes');
readonly project = signal<AnalogProject | null>(null);
readonly loadFailed = signal(false);
readonly state = signal<AnalogState>({});
readonly findings = signal<Finding[]>([]);
readonly renderRows = signal<RenderRow[]>([]);
Expand Down Expand Up @@ -2132,7 +2142,12 @@ export class AnalogInspector {
private refreshRun = 0;
private readonly destroyRef = inject(DestroyRef);

readonly page = computed(() => this.state().pages?.[0] ?? null);
private readonly hostPageId = hostPageId();

readonly page = computed(() => {
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)));
readonly allCalls = computed(() => this.state().calls ?? []);
readonly calls = computed(() => {
Expand Down Expand Up @@ -2238,6 +2253,7 @@ export class AnalogInspector {
const project = await call<AnalogProject>(client, 'analog-project');
if (this.destroyRef.destroyed) return;
this.project.set(project);
this.loadFailed.set(project === null);
try {
const shared = await client.scope('pangular').rpc.sharedState('analog');
if (this.destroyRef.destroyed) return;
Expand All @@ -2252,6 +2268,13 @@ export class AnalogInspector {
await this.refresh(this.view());
}

retry() {
const client = this.rpc();
if (!client) return;
this.loadFailed.set(false);
void this.load(client);
}

private scheduleRefresh(view: View) {
clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => void this.refresh(view), 300);
Expand All @@ -2269,9 +2292,12 @@ export class AnalogInspector {
this.findings.set(findings ?? []);
this.renderRows.set(render?.rows ?? []);
this.plan.set(render?.plan ?? null);
if (view === 'routes' || view === 'content') {
if (view === 'routes' || view === 'content' || this.project() === null) {
const project = await call<AnalogProject>(client, 'analog-project');
if (project && run === this.refreshRun) this.project.set(project);
if (project && run === this.refreshRun) {
this.project.set(project);
this.loadFailed.set(false);
}
}
}

Expand Down
Loading
Loading