diff --git a/app/src/__tests__/cd-cycle-chart.test.ts b/app/src/__tests__/cd-cycle-chart.test.ts index 5713f7c8..419982a2 100644 --- a/app/src/__tests__/cd-cycle-chart.test.ts +++ b/app/src/__tests__/cd-cycle-chart.test.ts @@ -99,7 +99,20 @@ describe('CdCycleChart', () => { expect(heights[0]).toBeCloseTo((4 / 30) * 100); const budget = host().querySelector('.budget')!; expect(parseFloat(budget.style.bottom)).toBeCloseTo((FRAME_BUDGET_MS / 30) * 100); - expect(budget.textContent).toContain('16.6 ms budget'); + expect(budget.textContent).toContain('16 ms budget'); + }); + + it('draws the line at the recording slowMs and flags only cycles over it', async () => { + fixture = TestBed.createComponent(CdCycleChart); + fixture.componentRef.setInput('cycles', CYCLES); + fixture.componentRef.setInput('slowMs', 25); + await fixture.whenStable(); + const budget = host().querySelector('.budget')!; + expect(budget.textContent).toContain('25 ms budget'); + expect(parseFloat(budget.style.bottom)).toBeCloseTo((25 / 31.25) * 100); + const over = options().filter((o) => o.getAttribute('aria-label')!.includes('over budget')); + expect(over.map((o) => o.getAttribute('aria-label')!.split(',')[0])).toEqual(['Cycle 8']); + expect(host().textContent).toContain('1 over the 25 ms budget'); }); it('keeps the budget line inside the chart when every cycle is fast', async () => { @@ -120,7 +133,7 @@ describe('CdCycleChart', () => { expect(options()[0].getAttribute('aria-label')).not.toContain('over budget'); expect(host().querySelectorAll('.markers span.over')).toHaveLength(2); expect(host().querySelector('.summary')!.textContent).toContain( - 'All 5 cycles. 2 over the 16.6 ms budget.', + 'All 5 cycles. 2 over the 16 ms budget.', ); }); @@ -168,7 +181,7 @@ describe('CdCycleChart', () => { expect(fixture.componentInstance.range()).toEqual({ from: 6, to: 8 }); expect(selectedIds()).toEqual(['Cycle 6', 'Cycle 7', 'Cycle 8']); expect(host().querySelector('.summary')!.textContent).toContain( - 'Cycles 6 to 8 (3). 2 over the 16.6 ms budget.', + 'Cycles 6 to 8 (3). 2 over the 16 ms budget.', ); const band = host().querySelector('.band')!; expect(parseFloat(band.style.left)).toBeCloseTo(20); @@ -365,6 +378,26 @@ describe('CdRecording with the chart', () => { expect(shown).toEqual(['Cart']); fixture.destroy(); }); + + it("passes the page's slowMs to the chart", async () => { + const page: CdPage = { + pageId: 'p1', + supported: true, + recording: false, + startedAt: null, + dropped: 0, + slowMs: 3, + cycles: CYCLES, + components: [], + hosts: {}, + }; + const fixture = TestBed.createComponent(CdRecording); + fixture.componentRef.setInput('page', page); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelector('.budget')!.textContent).toContain('3 ms budget'); + fixture.destroy(); + }); }); describe('CdFlameGraph', () => { diff --git a/app/src/__tests__/dashboard-security.test.ts b/app/src/__tests__/dashboard-security.test.ts new file mode 100644 index 00000000..cd422174 --- /dev/null +++ b/app/src/__tests__/dashboard-security.test.ts @@ -0,0 +1,138 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import type { SecurityStatus } from '@pangular-inspector/devtools/internal/config'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Dashboard } from '../pages/dashboard'; +import { inTime, securityItems } from '../pages/security-summary'; + +const HOUR = 3_600_000; + +function status(overrides: Partial = {}): SecurityStatus { + return { + reported: true, + auth: true, + originCheck: true, + allowedOrigins: [], + customOrigins: false, + allowedHosts: [], + network: false, + tokens: { count: 0, nextExpiresAt: null, lastExpiresAt: null, ttlMs: 168 * HOUR }, + pairingTokens: 0, + oneTimeCode: { state: 'accepting' }, + ...overrides, + }; +} + +const value = (items: { label: string; value: string }[], label: string) => + items.find((item) => item.label === label)?.value; + +describe('security summary', () => { + it('says times ahead in words', () => { + expect(inTime(4 * 60_000, 0)).toBe('in 4 min'); + expect(inTime(3 * HOUR, 0)).toBe('in 3 hours'); + expect(inTime(6 * 24 * HOUR, 0)).toBe('in 6 days'); + expect(inTime(0, 10)).toBe('in 0 min'); + }); + + it('lists the code, the trusted tokens and the reach of a default server', () => { + const items = securityItems( + status({ + oneTimeCode: { state: 'accepting', expiresAt: 4 * 60_000 }, + tokens: { count: 2, nextExpiresAt: 3 * HOUR, lastExpiresAt: 6 * 24 * HOUR, ttlMs: 1 }, + pairingTokens: 1, + }), + 0, + ); + expect(value(items, 'One-time code')).toBe('On, current code expires in 4 min'); + expect(value(items, 'Trusted clients')).toBe( + '2 tokens, the first expires in 3 hours, the last in 6 days', + ); + expect(value(items, 'Pairing tokens')).toBe('1 accepted instead of the code'); + expect(value(items, 'Reach')).toBe('This machine only'); + expect(value(items, 'Allowed origins')).toBe('This machine and the Chrome extension'); + expect(value(items, 'Allowed hosts')).toBeUndefined(); + }); + + it('says when auth is off, the code is locked, and the origin check is off', () => { + const off = securityItems(status({ auth: false, oneTimeCode: { state: 'off' } }), 0); + expect(value(off, 'One-time code')).toBe('Off: every client is trusted'); + expect(value(off, 'Trusted clients')).toBeUndefined(); + const locked = securityItems(status({ oneTimeCode: { state: 'locked' } }), 0); + expect(value(locked, 'One-time code')).toContain('Locked'); + const open = securityItems( + status({ originCheck: false, network: true, allowedHosts: 'any' }), + 0, + ); + expect(value(open, 'Allowed origins')).toBe('Any: the origin check is off'); + expect(value(open, 'Reach')).toBe('This machine and other machines'); + expect(value(open, 'Allowed hosts')).toBe('Any'); + }); + + it('leaves out the access rows when the host did not report them', () => { + const items = securityItems(status({ reported: false }), 0); + expect(items.map((item) => item.label)).toEqual(['One-time code', 'Trusted clients']); + }); +}); + +function fakeClient(backend: string, calls: string[], answer: unknown): DevframeRpcClient { + const rpc = { + call: (name: string) => { + calls.push(name); + if (name === 'security-status') return Promise.resolve(answer); + 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) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +const section = (fixture: ComponentFixture) => + (fixture.nativeElement as HTMLElement).querySelector('[aria-labelledby="security-title"]'); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('Dashboard security rows', () => { + it('shows the security status a trusted panel reads from the server', async () => { + const calls: string[] = []; + const fixture = TestBed.createComponent(Dashboard); + fixture.componentRef.setInput( + 'rpc', + fakeClient('websocket', calls, status({ allowedOrigins: ['http://192.168.1.20:8100'] })), + ); + await settle(fixture); + expect(calls).toContain('security-status'); + const text = section(fixture)?.textContent ?? ''; + expect(text).toContain('Security'); + expect(text).toContain('One-time code'); + expect(text).toContain('http://192.168.1.20:8100'); + }); + + it('shows nothing for a static report or a server without the RPC', async () => { + const staticCalls: string[] = []; + const report = TestBed.createComponent(Dashboard); + report.componentRef.setInput('rpc', fakeClient('static', staticCalls, status())); + await settle(report); + expect(staticCalls).not.toContain('security-status'); + expect(section(report)).toBeNull(); + + const older = TestBed.createComponent(Dashboard); + older.componentRef.setInput('rpc', fakeClient('websocket', [], null)); + await settle(older); + expect(section(older)).toBeNull(); + }); +}); diff --git a/app/src/pages/cd-chart-data.ts b/app/src/pages/cd-chart-data.ts index c2de548e..bd074cf6 100644 --- a/app/src/pages/cd-chart-data.ts +++ b/app/src/pages/cd-chart-data.ts @@ -4,8 +4,11 @@ import type { CdSpanKind, } from '@pangular-inspector/devtools/internal/config'; -/** One frame at 60 frames per second. A cycle longer than this can drop a frame. */ -export const FRAME_BUDGET_MS = 16.6; +/** + * The slow cycle threshold when a recording names none: `limits.cdSlowMs`'s + * default, about one frame at 60 frames per second. + */ +export const FRAME_BUDGET_MS = 16; /** A range of recorded cycles by cycle id, both ends included. */ export interface CdRange { @@ -13,8 +16,9 @@ export interface CdRange { to: number; } -export function overBudget(cycle: Pick): boolean { - return cycle.ms > FRAME_BUDGET_MS; +/** Whether a cycle took longer than the recording's slow threshold (`limits.cdSlowMs`). */ +export function overBudget(cycle: Pick, slowMs = FRAME_BUDGET_MS): boolean { + return cycle.ms > slowMs; } /** Formats a duration the way the recording view does. */ @@ -64,9 +68,9 @@ export function aggregateComponents(cycles: readonly CdCycle[]): CdComponentStat } /** The top of the bar chart's scale: the slowest cycle, with room above the budget line. */ -export function chartMax(cycles: readonly CdCycle[]): number { +export function chartMax(cycles: readonly CdCycle[], slowMs = FRAME_BUDGET_MS): number { const slowest = cycles.reduce((max, cycle) => Math.max(max, cycle.ms), 0); - return Math.max(slowest, FRAME_BUDGET_MS * 1.25); + return Math.max(slowest, slowMs * 1.25); } /** A treemap cell in percent of the map's width and height. */ diff --git a/app/src/pages/cd-cycle-chart.ts b/app/src/pages/cd-cycle-chart.ts index e0a17c15..26d80cab 100644 --- a/app/src/pages/cd-cycle-chart.ts +++ b/app/src/pages/cd-cycle-chart.ts @@ -24,8 +24,8 @@ interface Bar { } /** - * One bar per recorded change detection cycle, with the 16.6 ms frame budget - * as a line. Dragging across the bars, or Shift with the arrow keys, picks a + * One bar per recorded change detection cycle, with the slow cycle threshold + * (`limits.cdSlowMs`, 16 ms by default) as a line. Dragging across the bars, or Shift with the arrow keys, picks a * range of cycles; Start and End fields do the same by cycle number. */ @Component({ @@ -80,7 +80,7 @@ interface Bar { > }