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
6 changes: 1 addition & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,7 @@ jobs:
pnpm devtools:build-pkg
pnpm build --configuration development

# Starts the demo on port 5401 itself. Two checks fail today on open issues and are marked
# as known failures in the script, so they print XFAIL and do not fail the job:
# - expiry: a crashed tab stays listed for about 150 s (#422);
# - stress: the forms collector keeps 100 children per group and does not say so (#423).
# The soak check (`soak --minutes 30`) is manual only.
# Starts the demo on port 5401 itself. The soak check (`soak --minutes 30`) is manual only.
- name: Check multitab, expiry, lifecycle and stress
run: pnpm e2e:resilience multitab expiry lifecycle stress --json dist/e2e-resilience.json

Expand Down
124 changes: 124 additions & 0 deletions app/src/__tests__/component-tree-narrow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ComponentTree } from '../pages/component-tree';

const LONG = 'CustomerOrderHistoryTableWithInlineFiltersComponent';

const roots = [
{
id: 'app',
name: 'App',
tag: 'app-root',
children: [{ id: 'long', name: LONG, tag: 'app-customer-order-history-table', children: [] }],
},
];

function client(): DevframeRpcClient {
const state: Record<string, unknown> = {
'component-tree': {
pages: {
a: { pageId: 'a', title: 'a', url: '/', roots, count: 2, detail: null, reportedAt: 1 },
},
},
};
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({ value: () => state[name] ?? null, 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 setup() {
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client());
document.body.appendChild(fixture.nativeElement);
await settle(fixture);
return { fixture, root: fixture.nativeElement as HTMLElement };
}

/** jsdom has no layout, so names that are cut off report a wider content than their box. */
function cutOff(names: string[]) {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (
this: HTMLElement,
) {
return names.includes(this.textContent?.trim() ?? '') ? 400 : 0;
});
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(120);
}

afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
TestBed.resetTestingModule();
});

describe('component tree at narrow widths', () => {
it('keeps a name and a hover title on every toolbar action', async () => {
const { fixture, root } = await setup();
const actions = Array.from(
root.querySelectorAll<HTMLButtonElement>('.toolbar .tree-action, .toolbar .pick'),
);
expect(actions.map((b) => b.querySelector('.label')?.textContent?.trim())).toEqual([
'Expand all',
'Collapse all',
'Pick component on page',
]);
for (const action of actions) {
expect(action.title).toBe(action.textContent?.trim());
}
fixture.destroy();
});

it('gives each name and tag its full text as a hover title', async () => {
const { fixture, root } = await setup();
const row = root.querySelector<HTMLElement>('.row[data-id="long"]')!;
expect(row.querySelector<HTMLElement>('.name')!.title).toBe(LONG);
expect(row.querySelector<HTMLElement>('.tag')!.title).toBe(
'<app-customer-order-history-table>',
);
fixture.destroy();
});

it('shows the full text of a cut off row while it has focus', async () => {
cutOff([LONG]);
const { fixture, root } = await setup();
const row = root.querySelector<HTMLElement>('.row[data-id="long"]')!;
row.focus();
await settle(fixture);
const tip = root.querySelector<HTMLElement>('.name-tip');
expect(tip?.textContent?.trim()).toBe(`${LONG} <app-customer-order-history-table>`);
expect(tip?.getAttribute('aria-hidden')).toBe('true');

row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await settle(fixture);
expect(root.querySelector('.name-tip')).toBeNull();

row.blur();
row.focus();
await settle(fixture);
expect(root.querySelector('.name-tip')).not.toBeNull();
row.blur();
await settle(fixture);
expect(root.querySelector('.name-tip')).toBeNull();
fixture.destroy();
});

it('shows no tip for a row that fits', async () => {
cutOff([LONG]);
const { fixture, root } = await setup();
root.querySelector<HTMLElement>('.row[data-id="app"]')!.focus();
await settle(fixture);
expect(root.querySelector('.name-tip')).toBeNull();
fixture.destroy();
});
});
64 changes: 64 additions & 0 deletions app/src/__tests__/dashboard-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { Dashboard } from '../pages/dashboard';

/** A trusted client whose `__connection.json` config has no redaction, like a current server's. */
function fakeClient(backend: string, calls: string[]): DevframeRpcClient {
const rpc = {
call: (name: string) => {
calls.push(name);
if (name === 'redaction-config') {
return Promise.resolve({ secretNames: ['passport'], unmask: ['pin'] });
}
return new Promise(() => {});
},
callEvent: () => Promise.resolve(),
sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }),
};
return {
status: 'connected',
connectionMeta: { backend, configs: { pangular: {} } },
scope: () => ({ rpc }),
events: { on: () => () => {} },
} 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();
}
}

