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

const provider = (token: string, framework = false) => ({
token,
type: 'class',
isViewProvider: false,
...(framework ? { framework: true } : {}),
});

const tree = {
pages: {
p1: {
pageId: 'p1',
reportedAt: 1,
roots: [
{
injector: {
id: 'inj-1',
type: 'element',
name: 'app-root',
providerCount: 0,
component: 'App',
componentId: 'c1',
},
providers: [],
children: [],
},
],
environment: [
{
injector: {
id: 'inj-p',
type: 'environment',
name: 'Platform',
providerCount: 1,
framework: true,
},
providers: [provider('PlatformLocation', true)],
children: [
{
injector: { id: 'inj-r', type: 'environment', name: 'Root', providerCount: 3 },
providers: [
provider('ApplicationRef', true),
provider('NgZone', true),
provider('TripStore'),
],
children: [],
},
],
},
],
},
},
};

const calls: { name: string; payload: unknown }[] = [];

function client(): DevframeRpcClient {
const rpc = {
call: (name: string, payload: unknown) => {
calls.push({ name, payload });
if (name === 'request-console-global') {
return Promise.resolve({ ok: true, message: 'Logged TripStore in the page console.' });
}
return Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: () => Promise.resolve({ value: () => tree, on: () => () => {} }),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

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

async function render() {
const fixture = TestBed.createComponent(DiInspector);
fixture.componentRef.setInput('rpc', client());
document.body.append(fixture.nativeElement);
await settle(fixture);
return fixture;
}

const host = (fixture: ComponentFixture<DiInspector>) => fixture.nativeElement as HTMLElement;
const checkbox = (fixture: ComponentFixture<DiInspector>, label: string) =>
[...host(fixture).querySelectorAll('label.checkbox')]
.find((l) => l.textContent?.trim() === label)!
.querySelector('input')!;
const rowNames = (fixture: ComponentFixture<DiInspector>) =>
[...host(fixture).querySelectorAll('.row .name')].map((n) => n.textContent?.trim());
const providerTokens = (fixture: ComponentFixture<DiInspector>) =>
[...host(fixture).querySelectorAll('.providers .provider .token')].map((n) =>
n.textContent?.trim(),
);

describe('Hide framework in the Injectors tab', () => {
afterEach(() => {
calls.length = 0;
localStorage.clear();
document.body.innerHTML = '';
});

it("hides Angular's own injectors and providers, says how many, and remembers it", async () => {
const fixture = await render();
fixture.componentInstance.setView('environment');
await settle(fixture);
expect(rowNames(fixture)).toEqual(['Platform', 'Root']);

checkbox(fixture, 'Hide framework').click();
await settle(fixture);
// Root moves up to take the Platform injector's place.
expect(rowNames(fixture)).toEqual(['Root']);
expect(host(fixture).querySelector('.framework-hidden')?.textContent?.trim()).toBe(
'1 injector and 2 providers hidden',
);
expect(localStorage.getItem('pangular-pref:injectors.hideFramework')).toBe('true');

fixture.componentInstance.select('inj-r');
await settle(fixture);
expect(providerTokens(fixture)).toEqual(['TripStore']);
expect(host(fixture).querySelector('.framework-note')?.textContent).toMatch(
/2 framework providers hidden/,
);
const pill = host(fixture).querySelector('.row[data-id="inj-r"] .provides');
expect(pill?.textContent?.replace(/\s+/g, ' ').trim()).toBe('provides 1');

// A second visit starts with the saved choice.
const again = await render();
expect(again.componentInstance.hideFramework()).toBe(true);
});

it('shows the framework providers again from the note', async () => {
localStorage.setItem('pangular-pref:injectors.hideFramework', 'true');
const fixture = await render();
fixture.componentInstance.setView('environment');
fixture.componentInstance.select('inj-r');
await settle(fixture);
[...host(fixture).querySelectorAll<HTMLButtonElement>('.framework-note button')][0]!.click();
await settle(fixture);
expect(providerTokens(fixture)).toEqual(['ApplicationRef', 'NgZone', 'TripStore']);
expect(host(fixture).querySelectorAll('.providers .flag').length).toBeGreaterThan(0);
expect(localStorage.getItem('pangular-pref:injectors.hideFramework')).toBe('false');
});

it('turns the filter off for now to reveal a hidden injector, without saving that', async () => {
localStorage.setItem('pangular-pref:injectors.hideFramework', 'true');
const fixture = await render();
fixture.componentInstance.reveal('inj-p');
await settle(fixture);
expect(fixture.componentInstance.hideFramework()).toBe(false);
expect(fixture.componentInstance.selectedId()).toBe('inj-p');
expect(localStorage.getItem('pangular-pref:injectors.hideFramework')).toBe('true');
});
});

describe('Log to console for a provider', () => {
afterEach(() => {
calls.length = 0;
document.body.innerHTML = '';
});

it("asks the shown page to log the provider's value and shows only the message", async () => {
const fixture = await render();
fixture.componentInstance.setView('environment');
fixture.componentInstance.select('inj-r');
await settle(fixture);
const log = [...host(fixture).querySelectorAll<HTMLButtonElement>('button.log')].find((b) =>
b.textContent?.includes('TripStore'),
)!;
expect(log.textContent?.replace(/\s+/g, ' ').trim()).toBe('Log to console : TripStore');
log.click();
await settle(fixture);
expect(calls.find((c) => c.name === 'request-console-global')?.payload).toEqual({
pageId: 'p1',
mode: 'log',
target: { kind: 'provider', injectorId: 'inj-r', token: 'TripStore', index: 2 },
});
expect(log.parentElement?.querySelector('[role="status"]')?.textContent).toBe(
'Logged TripStore in the page console.',
);
});

it('offers "Show in Components" only when the Components tab is on', async () => {
const fixture = await render();
const emitted: unknown[] = [];
fixture.componentInstance.showComponent.subscribe((focus) => emitted.push(focus));
fixture.componentInstance.select('inj-1');
await settle(fixture);
expect(host(fixture).querySelector('.show-component')).toBeNull();
fixture.componentRef.setInput('canShowComponent', true);
await settle(fixture);
host(fixture).querySelector<HTMLButtonElement>('.show-component')!.click();
expect(emitted).toEqual([{ id: 'c1', pageId: 'p1' }]);
});
});
170 changes: 170 additions & 0 deletions app/src/__tests__/injector-component-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

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

const { App } = await import('../app');
const { ComponentTree } = await import('../pages/component-tree');
const { DiInspector } = await import('../pages/di-inspector');

const element = (
id: string,
name: string,
component: string,
componentId: string,
children = [],
) => ({
injector: {
id,
type: 'element',
name,
providerCount: 0,
component,
componentId,
directives: [component],
},
providers: [],
children,
});

const state: Record<string, unknown> = {
'component-tree': {
pages: {
p1: {
pageId: 'p1',
title: 'Demo',
url: '/',
roots: [
{
id: 'c1',
name: 'App',
tag: 'app-root',
children: [{ id: 'c7', name: 'Shell', tag: 'app-shell', children: [] }],
},
],
count: 2,
detail: null,
reportedAt: 1,
},
},
},
'injector-tree': {
pages: {
p1: {
pageId: 'p1',
reportedAt: 1,
roots: [
{
...element('inj-1', 'app-root', 'App', 'c1'),
children: [element('inj-2', 'app-shell', 'Shell', 'c7')],
},
],
environment: [],
},
},
},
};

function fakeClient(): DevframeRpcClient {
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }),
};
return {
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
} as unknown as DevframeRpcClient;
}

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

