-
- ${angularVersion ? `ng ${escapeHtml(angularVersion)} ` : ""}
-
-
-
- ${this.options.enabled ? "On" : "Off"}
-
-
+
+
+
+ ${this.options.enabled ? "On" : "Off"}
+
${this.options.enabled ? `
- ${this.metric("FPS", this.options.showFPS ? String(displayedFps) + " fps" : "-", this.getFpsClass(displayedFps))}
-
- CPU
- ${cpuVal}%
-
- ${sparklineSvg}
+ ${this.metric("Cycle", cycle ? `${cycle.duration.toFixed(1)}ms` : "-")}
${compactToolbar ? "" : `
${this.metric("Last cycle", cycle ? `${cycle.duration.toFixed(1)}ms${cycleWasteText}` : "-")}
-
- CD Graph
- Graph
-
${this.metric("Slowest", cycle?.slowest ? cycle.slowest.name : "-", "", "slowest-metric")}
${triggerBadge}
- ${leaksChip}
+ ${detachedChip}
${alertsChip}
${onPushChip}
${pollutionChip}
+ ${this.metric("Context FPS", this.options.showFPS ? String(displayedFps) : "-", this.getFpsClass(displayedFps))}
`}
- ${compactToolbar ? "" : `
- ${this.options.showCopyPrompt ? '✦ ' : ""}
-
-
- `}
+ ${this.interactionCapturing ? "Finish" : "Capture"}
+ Reset
-
-
-
- ${compactToolbar ? ' ' : ' '}
+ Inspect
` : ""}
@@ -1835,18 +2106,68 @@ export class AngularRenderScanOverlay {
{ once: true },
);
- toolbarEl?.querySelector(".leak-toggle")?.addEventListener(
+ toolbarEl?.querySelector(".detached-toggle")?.addEventListener(
"click",
() => {
this.detailsMode = true;
- if (leaks.length > 0) {
- this.selectedEntry = leaks[0];
+ if (detached.length > 0) {
+ this.selectedEntry = detached[0];
}
this.renderToolbar();
},
{ once: true },
);
+ toolbarEl?.querySelector(".interaction-capture-btn")?.addEventListener(
+ "click",
+ () => {
+ if (!this.interactionCapturing) {
+ const name = window.prompt("Name the interaction to measure", "Primary interaction")?.trim();
+ if (!name) return;
+ beginInteraction(name);
+ this.interactionCapturing = true;
+ this.interactionComparison = undefined;
+ this.showDiagnosisPanel = false;
+ this.setCopyStatus("Capture started");
+ } else {
+ this.interactionReport = endInteraction();
+ this.interactionCapturing = false;
+ this.showDiagnosisPanel = true;
+ if (this.interactionBaseline) {
+ this.interactionComparison = compareInteractionReports(this.interactionBaseline, this.interactionReport);
+ }
+ this.renderToolbar();
+ }
+ },
+ { once: true },
+ );
+
+ container.querySelector(".diagnosis-close-btn")?.addEventListener("click", () => { this.showDiagnosisPanel = false; this.renderToolbar(); }, { once: true });
+ container.querySelector(".diagnosis-baseline-btn")?.addEventListener("click", () => {
+ if (!this.interactionReport) return;
+ this.interactionBaseline = this.interactionReport;
+ setInteractionBaseline(this.interactionReport);
+ this.interactionComparison = undefined;
+ this.setCopyStatus("Baseline saved");
+ this.renderToolbar();
+ }, { once: true });
+ container.querySelector(".diagnosis-copy-btn")?.addEventListener("click", async () => {
+ if (!this.interactionReport) return;
+ const copied = await navigator.clipboard.writeText(formatInteractionReportMarkdown(this.interactionReport)).then(() => true, () => false);
+ this.setCopyStatus(copied ? "Report copied" : "Copy failed");
+ }, { once: true });
+ container.querySelector(".diagnosis-html-btn")?.addEventListener("click", () => {
+ if (!this.interactionReport) return;
+ const blob = new Blob([formatInteractionReportHtml(this.interactionReport)], { type: "text/html" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `angular-render-scan-${this.interactionReport.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.html`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+ this.setCopyStatus("HTML report exported");
+ }, { once: true });
+
toolbarEl?.querySelector(".clear-btn")?.addEventListener(
"click",
() => {
@@ -1912,6 +2233,17 @@ export class AngularRenderScanOverlay {
{ once: true },
);
+ container.querySelector(".component-copy-btn")?.addEventListener(
+ "click",
+ async () => {
+ const entry = this.currentDetailsEntry();
+ if (!entry) return;
+ const copied = await this.copyComponentPrompt(entry, this.latestFps || this.fps.value);
+ this.setCopyStatus(copied ? "Copied evidence" : "Copy failed");
+ },
+ { once: true },
+ );
+
container.querySelector(".open-editor-btn")?.addEventListener(
"click",
async () => {
@@ -2067,6 +2399,68 @@ export class AngularRenderScanOverlay {
return true;
}
+ private streamlinedInspectPanelHtml(): string {
+ const entry = this.currentDetailsEntry();
+ if (!entry) return "";
+
+ const cause = entry.renderCause
+ ? `${entry.renderCause.trigger}${entry.renderCause.source ? ` → ${entry.renderCause.source}` : ""}`
+ : entry.reason ?? "unknown";
+ const changedInputs = entry.changedInputs?.slice(0, 3) ?? [];
+ const recommendation = this.recommendationsFor(entry)[0];
+ const didChangeDom = entry.mutationType !== "none";
+ const status = didChangeDom ? "Changed" : "No visual change";
+ const statusClass = didChangeDom ? "changed" : "no-change";
+ const durationRatio = Math.min(100, (entry.latestDuration / Math.max(this.slowThresholdMs, 0.1)) * 100);
+ const durationClass = entry.latestDuration >= this.slowThresholdMs
+ ? "slow"
+ : entry.latestDuration >= this.fastThresholdMs ? "medium" : "fast";
+
+ return `
+
+
+
+
+ Last component check
+ ${entry.latestDuration.toFixed(2)} ms
+
+ 0 slow at ${this.slowThresholdMs.toFixed(0)} ms
+
+
Checks ${entry.count}
+
Strategy ${escapeHtml(entry.cdStrategy ?? "unknown")}
+
+
+
+
+ Why Angular checked it
+ ${escapeHtml(cause)}
+
+
+
Input evidence
+ ${changedInputs.length ? changedInputs.map(input => `
+
${escapeHtml(input.name)}
+
${escapeHtml(input.previous)}→ ${escapeHtml(input.current)}
+ ${input.isReferentiallyUnstable ? '
Equal value, different reference ' : ''}
+
`).join("") : '
No input change captured in this check.
'}
+
+
+
+
+ `;
+ }
+
private inspectPanelHtml(): string {
const entry = this.currentDetailsEntry();
if (!entry) {
@@ -2113,9 +2507,9 @@ export class AngularRenderScanOverlay {
: "";
const leakWarningHtml = !entry.element?.isConnected
- ? `
+ ? `
⚠️
- Memory Leak Warning: Element is disconnected from the DOM but was not destroyed!
+ Detached host observed. Confirm retention with a heap snapshot before calling this a memory leak.
`
: "";
@@ -2228,66 +2622,12 @@ export class AngularRenderScanOverlay {
return this.hoveredEntry ?? this.selectedEntry;
}
- private inspectPanelPositionStyle(entry: AngularRenderEntry): string {
+ private inspectPanelPositionStyle(_entry: AngularRenderEntry): string {
const margin = 12;
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const panelWidth = Math.min(280, Math.max(220, viewportWidth - margin * 2));
- const panelHeightEstimate = Math.min(
- 300,
- Math.max(180, viewportHeight - margin * 2),
- );
- const rect =
- this.hoveredEntry?.id === entry.id
- ? this.hoveredRect
- : entry.element?.getBoundingClientRect();
- const pointer =
- this.hoveredEntry?.id === entry.id ? this.hoverPointer : undefined;
-
- let left = viewportWidth - this.toolbarX - panelWidth;
- let top = viewportHeight - this.toolbarY - panelHeightEstimate - 60;
-
- if (rect) {
- const anchorX = pointer?.x ?? rect.left + rect.width / 2;
- const anchorY = pointer?.y ?? rect.top + rect.height / 2;
- const isLargeTarget =
- rect.width > panelWidth + 48 || rect.height > panelHeightEstimate;
-
- if (isLargeTarget && pointer) {
- left =
- anchorX + margin + panelWidth <= viewportWidth
- ? anchorX + margin
- : anchorX - panelWidth - margin;
- top =
- anchorY + margin + panelHeightEstimate <= viewportHeight
- ? anchorY + margin
- : anchorY - panelHeightEstimate - margin;
- } else {
- if (rect.right + margin + panelWidth <= viewportWidth) {
- left = rect.right + margin;
- } else if (rect.left - margin - panelWidth >= 0) {
- left = rect.left - panelWidth - margin;
- } else {
- left =
- anchorX + margin + panelWidth <= viewportWidth
- ? anchorX + margin
- : anchorX - panelWidth - margin;
- }
-
- top = rect.top;
- if (rect.height > panelHeightEstimate / 2) {
- top = anchorY - panelHeightEstimate / 2;
- }
- }
- }
-
- left = Math.max(margin, Math.min(left, viewportWidth - panelWidth - margin));
- top = Math.max(
- margin,
- Math.min(top, viewportHeight - panelHeightEstimate - margin),
- );
-
- return `left: ${Math.round(left)}px; top: ${Math.round(top)}px; right: auto; bottom: auto; width: ${Math.round(panelWidth)}px; max-width: calc(100vw - ${margin * 2}px);`;
+ const width = Math.min(360, window.innerWidth - margin * 2);
+ const right = Math.max(margin, this.toolbarX);
+ const bottom = Math.max(margin, this.toolbarY + 58);
+ return `right:${Math.round(right)}px; bottom:${Math.round(bottom)}px; left:auto; top:auto; width:${Math.round(width)}px; max-width:calc(100vw - ${margin * 2}px); max-height:calc(100vh - ${Math.round(bottom + margin)}px);`;
}
private panelField(label: string, value: string): string {
@@ -2446,7 +2786,7 @@ export class AngularRenderScanOverlay {
label: string;
} {
if (entry.element && !entry.element.isConnected) {
- return { kind: "slow", label: "Memory Leak" };
+ return { kind: "medium", label: "Detached" };
}
const maxDuration = Math.max(entry.latestDuration, entry.averageDuration);
if (maxDuration >= this.slowThresholdMs) {
@@ -2483,9 +2823,9 @@ export class AngularRenderScanOverlay {
if (entry.element && !entry.element.isConnected) {
recommendations.push({
- category: "Memory Leak",
- action: `Component element is disconnected from the DOM but was not destroyed. Make sure subscriptions and global events are cleanly unsubscribed (e.g. takeUntilDestroyed).`,
- severity: "slow",
+ category: "Detached host",
+ action: `The host is disconnected. Use a heap snapshot or allocation profile to verify the component is retained before changing cleanup logic.`,
+ severity: "medium",
});
}
@@ -2721,6 +3061,40 @@ export class AngularRenderScanOverlay {
`;
}
+ private diagnosisPanelHtml(): string {
+ const report = this.interactionReport;
+ if (!this.showDiagnosisPanel || !report) return "";
+ const comparison = this.interactionComparison;
+ const outcomeColor = comparison?.outcome === "regressed" ? "#ef4444" : comparison?.outcome === "improved" ? "#10b981" : "#64748b";
+ const comparisonHtml = comparison
+ ? `
+
Candidate ${comparison.outcome}
+
Total cycle time ${comparison.deltas.totalCycleDuration.absolute > 0 ? "+" : ""}${comparison.deltas.totalCycleDuration.absolute}ms · Max cycle ${comparison.deltas.maxCycleDuration.absolute > 0 ? "+" : ""}${comparison.deltas.maxCycleDuration.absolute}ms · No-mutation share ${comparison.deltas.wastedPercentage.absolute > 0 ? "+" : ""}${comparison.deltas.wastedPercentage.absolute}pt
+ ${comparison.regressions.length ? `
${escapeHtml(comparison.regressions.join(" "))}
` : ""}
+
`
+ : "";
+ const findingRows = report.findings.slice(0, 5).map((item, index) => `
+
+
${index + 1} ${escapeHtml(item.title)}
${escapeHtml(item.summary)}
+
Next: ${escapeHtml(item.action)}
+
`).join("");
+ return `
+
+
+ ${comparisonHtml}
+ ${findingRows || `
No actionable finding in this capture.
`}
+
+ Use as baseline
+ Copy Markdown
+ Export HTML
+
+ CPU/FPS are context only. Detached hosts and OnPush opportunities require verification.
+ `;
+ }
+
private onPushPanelHtml(candidates: OnPushCandidate[]): string {
if (!this.showOnPushPanel || candidates.length === 0) return "";
const panelRight = this.toolbarX;
@@ -2749,9 +3123,7 @@ export class AngularRenderScanOverlay {
${confLabel}
- ${c.wastedPercentage}% wasted
- →
- ~${c.estimatedSavingPct}% saving
+ ${c.opportunityPercentage}% no-mutation share
${escapeHtml(c.reason)}
@@ -2769,7 +3141,7 @@ export class AngularRenderScanOverlay {
×
- These components use Default CD with high wasted render rates. Adding ChangeDetectionStrategy.OnPush could significantly reduce unnecessary checks.
+ These are experiments, not predicted savings. Apply ChangeDetectionStrategy.OnPush only in a candidate change and verify the same interaction before and after.
${items}
@@ -2821,7 +3193,7 @@ export class AngularRenderScanOverlay {
×
- These CD cycles were triggered by async operations with no user interaction — suspected Zone.js pollution. Use NgZone.runOutsideAngular() to escape Zone.
+ Applies only to Zone.js applications. These cycles followed async work without a nearby user event; verify the source before using NgZone.runOutsideAngular().
${items}
@@ -3074,13 +3446,6 @@ export class AngularRenderScanOverlay {
`;
}
- private signalsPanelHtml(): string {
- return "";
- }
-
- private costPanelHtml(): string {
- return "";
- }
}
function rgba(color: readonly [number, number, number], alpha: number): string {
diff --git a/packages/angular-render-scan/src/noop.ts b/packages/angular-render-scan/src/noop.ts
index cbf252b..9f7c09f 100644
--- a/packages/angular-render-scan/src/noop.ts
+++ b/packages/angular-render-scan/src/noop.ts
@@ -22,7 +22,9 @@ import type {
SessionExportData,
WastedStats,
ZonePollutionEvent,
- CdGraph
+ CdGraph,
+ InteractionComparison,
+ InteractionReport
} from './domain/entities';
/** No-op provider for production — contributes zero bytes to output. */
@@ -40,6 +42,7 @@ export function getAIPrompt(): string { return ''; }
export async function copyAIPrompt(): Promise
{ return false; }
export function getWastedStats(): WastedStats { return { totalChecks: 0, wastedChecks: 0, wastedPercentage: 0 }; }
export function getLeakedComponents(): AngularRenderEntry[] { return []; }
+export function getDetachedComponents(): AngularRenderEntry[] { return []; }
export function getOnPushCandidates(): OnPushCandidate[] { return []; }
export function getReferentialInstability(): ReferentialInstabilityReport[] { return []; }
export function getZonePollutionEvents(): ZonePollutionEvent[] { return []; }
@@ -54,9 +57,26 @@ export function getSessionData(): SessionExportData {
cycles: [],
wastedStats: { totalChecks: 0, wastedChecks: 0, wastedPercentage: 0 },
budgetViolations: [],
+ detachedComponents: [],
leakedComponents: [],
onPushCandidates: [],
zonePollutionEvents: [],
referentialInstabilityReports: []
};
}
+
+let interactionName = 'Captured interaction';
+export function beginInteraction(name: string): void { interactionName = name || interactionName; }
+export function endInteraction(): InteractionReport {
+ const session = getSessionData();
+ return {
+ schemaVersion: 1, name: interactionName, startedAt: session.exportedAt, finishedAt: session.exportedAt,
+ url: '', viewport: '', findings: [], session,
+ metrics: { cycleCount: 0, componentCheckCount: 0, totalCycleDuration: 0, maxCycleDuration: 0, wastedChecks: 0, wastedPercentage: 0, budgetViolationCount: 0 }
+ };
+}
+export function getInteractionReport(): InteractionReport | undefined { return undefined; }
+export function setInteractionBaseline(_report: InteractionReport): void {}
+export function compareWithInteractionBaseline(): InteractionComparison { throw new Error('[angular-render-scan] No reports exist in noop mode.'); }
+export function formatInteractionReportMarkdown(_report: InteractionReport): string { return ''; }
+export function formatInteractionReportHtml(_report: InteractionReport): string { return ''; }
diff --git a/packages/angular-render-scan/src/public-api.ts b/packages/angular-render-scan/src/public-api.ts
index 8787c32..fb9c709 100644
--- a/packages/angular-render-scan/src/public-api.ts
+++ b/packages/angular-render-scan/src/public-api.ts
@@ -8,12 +8,22 @@ export {
getSessionData,
getWastedStats,
getLeakedComponents,
+ getDetachedComponents,
getOnPushCandidates,
getReferentialInstability,
getZonePollutionEvents,
getCdGraph,
getSignalDependencyGraph,
- getComponentCostAnalysis
+ getComponentCostAnalysis,
+ beginInteraction,
+ endInteraction,
+ getInteractionReport,
+ setInteractionBaseline,
+ compareWithInteractionBaseline,
+ compareInteractionReports,
+ createInteractionReport,
+ formatInteractionReportMarkdown,
+ formatInteractionReportHtml
} from './application/runtime';
export {
AngularRenderScanMarkDirective,
@@ -41,7 +51,16 @@ export type {
ZonePollutionEvent as ZonePollutionEventType,
CdGraph,
CdGraphNode,
- CdGraphEdge
+ CdGraphEdge,
+ SignalDependencyGraph,
+ ComponentCostEntry,
+ InteractionFinding,
+ InteractionFindingKind,
+ InteractionFindingSeverity,
+ InteractionMetrics,
+ InteractionReport,
+ InteractionMetricDelta,
+ InteractionComparison
} from './domain/entities';
export { startRenderAudit } from './testing';
export type { RenderAuditReport, RenderAuditSession } from './testing';
diff --git a/packages/angular-render-scan/src/testing.ts b/packages/angular-render-scan/src/testing.ts
index 68b0fc4..4eb0164 100644
--- a/packages/angular-render-scan/src/testing.ts
+++ b/packages/angular-render-scan/src/testing.ts
@@ -1,74 +1,85 @@
+import type { BudgetViolation, InteractionComparison, InteractionReport, SessionExportData } from './domain/entities';
+import { compareInteractionReports, formatInteractionReportHtml, formatInteractionReportMarkdown } from './application/interaction';
+
+interface BrowserScanApi {
+ scan(options: { enabled: boolean }): void;
+ stop(): void;
+ beginInteraction?(name: string): void;
+ endInteraction?(): InteractionReport;
+ getSessionData(): SessionExportData;
+}
+
+export interface RenderAuditPage {
+ evaluate(pageFunction: () => Result | Promise): Promise>;
+ evaluate(pageFunction: (argument: Argument) => Result | Promise, argument: Argument): Promise>;
+}
+
export interface RenderAuditReport {
rendersFor(name: string): Promise;
maxDurationFor(name: string): Promise;
wastedRenderPercentage(): Promise;
- budgetViolations(): Promise;
+ budgetViolations(): Promise;
+ sessionData(): Promise;
+ interactionReport(): Promise;
+ compareWith(baseline: InteractionReport): Promise;
+ toMarkdown(): Promise;
+ toHtml(): Promise;
}
export interface RenderAuditSession {
stop(): Promise;
}
-/**
- * Starts a Playwright-compatible headless render audit session on the given Page object.
- * Intercepts change-detection metrics and budget violations.
- */
-export async function startRenderAudit(page: any): Promise {
+/** Starts a named, Playwright-compatible interaction audit. */
+export async function startRenderAudit(page: RenderAuditPage, name = 'Playwright interaction'): Promise {
await page.evaluate(() => {
- const scan = (window as any).AngularRenderScan;
- if (scan) {
- scan.scan({ enabled: true });
- }
+ const scan = (window as Window & { AngularRenderScan?: BrowserScanApi }).AngularRenderScan;
+ scan?.scan({ enabled: true });
});
+ await page.evaluate((interactionName) => {
+ const scan = (window as Window & { AngularRenderScan?: BrowserScanApi }).AngularRenderScan;
+ scan?.beginInteraction?.(interactionName);
+ }, name);
return {
async stop(): Promise {
- const sessionData = await page.evaluate(() => {
- const scan = (window as any).AngularRenderScan;
- if (scan && scan.getSessionData) {
- const data = scan.getSessionData();
- scan.stop();
- return data;
- }
- return null;
+ const payload = await page.evaluate(() => {
+ const scan = (window as Window & { AngularRenderScan?: BrowserScanApi }).AngularRenderScan;
+ if (!scan?.getSessionData) return null;
+ const interaction = scan.endInteraction?.();
+ const session = scan.getSessionData();
+ scan.stop();
+ return { interaction, session };
});
-
- if (!sessionData) {
- throw new Error(
- '[angular-render-scan] Telemetry session data could not be fetched. ' +
- 'Is the package enabled and running in development mode?'
- );
+ if (!payload) {
+ throw new Error('[angular-render-scan] Telemetry could not be fetched. Is the scanner enabled in development mode?');
}
-
+ const interaction = payload.interaction ?? {
+ schemaVersion: 1 as const,
+ name,
+ startedAt: payload.session.exportedAt,
+ finishedAt: payload.session.exportedAt,
+ url: payload.session.url,
+ viewport: payload.session.viewport,
+ metrics: { cycleCount: 0, componentCheckCount: 0, totalCycleDuration: 0, maxCycleDuration: 0, wastedChecks: 0, wastedPercentage: payload.session.wastedStats.wastedPercentage, budgetViolationCount: payload.session.budgetViolations.length },
+ findings: [],
+ session: payload.session
+ };
+ const sessionData = payload.session;
return {
- async rendersFor(name: string): Promise {
- let count = 0;
- for (const cycle of sessionData.cycles) {
- for (const entry of cycle.entries) {
- if (entry.name === name) {
- count = Math.max(count, entry.count);
- }
- }
- }
- return count;
- },
- async maxDurationFor(name: string): Promise {
- let maxDur = 0;
- for (const cycle of sessionData.cycles) {
- for (const entry of cycle.entries) {
- if (entry.name === name) {
- maxDur = Math.max(maxDur, entry.latestDuration);
- }
- }
- }
- return maxDur;
+ async rendersFor(componentName) {
+ return Math.max(0, ...sessionData.cycles.flatMap((cycle) => cycle.entries.filter((entry) => entry.name === componentName).map((entry) => entry.count)));
},
- async wastedRenderPercentage(): Promise {
- return sessionData.wastedStats.wastedPercentage;
+ async maxDurationFor(componentName) {
+ return Math.max(0, ...sessionData.cycles.flatMap((cycle) => cycle.entries.filter((entry) => entry.name === componentName).map((entry) => entry.latestDuration)));
},
- async budgetViolations(): Promise {
- return sessionData.budgetViolations;
- }
+ async wastedRenderPercentage() { return interaction.metrics.wastedPercentage; },
+ async budgetViolations() { return interaction.session.budgetViolations; },
+ async sessionData() { return sessionData; },
+ async interactionReport() { return interaction; },
+ async compareWith(baseline) { return compareInteractionReports(baseline, interaction); },
+ async toMarkdown() { return formatInteractionReportMarkdown(interaction); },
+ async toHtml() { return formatInteractionReportHtml(interaction); }
};
}
};
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 81ed5f6..0138590 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -1,6 +1,6 @@
# angular-render-scan-cli
-CLI installer for [angular-render-scan](https://www.npmjs.com/package/angular-render-scan).
+CLI installer and CI report generator for [angular-render-scan](https://www.npmjs.com/package/angular-render-scan).
## Usage
@@ -22,6 +22,21 @@ npx angular-render-scan-cli --help
- `--script-tag`: add the CDN script tag instead of provider setup.
- `--force`: patch even if `angular-render-scan` is already present.
+## CI performance report
+
+Save the `InteractionReport` returned by `report.interactionReport()` in a Playwright test, then render or compare it:
+
+```sh
+npx angular-render-scan-cli report --input candidate.json --format markdown
+npx angular-render-scan-cli report \
+ --input candidate.json \
+ --baseline baseline.json \
+ --github-summary \
+ --fail-on-regression
+```
+
+`--format` accepts `markdown`, `html`, or `json`; `--output` writes to a file. In GitHub Actions, `--github-summary` appends the result to the job summary. `--fail-on-regression` exits with status 1 when total or maximum cycle time rises more than 10%, no-mutation check share rises more than 5 points, or new budget violations appear.
+
## Docs
https://github.com/edisonaugusthy/angular-render-scan
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index d6c5d8b..b486fe1 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -35,6 +35,113 @@ function step(n: number, total: number, msg: string) {
log(`${c.gray}[${n}/${total}]${c.reset} ${msg}`);
}
+type ReportMetrics = {
+ cycleCount: number;
+ totalCycleDuration: number;
+ maxCycleDuration: number;
+ wastedPercentage: number;
+ budgetViolationCount: number;
+};
+
+type PortableReport = {
+ schemaVersion: 1;
+ name: string;
+ metrics: ReportMetrics;
+ findings: Array<{ title: string; summary: string; evidence: string[]; action: string }>;
+};
+
+type ReportComparison = {
+ outcome: 'improved' | 'regressed' | 'unchanged';
+ regressions: string[];
+ deltas: Record<'totalCycleDuration' | 'maxCycleDuration' | 'wastedPercentage' | 'budgetViolationCount', number>;
+};
+
+function optionValue(args: string[], flag: string): string | undefined {
+ const index = args.indexOf(flag);
+ return index >= 0 ? args[index + 1] : undefined;
+}
+
+function readPortableReport(filePath: string): PortableReport {
+ const value: unknown = JSON.parse(readFile(path.resolve(filePath)));
+ if (!value || typeof value !== 'object' || !('metrics' in value) || !('findings' in value) || !('name' in value)) {
+ throw new Error(`Invalid interaction report: ${filePath}`);
+ }
+ return value as PortableReport;
+}
+
+function comparePortableReports(baseline: PortableReport, candidate: PortableReport): ReportComparison {
+ const percent = (before: number, after: number) => before === 0 ? 0 : ((after - before) / before) * 100;
+ const deltas = {
+ totalCycleDuration: candidate.metrics.totalCycleDuration - baseline.metrics.totalCycleDuration,
+ maxCycleDuration: candidate.metrics.maxCycleDuration - baseline.metrics.maxCycleDuration,
+ wastedPercentage: candidate.metrics.wastedPercentage - baseline.metrics.wastedPercentage,
+ budgetViolationCount: candidate.metrics.budgetViolationCount - baseline.metrics.budgetViolationCount
+ };
+ const regressions: string[] = [];
+ const totalPct = percent(baseline.metrics.totalCycleDuration, candidate.metrics.totalCycleDuration);
+ const maxPct = percent(baseline.metrics.maxCycleDuration, candidate.metrics.maxCycleDuration);
+ if (totalPct > 10) regressions.push(`Total cycle time increased ${totalPct.toFixed(1)}%.`);
+ if (maxPct > 10) regressions.push(`Maximum cycle time increased ${maxPct.toFixed(1)}%.`);
+ if (deltas.wastedPercentage > 5) regressions.push(`No-mutation check share increased ${deltas.wastedPercentage.toFixed(1)} points.`);
+ if (deltas.budgetViolationCount > 0) regressions.push(`${deltas.budgetViolationCount} additional budget violation(s).`);
+ const improved = totalPct < -10 || maxPct < -10;
+ return { outcome: regressions.length ? 'regressed' : improved ? 'improved' : 'unchanged', regressions, deltas };
+}
+
+function portableMarkdown(report: PortableReport, comparison?: ReportComparison): string {
+ const lines = [`# Angular Render Scan: ${report.name}`, ''];
+ if (comparison) {
+ lines.push(`## Comparison: ${comparison.outcome.toUpperCase()}`, '',
+ `- Total cycle time delta: ${comparison.deltas.totalCycleDuration.toFixed(2)}ms`,
+ `- Maximum cycle delta: ${comparison.deltas.maxCycleDuration.toFixed(2)}ms`,
+ `- No-mutation share delta: ${comparison.deltas.wastedPercentage.toFixed(2)} points`,
+ `- Budget violation delta: ${comparison.deltas.budgetViolationCount}`, '');
+ comparison.regressions.forEach(message => lines.push(`- ❌ ${message}`));
+ if (comparison.regressions.length) lines.push('');
+ }
+ lines.push('## Interaction metrics', '', `- Cycles: ${report.metrics.cycleCount}`,
+ `- Total cycle time: ${report.metrics.totalCycleDuration}ms`, `- Maximum cycle: ${report.metrics.maxCycleDuration}ms`,
+ `- No-mutation check share: ${report.metrics.wastedPercentage}%`, `- Budget violations: ${report.metrics.budgetViolationCount}`, '', '## Ranked findings', '');
+ if (!report.findings.length) lines.push('No actionable findings were observed.');
+ report.findings.forEach((finding, index) => lines.push(`### ${index + 1}. ${finding.title}`, '', finding.summary, '', `**Evidence:** ${finding.evidence.join('; ')}`, '', `**Next action:** ${finding.action}`, ''));
+ lines.push('> CPU/FPS are context only. Detached hosts and OnPush opportunities require verification.');
+ return lines.join('\n');
+}
+
+function portableHtml(report: PortableReport, markdown: string): string {
+ const escaped = markdown.replace(/&/g, '&').replace(//g, '>');
+ return `${report.name.replace(/[<>]/g, '')} ${escaped} `;
+}
+
+async function runReport(args: string[]): Promise {
+ const input = optionValue(args, '--input');
+ if (!input) throw new Error('report requires --input ');
+ const candidate = readPortableReport(input);
+ const baselinePath = optionValue(args, '--baseline');
+ const comparison = baselinePath ? comparePortableReports(readPortableReport(baselinePath), candidate) : undefined;
+ const format = optionValue(args, '--format') ?? 'markdown';
+ const markdown = portableMarkdown(candidate, comparison);
+ const rendered = format === 'json'
+ ? JSON.stringify(comparison ? { report: candidate, comparison } : candidate, null, 2)
+ : format === 'html' ? portableHtml(candidate, markdown) : markdown;
+ if (!['markdown', 'html', 'json'].includes(format)) throw new Error(`Unsupported format: ${format}`);
+ const output = optionValue(args, '--output');
+ if (output) {
+ writeFile(path.resolve(output), rendered + (format === 'html' ? '' : '\n'));
+ ok(`Wrote ${format} report to ${output}`);
+ } else {
+ log(rendered);
+ }
+ if (args.includes('--github-summary')) {
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
+ if (!summaryPath) warn('GITHUB_STEP_SUMMARY is not set; skipped job summary.');
+ else fs.appendFileSync(summaryPath, markdown + '\n', 'utf-8');
+ }
+ if (args.includes('--fail-on-regression') && comparison?.outcome === 'regressed') {
+ process.exitCode = 1;
+ }
+}
+
// ─── File discovery helpers ───────────────────────────────────────────────────
function findUp(filename: string, startDir = process.cwd()): string | null {
@@ -520,22 +627,37 @@ export async function run(): Promise {
return;
}
+ if (command === 'report') {
+ await runReport(rest);
+ return;
+ }
+
if (command === '--help' || command === '-h' || command === 'help') {
log('');
log(`${c.bold}Usage:${c.reset} npx angular-render-scan-cli [command] [options]`);
log('');
log(`${c.bold}Commands:${c.reset}`);
log(' init Auto-patch your Angular project (default)');
+ log(' report Render a capture or compare it with a baseline');
log('');
log(`${c.bold}Options for init:${c.reset}`);
log(' --force Patch even if angular-render-scan is already present');
log(' --dry-run Show what would be patched without writing files');
log(' --script-tag Use CDN script tag in index.html instead of provider');
log('');
+ log(`${c.bold}Options for report:${c.reset}`);
+ log(' --input Candidate interaction report JSON');
+ log(' --baseline Baseline interaction report JSON');
+ log(' --format markdown (default), html, or json');
+ log(' --output Write output instead of stdout');
+ log(' --github-summary Append Markdown to GITHUB_STEP_SUMMARY');
+ log(' --fail-on-regression Exit 1 when guardrails regress');
+ log('');
log(`${c.bold}Examples:${c.reset}`);
log(' npx angular-render-scan-cli init');
log(' npx angular-render-scan-cli init --dry-run');
log(' npx angular-render-scan-cli init --script-tag');
+ log(' npx angular-render-scan-cli report --input candidate.json --baseline baseline.json --github-summary --fail-on-regression');
log('');
return;
}