test(perf): count change-detection ticks in the electron-performance build (#1776)

* test(perf): count change-detection ticks in the electron-performance build

J1 renderer.cdTicksToFirstCard, J2 renderer.cdTicksToFirstPage and the
J1 idle baseline renderer.cdTicksIdle30s. Angular's ɵsetProfiler is only
reachable through the dev-mode window.ng global, so the electron-performance
configuration alone swaps environment.ts for environment.performance.ts,
which re-exports the production AppConfig and wraps ApplicationRef._tick.
Production and PWA sources and output are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): refuse a J1 idle window that opened late after the settle point

Addresses review: the idle window opens in the settle timer's callback while
the settle point is that timer's deadline, so a late callback left ticks
uncounted between the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 14:23:15 +02:00
1 parent 5a4d7a8a11
commit e4cf48fdc2
20 files changed
+778 -81

No files matched your search

@@ -0,0 +1,72 @@
import { ApplicationRef, Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
CHANGE_DETECTION_TICK_COUNTER_KEY,
installChangeDetectionTickCounter,
} from './change-detection-tick-counter';
describe('installChangeDetectionTickCounter', () => {
it('counts every _tick call and forwards to the original', () => {
const calls: unknown[] = [];
const prototype = {
_tick(this: unknown) {
calls.push(this);
},
};
const target: Record<string, unknown> = {};
const counter = installChangeDetectionTickCounter(target, prototype);
const instance = {};
prototype._tick.call(instance);
prototype._tick.call(instance);
expect(counter.count).toBe(2);
expect(calls).toEqual([instance, instance]);
expect(target[CHANGE_DETECTION_TICK_COUNTER_KEY]).toBe(counter);
});
it('installs once per target', () => {
const prototype = { _tick: jest.fn() };
const target: Record<string, unknown> = {};
const first = installChangeDetectionTickCounter(target, prototype);
const second = installChangeDetectionTickCounter(target, prototype);
prototype._tick();
expect(second).toBe(first);
expect(first.count).toBe(1);
});
it('fails when the internal tick method is missing', () => {
expect(() => installChangeDetectionTickCounter({}, {})).toThrow(
'change-detection-tick-counter-hook-missing'
);
});
it('counts the ticks of the installed Angular version', () => {
@Component({ template: '{{ value() }}' })
class CounterHostComponent {
readonly value = signal(0);
}
const prototype = ApplicationRef.prototype as unknown as {
_tick: () => void;
};
const original = prototype._tick;
try {
const counter = installChangeDetectionTickCounter({});
const fixture = TestBed.createComponent(CounterHostComponent);
const appRef = TestBed.inject(ApplicationRef);
appRef.attachView(fixture.componentRef.hostView);
const before = counter.count;
appRef.tick();
fixture.componentInstance.value.set(1);
appRef.tick();
expect(counter.count - before).toBe(2);
expect(fixture.nativeElement.textContent).toBe('1');
} finally {
prototype._tick = original;
}
});
});
@@ -0,0 +1,58 @@
import { ApplicationRef } from '@angular/core';
/**
* Change-detection tick counter for the performance journeys. Only the
* `electron-performance` build of `apps/web` contains this module: it is
* imported by `environment.performance.ts`, which `fileReplacements` swaps
* in for `environment.ts` in that configuration alone. The production and
* PWA builds never reference it, so their output is unchanged.
*
* Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode
* `window.ng` global, which the optimized build does not publish. Every
* tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless
* scheduler or an explicit `ApplicationRef.tick()`, goes through the
* internal `ApplicationRef._tick`, which is also where Angular emits the
* profiler's `ChangeDetectionStart`. Counting its calls therefore matches
* the profiler's count and stays comparable across the zoneless migration.
* Contract: docs/architecture/performance-journeys.md.
*/
export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks';
export interface ChangeDetectionTickCounter {
/** `ApplicationRef._tick` calls since the counter was installed. */
readonly count: number;
readonly schemaVersion: 1;
}
type TickPrototype = { _tick?: (this: ApplicationRef) => void };
export function installChangeDetectionTickCounter(
target: Record<string, unknown> = globalThis as unknown as Record<
string,
unknown
>,
prototype: TickPrototype = ApplicationRef.prototype as unknown as TickPrototype
): ChangeDetectionTickCounter {
const existing = target[CHANGE_DETECTION_TICK_COUNTER_KEY];
if (existing !== undefined) {
return existing as ChangeDetectionTickCounter;
}
const original = prototype._tick;
// A renamed internal must fail the performance build loudly, never
// report zero ticks.
if (typeof original !== 'function') {
throw new Error('change-detection-tick-counter-hook-missing');
}
const counter = { count: 0, schemaVersion: 1 as const };
prototype._tick = function countedTick(this: ApplicationRef): void {
counter.count += 1;
original.call(this);
};
Object.defineProperty(target, CHANGE_DETECTION_TICK_COUNTER_KEY, {
configurable: false,
enumerable: false,
value: counter,
writable: false,
});
return counter;
}
@@ -0,0 +1,8 @@
// The electron-performance build's environment: production values plus the
// change-detection tick counter the performance journeys read. See
// change-detection-tick-counter.ts; no other configuration imports this file.
import { installChangeDetectionTickCounter } from './change-detection-tick-counter';
installChangeDetectionTickCounter();
export { AppConfig } from './environment.prod';