Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ jobs:
range: '~19.2.0'
- angular: '20.0'
range: '~20.0.0'
# The last 20.x minor. Several debug APIs arrive within 20.x (`ɵgetTransferState` in 20.2,
# `ɵgetRouterInstance` in 20.3.5), and resource internals have no debug names until
# 21.0.1, so the folded resources test is skipped here and the unfolded case runs instead.
- angular: '20.3'
range: '~20.3.0'
- angular: '21'
range: '^21.0.0'
- angular: '22'
Expand Down
44 changes: 44 additions & 0 deletions app/src/__tests__/cd-recording-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,47 @@ describe('CdRecording status', () => {
]);
});
});

describe('CdRecording on a page that could not record', () => {
afterEach(() => {
fixture?.destroy();
TestBed.resetTestingModule();
});

async function showUnsupported(capabilities: Record<string, boolean>, version: string) {
const angular = {
pages: { p1: { pageId: 'p1', reportedAt: 1, version, capabilities } },
};
const rpc = {
call: () => Promise.resolve({ ok: true }),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'page-angular' ? angular : null),
on: () => () => {},
}),
};
fixture = TestBed.createComponent(CdRecording);
fixture.componentRef.setInput('pageId', 'p1');
fixture.componentRef.setInput('rpc', {
scope: () => ({ rpc }),
} as unknown as DevframeRpcClient);
fixture.componentRef.setInput('page', { ...page(false, 0), supported: false });
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
return host().textContent!.replace(/\s+/g, ' ');
}

it('names the version when the page lacks the profiler hook', async () => {
const text = await showUnsupported({ profiler: false }, '19.2.0');
expect(text).toContain('Recording change detection needs Angular 20.0 or later');
expect(text).not.toContain('could not attach');
});

it('says the recording could not attach, not the version, when the page has the hook', async () => {
const text = await showUnsupported({ profiler: true }, '22.1.0');
expect(text).toContain('could not attach to it yet');
expect(text).not.toContain('needs Angular 20.0');
expect(text).not.toContain('Not available on Angular 22.1');
});
});
56 changes: 56 additions & 0 deletions app/src/__tests__/route-timeline-phases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { RouteTimeline } from '../pages/route-timeline';
import type { RouterPage } from '../pages/router-types';

function fakeClient(): DevframeRpcClient {
const rpc = { call: () => Promise.resolve(undefined), callEvent: () => Promise.resolve() };
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();
}
}

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

describe('RouteTimeline phase colors', () => {
it('paints the bar segments and the legend with the --phase-<name> tokens', async () => {
const page: RouterPage = {
pageId: 'p1',
reportedAt: 1,
snapshot: null,
navigations: [
{
id: 1,
url: '/a',
trigger: 'imperative',
startedAt: 1,
endedAt: 41,
outcome: 'succeeded',
phases: { recognize: 10, guards: 10, resolve: 10, activate: 10, total: 40 },
},
],
};
const fixture = TestBed.createComponent(RouteTimeline);
fixture.componentRef.setInput('page', page);
fixture.componentRef.setInput('rpc', fakeClient());
const root = fixture.nativeElement as HTMLElement;
document.body.append(root);
await settle(fixture);

const backgrounds = (selector: string) =>
[...root.querySelectorAll<HTMLElement>(selector)].map((node) => node.style.background);
const expected = ['recognize', 'guards', 'resolve', 'activate'].map(
(phase) => `var(--phase-${phase})`,
);
expect(backgrounds('.bar span')).toEqual(expected);
expect(backgrounds('.legend i')).toEqual(expected);
});
});
158 changes: 158 additions & 0 deletions app/src/__tests__/signal-restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SignalInspector } from '../pages/signal-inspector';

const at = (s: number) => new Date(2026, 0, 1, 10, 0, s).getTime();