const el = (fixture: ComponentFixture<unknown>) => fixture.nativeElement as HTMLElement;
const activeTab = (fixture: ComponentFixture<unknown>) =>
el(fixture).querySelector('nav button.active')?.textContent?.trim();
const button = (fixture: ComponentFixture<unknown>, text: string) =>
[...el(fixture).querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.textContent?.replace(/\s+/g, ' ').replace(/ :/g, ':').trim() === text,
);

function child<T>(fixture: ComponentFixture<unknown>, type: abstract new (...args: never[]) => T) {
return fixture.debugElement.query((node) => node.componentInstance instanceof type)
.componentInstance as T;
}

async function open(tab: string) {
location.hash = `#tab=${tab}`;
const fixture = TestBed.createComponent(App);
document.body.append(el(fixture));
await settle(fixture);
connection.resolve(fakeClient());
await settle(fixture);
return fixture;
}

afterEach(() => {
document.body.innerHTML = '';
location.hash = '';
sessionStorage.clear();
});

describe('links between the Components and Injectors tabs', () => {
it("opens the selected component's element injector from the Components tab", async () => {
const fixture = await open('components');
el(fixture).querySelector<HTMLElement>('.row[data-id="c7"]')!.click();
await settle(fixture);

const show = button(fixture, 'Show in Injectors: Shell');
expect(show).toBeDefined();
show!.click();
await settle(fixture);

expect(activeTab(fixture)).toBe('Injectors');
const di = child(fixture, DiInspector);
expect(di.selectedId()).toBe('inj-2');
expect(el(fixture).querySelector('.row[data-id="inj-2"]')?.getAttribute('aria-selected')).toBe(
'true',
);
expect(fixture.componentInstance.injectorFocus()).toBeNull();
});

it('opens the owning component from an element injector', async () => {
const fixture = await open('injectors');
el(fixture).querySelector<HTMLElement>('.row[data-id="inj-2"]')!.click();
await settle(fixture);

button(fixture, 'Show in Components: Shell')!.click();
await settle(fixture);

expect(activeTab(fixture)).toBe('Components');
expect(child(fixture, ComponentTree).selectedId()).toBe('c7');
});

it('says so when the component has no element injector on the page', async () => {
const fixture = await open('injectors');
fixture.componentInstance.showInjector({ componentId: 'c404', pageId: 'p1' });
await settle(fixture);
const status = [...el(fixture).querySelectorAll('[role="status"]')].map((n) =>
n.textContent?.trim(),
);
expect(status).toContain("That component's injector is not on this page.");
expect(fixture.componentInstance.injectorFocus()).toBeNull();
});
});
Loading
Loading