| 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 |