function configText(fixture: ComponentFixture<unknown>): string {
return (fixture.nativeElement as HTMLElement).querySelector('section.config')?.textContent ?? '';
}

afterEach(() => {
document.body.innerHTML = '';
});

describe('Dashboard redaction rows', () => {
it('lists the secret names a trusted panel reads from the server', async () => {
const calls: string[] = [];
const fixture = TestBed.createComponent(Dashboard);
fixture.componentRef.setInput('rpc', fakeClient('websocket', calls));
await settle(fixture);
expect(calls).toContain('redaction-config');
const text = configText(fixture);
expect(text).toContain('Extra secret names');
expect(text).toContain('passport');
expect(text).toContain('Unmasked names');
expect(text).toContain('pin');
});

it('does not ask a static report, which carries no secret names', async () => {
const calls: string[] = [];
const fixture = TestBed.createComponent(Dashboard);
fixture.componentRef.setInput('rpc', fakeClient('static', calls));
await settle(fixture);
expect(calls).not.toContain('redaction-config');
expect(configText(fixture)).not.toContain('Extra secret names');
});
});
19 changes: 19 additions & 0 deletions app/src/__tests__/shell-deep-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ describe('deep links', () => {
booted.fixture.destroy();
});

it('switches tabs when only the hash changes after load', async () => {
const { fixture, app } = await boot('#tab=signals');
expect(app.tab()).toBe('signals');
location.hash = '#tab=forms';
await settle(fixture);
expect(app.tab()).toBe('forms');
location.hash = '#tab=components&id=9&page=p3';
await settle(fixture);
expect(app.tab()).toBe('components');
expect(app.componentFocus()).toEqual({ id: '9', pageId: 'p3' });
location.hash = '#tab=settings';
await settle(fixture);
expect(app.tab()).toBe('settings');
location.hash = '#tab=nope';
await settle(fixture);
expect(app.tab()).toBe('settings');
fixture.destroy();
});

it('follows a link the server sends to open panels', async () => {
const { fixture, app } = await boot();
server.handlers.get('open-in-panel')!({ tab: 'components', id: '7', page: 'p2' });
Expand Down
15 changes: 15 additions & 0 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ function readView(): View | null {
'[style.--accent]': 'viewAccent()?.accent ?? null',
'[style.--accent-text]': 'viewAccent()?.text ?? null',
'(window:message)': 'inspectFromPanel($event)',
'(window:hashchange)': 'followHash()',
'(document:keydown)': 'onGlobalKey($event)',
},
imports: [
Expand Down Expand Up @@ -1631,6 +1632,20 @@ export class App implements OnInit, OnDestroy {
return true;
}

/**
* Follows a `#tab=` link that changes after load (a link inside the panel, or
* a script setting `location.hash`), the same way as one on load.
*/
followHash() {
const link = parsePanelLink(location.hash);
if (!link) return;
if (link.tab === 'settings') {
if (this.tab() !== 'settings') this.toggleSettings();
return;
}
this.openLink(link);
}

/** Listens for the commands and links the server sends to every open panel. */
private listenForServer(client: DevframeRpcClient) {
const rpc = client.scope('pangular').rpc as unknown as {
Expand Down
22 changes: 22 additions & 0 deletions app/src/devtools-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ export function panelConfig(client: DevframeRpcClient | null): ResolvedPangularC
return configFromConnection(client?.connectionMeta);
}

function names(value: unknown): string[] | undefined {
return Array.isArray(value) && value.every((name) => typeof name === 'string')
? [...value]
: undefined;
}

/**
* `config` with the server's redaction config, which a trusted client reads
* with `redaction-config`: `__connection.json` leaves it out. A missing or
* malformed answer leaves `config` as it is.
*/
export function withRedaction(
config: ResolvedPangularConfig,
answer: unknown,
): ResolvedPangularConfig {
if (!answer || typeof answer !== 'object') return config;
const secretNames = names((answer as { secretNames?: unknown }).secretNames);
const unmask = names((answer as { unmask?: unknown }).unmask);
if (!secretNames || !unmask) return config;
return { ...config, redaction: { secretNames, unmask } };
}

export function actionAllowed(client: DevframeRpcClient | null, action: PangularAction): boolean {
return panelConfig(client).actions[action];
}
Expand Down
Loading
Loading