const graph = {
pageId: 'p1',
nodes: [
{
id: '1',
kind: 'signal',
label: 'query',
epoch: 2,
value: 'c',
locator: { id: 'c1', key: 'query' },
},
{ id: '2', kind: 'computed', label: 'shout', epoch: 2, value: 'C' },
],
edges: [],
component: { id: 'c1', name: 'App', tag: 'app-root', path: 'app-root' },
history: {
'1': [
{ epoch: 0, value: 'a', at: at(1), source: 'initial' },
{ epoch: 1, value: 'b', at: at(2), source: 'write' },
{ epoch: 2, value: 'c', at: at(3), source: 'write' },
],
'2': [
{ epoch: 1, value: 'A', at: at(1), source: 'initial' },
{ epoch: 2, value: 'C', at: at(3), source: 'sample' },
],
},
};

async function render(
meta: Record<string, unknown> = {},
call: (name: string, args?: unknown) => Promise<unknown> = () => Promise.resolve([]),
page: Record<string, unknown> = { pageId: 'p1', roots: [] },
) {
const state = (value: unknown) => Promise.resolve({ value: () => value, on: () => () => {} });
const rpc = {
call,
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
state(
name === 'signal-graph'
? { graph, pages: { p1: graph } }
: name === 'component-tree'
? { pages: { p1: page } }
: { pages: {} },
),
};
const fixture = TestBed.createComponent(SignalInspector);
fixture.componentRef.setInput('rpc', {
connectionMeta: meta,
scope: () => ({ rpc }),
} as unknown as DevframeRpcClient);
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
return fixture;
}

async function select(fixture: ComponentFixture<SignalInspector>, id: string) {
fixture.componentInstance.selectedId.set(id);
await fixture.whenStable();
fixture.detectChanges();
}

const restoreButtons = (fixture: ComponentFixture<unknown>) =>
Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('button.restore'),
);

const text = (fixture: ComponentFixture<unknown>) =>
(fixture.nativeElement as HTMLElement).textContent?.replace(/\s+/g, ' ') ?? '';

describe('Restore on signal history', () => {
afterEach(() => TestBed.resetTestingModule());

it('offers a named Restore button on each past entry of a located signal', async () => {
const fixture = await render();
await select(fixture, '1');
const buttons = restoreButtons(fixture);
// The newest entry is the current value, so it has no button.
expect(buttons.map((b) => b.getAttribute('aria-label'))).toEqual([
'Restore value from 10:00:02.000',
'Restore value from 10:00:01.000',
]);
expect(buttons.every((b) => b.type === 'button' && !b.disabled)).toBe(true);
});

it('sends the entry to the page and shows the answer', async () => {
const call = vi.fn(async (name: string) =>
name === 'request-signal-restore' ? { ok: true, key: 'query' } : [],
);
const fixture = await render({}, call);
await select(fixture, '1');
restoreButtons(fixture)[0].click();
await fixture.whenStable();
fixture.detectChanges();
expect(call).toHaveBeenCalledWith('request-signal-restore', {
pageId: 'p1',
request: { component: 'c1', node: '1', epoch: 1 },
});
const status = (fixture.nativeElement as HTMLElement).querySelector('.restore-result');
expect(status?.getAttribute('role')).toBe('status');
expect(status?.textContent?.trim()).toBe('Restored query to the value from 10:00:02.000.');
});

it('shows the page refusal as an error', async () => {
const fixture = await render({}, async (name) =>
name === 'request-signal-restore' ? { error: 'Epoch 1 is no longer kept.' } : [],
);
await select(fixture, '1');
restoreButtons(fixture)[0].click();
await fixture.whenStable();
fixture.detectChanges();
const status = (fixture.nativeElement as HTMLElement).querySelector('.restore-result');
expect(status?.classList.contains('restore-error')).toBe(true);
expect(status?.textContent?.trim()).toBe('Epoch 1 is no longer kept.');
});

it('has no Restore for a node without a locator', async () => {
const fixture = await render();
await select(fixture, '2');
expect(text(fixture)).toContain('Value history');
expect(restoreButtons(fixture)).toHaveLength(0);
});

it('hides Restore with a reason when component writes are off', async () => {
const fixture = await render({ configs: { pangular: { actions: { components: false } } } });
await select(fixture, '1');
expect(restoreButtons(fixture)).toHaveLength(0);
expect(text(fixture)).toContain(
'Restore is off. Editing component properties and signals is turned off in the devtools config (actions.components).',
);
});

it('hides Restore in a saved report and on a native page', async () => {
const report = await render({ backend: 'static' });
await select(report, '1');
expect(restoreButtons(report)).toHaveLength(0);
expect(text(report)).toContain('Restore is off. A saved report cannot write to the app.');
TestBed.resetTestingModule();

const native = await render({}, undefined, {
pageId: 'p1',
roots: [],
platform: 'nativescript',
});
await select(native, '1');
expect(restoreButtons(native)).toHaveLength(0);
expect(text(native)).toContain('Restore is off. It needs the app open in a browser.');
});
});
77 changes: 77 additions & 0 deletions app/src/__tests__/tint-contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Rgb = [number, number, number];
type Tokens = Record<string, string>;

