Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
61 changes: 59 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,16 @@ jobs:
run: pnpm test:axe

# The e2e jobs below are slow, so they run only when the package, the panel, the SSR demo, the
# tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. A diff that cannot
# be computed (a new branch, a force push) runs them.
# tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. The engines-floor
# job runs only when what the packed package is built from changes. A diff that cannot be
# computed (a new branch, a force push) runs them all.
changes:
name: Changed paths
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
e2e: ${{ steps.diff.outputs.e2e }}
package: ${{ steps.diff.outputs.package }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -121,6 +123,61 @@ jobs:
else
echo "e2e=true" >> "$GITHUB_OUTPUT"
fi
# What the packed package is built from: the package, the panel it bundles and the toolchain.
packed=(packages/devtools app package.json pnpm-lock.yaml pnpm-workspace.yaml .nvmrc
.github/actions/setup .github/workflows/ci.yml)
if git diff --quiet "$BASE" HEAD -- "${packed[@]}"; then
echo "package=false" >> "$GITHUB_OUTPUT"
else
echo "package=true" >> "$GITHUB_OUTPUT"
fi

# The source tests can't run on 22.12 (Node runs .ts files only from 22.18, and the build tools
# need newer versions), so this job checks the packed package there instead.
engines-floor:
name: Packed package on Node.js 22.12
needs: changes
if: needs.changes.outputs.package == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: ./.github/actions/setup

# `prepack` builds the library and the panel first, on the `.nvmrc` version.
- name: Pack the package
run: pnpm --filter @pangular-inspector/devtools pack --pack-destination "$RUNNER_TEMP/pack"

# The engines floor: the lowest version `engines.node` in packages/devtools/package.json allows.
# Keep the two in step.
- uses: actions/setup-node@v7
with:
node-version: 22.12.0

- name: Install the tarball into an empty project
working-directory: ${{ runner.temp }}
run: |
mkdir floor
cd floor
npm init -y > /dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP"/pack/*.tgz

# The Node.js entry points from `exports` in packages/devtools/package.json. The browser
# entries (overlay, popup and the hooks) need a DOM. `http` is left out too: it imports
# the optional Angular peers, which load in plain Node.js only with `@angular/compiler`
# (JIT), and the app's build bundles it.
- name: Run the CLI and load the entry points
working-directory: ${{ runner.temp }}/floor
run: |
node --version
npx pangular --version
for entry in '' /devframe /config /cli /hub /ssr /vite; do
node -e "import('@pangular-inspector/devtools$entry')"
done
node -e "require('@pangular-inspector/devtools/hub')"

e2e-security:
name: Security e2e
Expand Down
38 changes: 38 additions & 0 deletions app/src/__tests__/analog-inspector-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,41 @@ describe('Analog route filter', () => {
expect(box.value).toBe('');
});
});

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

it('marks pages in named outlets with the outlet name', async () => {
const route = (file: string) => ({
id: file,
fullPath: '/',
file,
kind: 'page' as const,
params: [],
children: [],
});
const fixture = await render(
client({
state: {
pages: [
{
pageId: 'A',
url: '/inbox(sidebar:chat)',
chain: [
{ path: '/inbox', file: '/src/app/pages/inbox.page.ts' },
{ path: '/chat', file: '/src/app/pages/chat.page.ts', outlet: 'sidebar' },
],
},
],
},
}),
);
const inspector = fixture.componentInstance;
expect(inspector.openText(route('/src/app/pages/inbox.page.ts'))).toBe('open');
expect(inspector.isOpen(route('/src/app/pages/chat.page.ts'))).toBe(true);
expect(inspector.openText(route('/src/app/pages/chat.page.ts'))).toBe('open in sidebar');
expect(inspector.isOpen(route('/src/app/pages/about.page.ts'))).toBe(false);
});
});
14 changes: 14 additions & 0 deletions app/src/__tests__/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,18 @@ describe('App tab bar', () => {
expect(now.filter((b) => b.tabIndex === 0)).toEqual([now[2]]);
fixture.destroy();
});

it('puts the scrolling content in the Tab order with a name, and still focuses it from code', async () => {
connection.answers = [true];
const fixture = TestBed.createComponent(App);
document.body.appendChild(host(fixture));
await settle(fixture);

const main = host(fixture).querySelector<HTMLElement>('main')!;
expect(main.tabIndex).toBe(0);
expect(main.getAttribute('aria-label')).toBeTruthy();
main.focus();
expect(document.activeElement).toBe(main);
fixture.destroy();
});
});
48 changes: 41 additions & 7 deletions app/src/__tests__/cd-recording-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,13 @@ describe('CdRecording status', () => {
pageId: 'p1',
on: true,
});
expect(live()).toBe('Recording started.');
// The server took the request; the page has not said it records yet.
expect(live()).not.toContain('started');
expect(visible()).toBe('Starting…');

await show(true, 3);
expect(live()).toBe('Recording started.');
expect(visible()).toBe('Recording. 3 cycles so far.');

await click('Stop recording');
// The page's last report still brings cycles: the total waits for it.
Expand All @@ -89,20 +92,51 @@ describe('CdRecording status', () => {
expect(live()).toBe('Cleared the recorded cycles.');
});

