返回 DeepSeek-Reasonix
rendererDiagnostics.test.ts
根目录 / desktop / electron / src / main / rendererDiagnostics.test.ts
1 import assert from 'node:assert/strict';
2 import { EventEmitter } from 'node:events';
3 import test, { type TestContext } from 'node:test';
4 import { RendererDiagnostics } from './rendererDiagnostics.js';
5
6 const profile = {
7 nodes: [{ id: 1, callFrame: { functionName: 'render', url: 'reasonix://app/main.js', lineNumber: 12 } }],
8 samples: [1, 1], timeDeltas: [10_000, 10_000], startTime: 0, endTime: 20_000,
9 };
10 const frames = [{ label: 'render', samples: 2, selfMs: 20 }];
11
12 function deferred<T>() {
13 let resolve!: (value: T) => void;
14 let reject!: (error: Error) => void;
15 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
16 return { promise, resolve, reject };
17 }
18
19 // Resolve bounded command chains without wall-clock sleeps. Timers remain under test control.
20 async function flush() {
21 for (let i = 0; i < 30; i++) await Promise.resolve();
22 }
23
24 class DebuggerDouble extends EventEmitter {
25 attached = false;
26 attaches = 0;
27 detaches = 0;
28 commands: string[] = [];
29 command: (method: string) => Promise<unknown> = async method =>
30 method === 'Profiler.stop' ? { profile } : {};
31
32 isAttached() { return this.attached; }
33 attach() { this.attached = true; this.attaches++; }
34 detach() { this.attached = false; this.detaches++; this.emit('detach'); }
35 sendCommand(method: string) { this.commands.push(method); return this.command(method); }
36 }
37
38 function setup(t: TestContext, options: {
39 durationMs?: number; cooldownMs?: number; maxCaptures?: number; commandTimeoutMs?: number;
40 analyse?: () => Promise<typeof frames>;
41 onInvalidated?: (listener: () => void) => () => void;
42 } = {}) {
43 t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000_000 });
44 const debug = new DebuggerDouble();
45 let foreground = true;
46 let destroyed = false;
47 let devTools = false;
48 let invalidated: (() => void) | undefined;
49 let analysed = 0;
50 const target = { debugger: debug, isDestroyed: () => destroyed, isDevToolsOpened: () => devTools };
51 const owner = new RendererDiagnostics({
52 target: () => target,
53 isForeground: () => foreground,
54 onInvalidated: cb => { invalidated = cb; return () => { invalidated = undefined; }; },
55 analyse: async received => { assert.deepEqual(received, profile); analysed++; return frames; },
56 now: () => Date.now(),
57 ...options,
58 });
59 t.after(() => owner.dispose());
60 return {
61 owner, debug,
62 foreground: (value: boolean) => { foreground = value; },
63 destroyed: (value: boolean) => { destroyed = value; },
64 devTools: (value: boolean) => { devTools = value; },
65 invalidate: () => invalidated?.(),
66 analysed: () => analysed,
67 subscribed: () => invalidated !== undefined,
68 advance: async (ms: number) => { t.mock.timers.tick(ms); await flush(); },
69 };
70 }
71
72 test('construction has no profiling cost and never attaches until requested', t => {
73 const { debug } = setup(t);
74 assert.equal(debug.attaches, 0);
75 assert.deepEqual(debug.commands, []);
76 });
77
78 test('inactive or destroyed renderers cannot start a profile', async t => {
79 const fixture = setup(t);
80 fixture.foreground(false);
81 assert.equal((await fixture.owner.capture()).status, 'inactive');
82 fixture.foreground(true);
83 fixture.destroyed(true);
84 assert.equal((await fixture.owner.capture()).status, 'unavailable');
85 assert.equal(fixture.debug.attaches, 0);
86 });
87
88 test('an existing debugger or open DevTools is never taken over', async t => {
89 const fixture = setup(t);
90 fixture.debug.attached = true;
91 assert.equal((await fixture.owner.capture()).status, 'unavailable');
92 fixture.debug.attached = false;
93 fixture.devTools(true);
94 assert.equal((await fixture.owner.capture()).status, 'unavailable');
95 assert.equal(fixture.debug.attaches, 0);
96 assert.equal(fixture.debug.detaches, 0);
97 assert.deepEqual(fixture.debug.commands, []);
98 });
99
100 test('only one capture runs and the default capture stops after five seconds', async t => {
101 const fixture = setup(t);
102 const result = fixture.owner.capture();
103 await flush();
104 assert.equal((await fixture.owner.capture()).status, 'busy');
105 assert.equal(fixture.debug.attaches, 1);
106 assert.equal(fixture.debug.commands.filter(command => command === 'Profiler.start').length, 1);
107 await fixture.advance(4999);
108 assert.equal(fixture.debug.commands.includes('Profiler.stop'), false);
109 await fixture.advance(1);
110 const captured = await result;
111 assert.equal(captured.status, 'captured');
112 assert.deepEqual(captured.frames, frames);
113 assert.equal(fixture.analysed(), 1);
114 assert.equal(fixture.debug.attached, false);
115 assert.equal(fixture.debug.detaches, 1);
116 });
117
118 for (const reason of ['cancel', 'invalidate', 'dispose'] as const) {
119 test(`${reason} ends the active recording and never starts another automatically`, async t => {
120 const fixture = setup(t);
121 const result = fixture.owner.capture();
122 await flush();
123 if (reason === 'invalidate') fixture.invalidate();
124 else fixture.owner[reason]();
125 await flush();
126 assert.equal((await result).status, 'cancelled');
127 assert.equal(fixture.debug.attached, false);
128 assert.equal(fixture.debug.commands.filter(command => command === 'Profiler.stop').length, 1);
129 assert.equal(fixture.analysed(), 0);
130 await fixture.advance(60_000);
131 assert.equal(fixture.debug.attaches, 1);
132 if (reason === 'dispose') assert.equal(fixture.subscribed(), false);
133 });
134 }
135
136 test('cooldown and lifetime capture limits bound repeated diagnostics', async t => {
137 const fixture = setup(t, { durationMs: 100, cooldownMs: 1000, maxCaptures: 2 });
138 const first = fixture.owner.capture();
139 await flush();
140 await fixture.advance(100);
141 assert.equal((await first).status, 'captured');
142 assert.equal((await fixture.owner.capture()).status, 'cooldown');
143 await fixture.advance(1001);
144 const second = fixture.owner.capture();
145 await flush();
146 await fixture.advance(100);
147 assert.equal((await second).status, 'captured');
148 await fixture.advance(1001);
149 assert.equal((await fixture.owner.capture()).status, 'limit');
150 assert.equal(fixture.debug.attaches, 2);
151 });
152
153 test('a rejected CDP command releases the owned debugger and returns failure', async t => {
154 const fixture = setup(t);
155 fixture.debug.command = async () => { throw new Error('CDP unavailable'); };
156 assert.equal((await fixture.owner.capture()).status, 'failed');
157 assert.equal(fixture.debug.attached, false);
158 assert.equal(fixture.debug.detaches, 1);
159 assert.equal(fixture.analysed(), 0);
160 });
161
162 test('a timed out command cannot leave the debugger attached or resume late', async t => {
163 const fixture = setup(t, { commandTimeoutMs: 100 });
164 const command = deferred<unknown>();
165 fixture.debug.command = () => command.promise;
166 const result = fixture.owner.capture();
167 await flush();
168 await fixture.advance(100);
169 assert.equal((await result).status, 'failed');
170 assert.equal(fixture.debug.attached, false);
171 const commandsBeforeLateCompletion = fixture.debug.commands.length;
172 command.resolve({});
173 await flush();
174 await fixture.advance(10_000);
175 assert.equal(fixture.debug.commands.length, commandsBeforeLateCompletion);
176 assert.equal(fixture.debug.attaches, 1);
177 });
178
179 test('external detach invalidates ownership before a later debugger session appears', async t => {
180 const fixture = setup(t);
181 const result = fixture.owner.capture();
182 await flush();
183 fixture.debug.attached = false;
184 fixture.debug.emit('detach');
185 // DevTools or another debugger may immediately attach after Chromium detaches ours.
186 fixture.debug.attached = true;
187 await flush();
188 assert.equal((await result).status, 'cancelled');
189 await fixture.advance(10_000);
190 fixture.owner.dispose();
191 assert.equal(fixture.debug.attached, true);
192 assert.equal(fixture.debug.detaches, 0);
193 assert.equal(fixture.debug.commands.includes('Profiler.stop'), false);
194 assert.equal(fixture.analysed(), 0);
195 });
196
197 test('cancellation during pending setup prevents late setup from starting recording', async t => {
198 const fixture = setup(t);
199 const enable = deferred<unknown>();
200 fixture.debug.command = method => method === 'Profiler.enable' ? enable.promise : Promise.resolve({});
201 const result = fixture.owner.capture();
202 await flush();
203 fixture.owner.cancel();
204 enable.resolve({});
205 await flush();
206 assert.equal((await result).status, 'cancelled');
207 assert.equal(fixture.debug.commands.includes('Profiler.start'), false);
208 assert.equal(fixture.debug.attached, false);
209 });
210
211 test('a stop timeout releases the debugger and ignores a late profile reply', async t => {
212 const fixture = setup(t, { durationMs: 100, commandTimeoutMs: 50 });
213 const stop = deferred<unknown>();
214 fixture.debug.command = method => method === 'Profiler.stop' ? stop.promise : Promise.resolve({});
215 const result = fixture.owner.capture();
216 await flush();
217 await fixture.advance(100);
218 await fixture.advance(50);
219 assert.equal((await result).status, 'failed');
220 assert.equal(fixture.debug.attached, false);
221 stop.resolve({ profile });
222 await flush();
223 assert.equal(fixture.analysed(), 0);
224 assert.equal(fixture.debug.detaches, 1);
225 });
226
227 test('invalidation during analysis discards stale frames and keeps captures single flight', async t => {
228 const analysis = deferred<typeof frames>();
229 const fixture = setup(t, { durationMs: 100, analyse: () => analysis.promise });
230 const result = fixture.owner.capture();
231 await flush();
232 await fixture.advance(100);
233 assert.equal((await fixture.owner.capture()).status, 'busy');
234 fixture.invalidate();
235 analysis.resolve(frames);
236 await flush();
237 const cancelled = await result;
238 assert.equal(cancelled.status, 'cancelled');
239 assert.equal(cancelled.frames, undefined);
240 assert.equal(fixture.debug.attached, false);
241 });
242
243 test('analysis failure releases the debugger without exposing an incomplete profile', async t => {
244 const fixture = setup(t, {
245 durationMs: 100,
246 analyse: async () => { throw new Error('analysis worker unavailable'); },
247 });
248 const result = fixture.owner.capture();
249 await flush();
250 await fixture.advance(100);
251 assert.equal((await result).status, 'failed');
252 assert.equal(fixture.debug.attached, false);
253 assert.equal(fixture.debug.detaches, 1);
254 });
255
256 test('cancellation during stop retains single flight and discards the completed profile', async t => {
257 const fixture = setup(t, { durationMs: 100 });
258 const stop = deferred<unknown>();
259 fixture.debug.command = method => method === 'Profiler.stop' ? stop.promise : Promise.resolve({});
260 const result = fixture.owner.capture();
261 await flush();
262 await fixture.advance(100);
263 fixture.owner.cancel();
264 assert.equal((await fixture.owner.capture()).status, 'busy');
265 stop.resolve({ profile });
266 await flush();
267 assert.equal((await result).status, 'cancelled');
268 assert.equal(fixture.analysed(), 0);
269 assert.equal(fixture.debug.detaches, 1);
270 });
271
272 test('listener registration failure returns failure and does not permanently occupy the owner', async t => {
273 const fixture = setup(t, {
274 cooldownMs: 0,
275 onInvalidated: () => { throw new Error('renderer exited during registration'); },
276 });
277 assert.equal((await fixture.owner.capture()).status, 'failed');
278 assert.equal((await fixture.owner.capture()).status, 'failed');
279 assert.equal(fixture.debug.attaches, 0);
280 });
281
282 test('listener cleanup failure cannot skip debugger cleanup or replace the capture result', async t => {
283 const fixture = setup(t, {
284 durationMs: 100,
285 onInvalidated: () => () => { throw new Error('renderer exited during cleanup'); },
286 });
287 const result = fixture.owner.capture();
288 await flush();
289 await fixture.advance(100);
290 assert.equal((await result).status, 'captured');
291 assert.equal(fixture.debug.attached, false);
292 assert.equal(fixture.debug.detaches, 1);
293 assert.equal((await fixture.owner.capture()).status, 'cooldown');
294 });
295
296 test('a late cancellation for an old request cannot interrupt a newer capture', async t => {
297 const fixture = setup(t, { durationMs: 100, cooldownMs: 1000 });
298 const old = fixture.owner.capture('old');
299 await flush();
300 await fixture.advance(100);
301 assert.equal((await old).status, 'captured');
302 await fixture.advance(1001);
303
304 const current = fixture.owner.capture('new');
305 await flush();
306 fixture.owner.cancel('old');
307 await flush();
308 assert.equal(fixture.debug.attached, true);
309 assert.equal((await fixture.owner.capture('another')).status, 'busy');
310 await fixture.advance(99);
311 assert.equal(fixture.debug.attached, true);
312 await fixture.advance(1);
313 assert.equal((await current).status, 'captured');
314 assert.equal(fixture.analysed(), 2);
315 assert.equal(fixture.debug.attached, false);
316 });
317
318 test('a cancellation with the active request identity ends its own capture', async t => {
319 const fixture = setup(t);
320 const result = fixture.owner.capture('new');
321 await flush();
322 fixture.owner.cancel('new');
323 await flush();
324 assert.equal((await result).status, 'cancelled');
325 assert.equal(fixture.analysed(), 0);
326 assert.equal(fixture.debug.attached, false);
327 });
328
328 lines TYPESCRIPT