const STYLES = new URL('../styles/', import.meta.url).pathname;
const PAGES = new URL('../pages/', import.meta.url).pathname;
const ACCENTS = ['amber', 'ember', 'gold'];
const TONES = ['ok', 'warn', 'danger', 'accent'];

Expand Down Expand Up @@ -99,3 +100,79 @@ describe('text on tinted pills', () => {
}
}
});

// Category hues (Analog route kinds, render modes, server calls and method
// badges): `--hue-<name>-text` sits on a plain surface-2 pill or on a 12 to 16%
// tint of `--hue-<name>`, over any surface or a selected row in the Analog view.
const HUES = [
'slate',
'sky',
'info',
'blue',
'teal',
'green',
'yellow',
'orange',
'pink',
'purple',
];

describe('text on category hue pills', () => {
const base = theme('amber');
for (const mode of ['dark', 'light'] as const) {
const analog = VIEW_ACCENTS[mode].analog;
for (const hue of HUES) {
it(`${mode}: --hue-${hue}-text keeps 4.5:1 on plain and tinted pills`, () => {
const tokens = base[mode];
expect(tokens[`hue-${hue}`], `--hue-${hue}`).toBeDefined();
expect(tokens[`hue-${hue}-text`], `--hue-${hue}-text`).toBeDefined();
const text = rgb(tokens[`hue-${hue}-text`]);
for (const surface of ['bg', 'surface', 'surface-2', 'surface-3']) {
expect(ratio(text, rgb(tokens[surface])), `on --${surface}`).toBeGreaterThanOrEqual(4.5);
}
for (const accent of [tokens['accent'], analog.accent]) {
const min = worst({ ...tokens, accent }, `hue-${hue}`);
expect(min.ratio, `${min.on} (accent ${accent})`).toBeGreaterThanOrEqual(4.5);
}
});
}
}
});

// Router timeline phases are graphics (bar segments and legend dots), so they
// need 3:1 on the bar track and every surface the timeline sits on.
const PHASES = ['recognize', 'guards', 'resolve', 'activate'];

describe('router timeline phase colours', () => {
const base = theme('amber');
for (const mode of ['dark', 'light'] as const) {
for (const phase of PHASES) {
it(`${mode}: --phase-${phase} keeps 3:1 on every surface`, () => {
const tokens = base[mode];
expect(tokens[`phase-${phase}`], `--phase-${phase}`).toBeDefined();
const color = rgb(tokens[`phase-${phase}`]);
for (const surface of ['bg', 'surface', 'surface-2', 'surface-3']) {
expect(ratio(color, rgb(tokens[surface])), `on --${surface}`).toBeGreaterThanOrEqual(3);
}
});
}
}
});

// Pages that used hard-coded hex colors now take them from the tokens above, so
// both themes get a value these tests check.
const TOKEN_ONLY_PAGES = ['analog-inspector.ts', 'route-timeline.ts'];

describe('pages that use theme tokens only', () => {
for (const file of TOKEN_ONLY_PAGES) {
it(`${file} has no hex colors`, async () => {
// The panel tsconfig has no Node types, so read the file through a loose import.
const fsName = 'node:fs';
const fs = (await import(/* @vite-ignore */ fsName)) as {
readFileSync(path: string, encoding: 'utf8'): string;
};
const source = fs.readFileSync(`${PAGES}${file}`, 'utf8');
expect(source.match(/#[0-9a-f]{3,8}\b/gi) ?? []).toEqual([]);
});
}
});
Loading
Loading