it('announces the count it has when the last report does not come', async () => {
await show(true, 2);
/** Clicks with fake timers, so the confirmation wait can run out. */
async function clickAndWait(text: string, ms: number) {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
button('Stop recording').click();
button(text).click();
await vi.advanceTimersByTimeAsync(0);
fixture.detectChanges();
expect(live()).not.toContain('stopped');
await vi.advanceTimersByTimeAsync(3000);
const before = live();
await vi.advanceTimersByTimeAsync(ms);
fixture.detectChanges();
expect(live()).toBe('Recording stopped. 2 cycles recorded.');
return before;
} finally {
vi.useRealTimers();
}
}

it('says the page did not confirm when no report shows the recording on', async () => {
const before = await clickAndWait('Record', 3000);
expect(before).not.toContain('started');
const failed =
'The page did not confirm that the recording started. The tab may be in the background, closed or reloading.';
expect(live()).toBe(failed);
expect(visible()).toBe(failed);
// The page still reports no recording, so the button still offers Record.
expect(button('Record')).toBeTruthy();
expect(button('Stop recording')).toBeUndefined();
});

it('announces the start once a late report confirms it', async () => {
await clickAndWait('Record', 3000);
expect(live()).toContain('did not confirm');
await show(true, 1);
expect(live()).toBe('Recording started.');
expect(visible()).toBe('Recording. 1 cycle so far.');
expect(button('Stop recording')).toBeTruthy();
});

it('says the page did not confirm when the report after Stop does not come', async () => {
await show(true, 2);
const before = await clickAndWait('Stop recording', 3000);
expect(before).not.toContain('stopped');
expect(live()).toBe(
'The page did not confirm that the recording stopped. The tab may be in the background, closed or reloading.',
);
expect(button('Stop recording')).toBeTruthy();
});

it('announces a repeated message again', async () => {
Expand Down
117 changes: 114 additions & 3 deletions app/src/__tests__/component-tree-defer.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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 { ComponentTree } from '../pages/component-tree';

function client(pages: Record<string, unknown>): DevframeRpcClient {
function client(
pages: Record<string, unknown>,
call: (...args: unknown[]) => Promise<unknown> = () => Promise.resolve([]),
): DevframeRpcClient {
const rpc = {
call: () => Promise.resolve([]),
call,
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
Expand All @@ -19,6 +22,7 @@ function client(pages: Record<string, unknown>): DevframeRpcClient {
describe('defer blocks', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
});

it('style the state badge apart from the empty-state box', async () => {
Expand Down Expand Up @@ -120,4 +124,111 @@ describe('defer blocks', () => {
expect(await pill(page())).toBe('2');
expect(await pill(page(true))).toBe('2+');
});

it('shows the notes the agent tool prints, the @error block and the block times', async () => {
const hour = 3_600_000;
// Fix the panel clock so the ages stay exact; timers stay real for the waits.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(1_700_000_000_000);
// The page's clock runs an hour ahead of the panel's.
const pageNow = Date.now() + hour;
const block = (id: string, extra: Record<string, unknown>) => ({
id,
owner: { id: 'c1', name: 'App', tag: 'app-root' },
state: 'complete',
hydration: 'not-configured',
triggers: ['on idle'],
rootIds: [],
since: pageNow,
hasErrorBlock: false,
...extra,
});
const page = {
pageId: 'p1',
roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
count: 1,
detail: null,
reportedAt: 1000,
clockOffset: -hour,
deferBlocks: [
block('d1', { state: 'error', hasErrorBlock: true }),
block('d2', { state: 'placeholder', since: pageNow - 30_000 }),
block('d3', { state: 'placeholder', since: pageNow - 2_000 }),
block('d4', { hydration: 'dehydrated', triggers: ['hydrate on viewport'] }),
block('d5', {
state: 'loading',
loading: { minimumTime: 1000, afterTime: 100 },
placeholder: { minimumTime: 500 },
}),
],
};
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client({ p1: page }));
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
const rows = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('.defer-row'),
);
const flags = (row: HTMLElement) =>
Array.from(row.querySelectorAll('.flag')).map((el) => el.textContent?.trim());
expect(flags(rows[0]!)).toEqual(['failed', '@error block']);
expect(rows[0]!.querySelector('.note-failed')?.getAttribute('title')).toBe('failed to load');
// 30 s on the page's clock, not an hour and 30 s.
expect(flags(rows[1]!)).toEqual(['stuck 30s']);
expect(rows[1]!.querySelector('.note-stuck')?.getAttribute('title')).toBe(
'on its placeholder for 30s',
);
expect(flags(rows[2]!)).toEqual([]);
expect(flags(rows[3]!)).toEqual(['dehydrated']);
expect(rows[4]!.querySelector('.timings')?.textContent?.trim()).toBe(
'placeholder min 500ms · loading after 100ms, min 1s',
);
expect(rows[0]!.querySelector('.timings')).toBeNull();
fixture.destroy();
});

it('highlights every root element of a block in one request', async () => {
const call = vi.fn((..._args: unknown[]) => Promise.resolve([] as unknown));
const block = (id: string, rootIds: string[]) => ({
id,
owner: { id: 'c1', name: 'App', tag: 'app-root' },
state: 'complete',
hydration: 'not-configured',
triggers: [],
rootIds,
});
const page = {
pageId: 'p1',
roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
count: 1,
detail: null,
reportedAt: 1000,
deferBlocks: [block('d1', ['e1', 'e2']), block('d2', ['e3']), block('d3', [])],
};
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client({ p1: page }, call));
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
const rows = (fixture.nativeElement as HTMLElement).querySelectorAll('.defer-row');
const highlights = () =>
call.mock.calls.filter((args) => args[0] === 'request-page-highlight').map((args) => args[1]);
call.mockClear();
for (const row of Array.from(rows)) {
row.dispatchEvent(new Event('mouseenter'));
row.dispatchEvent(new Event('mouseleave'));
}
expect(highlights()).toEqual([
{ pageId: 'p1', ids: ['e1', 'e2'] },
null,
{ pageId: 'p1', id: 'e3' },
null,
{ pageId: 'p1', id: 'c1' },
null,
]);
fixture.destroy();
});
});
Loading
Loading