返回 DeepSeek-Reasonix
crash-reporting.test.ts
根目录 / desktop / frontend / src / __tests__ / crash-reporting.test.ts
1 // Run: tsx src/__tests__/crash-reporting.test.ts
2
3 import {
4 aggregateLongTaskProfile,
5 buildCrashPayload,
6 buildPerformancePayload,
7 formatLongTaskAttribution,
8 formatPerformanceContext,
9 globalCrashReportReason,
10 isOpaqueScriptErrorEvent,
11 installPerformancePressureMonitor,
12 normalizeCrashError,
13 opaqueScriptFingerprintHint,
14 parseReportedPerf,
15 performanceLabelForReason,
16 performanceFingerprintHintForReason,
17 serializeReportedPerf,
18 shouldPromptForLongTasks,
19 shouldPromptForEventLoopLag,
20 shouldRecordEventLoopLagSample,
21 shouldPromptForPerformanceLabel,
22 shouldReportGlobalCrashEvent,
23 shouldRecordLongTaskSample,
24 topFrameFromStack,
25 type PerformanceSnapshot,
26 type ProfilerTrace,
27 } from "../lib/crash";
28 import { writeClipboardText } from "../lib/clipboard";
29 import { installObjectHasOwnPolyfill } from "../lib/compat";
30 import { readFileSync } from "node:fs";
31 import { dirname, resolve } from "node:path";
32 import { fileURLToPath } from "node:url";
33
34 let passed = 0;
35 let failed = 0;
36
37 function eq(a: unknown, b: unknown, label: string) {
38 if (JSON.stringify(a) === JSON.stringify(b)) {
39 process.stdout.write(` PASS ${label}\n`);
40 passed += 1;
41 } else {
42 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
43 failed += 1;
44 }
45 }
46
47 console.log("\ncrash reporting");
48
49 const testDir = dirname(fileURLToPath(import.meta.url));
50 const mainSource = readFileSync(resolve(testDir, "../main.tsx"), "utf8");
51 eq(mainSource.startsWith('import "./lib/compat";'), true, "installs WebKit compatibility before application imports");
52 const legacyObject = function LegacyObject() {} as unknown as ObjectConstructor & {
53 hasOwn?: (value: object, property: PropertyKey) => boolean;
54 };
55 installObjectHasOwnPolyfill(legacyObject);
56 eq(legacyObject.hasOwn?.({ own: true }, "own"), true, "Object.hasOwn polyfill accepts own properties");
57 eq(legacyObject.hasOwn?.(Object.create({ inherited: true }), "inherited"), false, "Object.hasOwn polyfill rejects inherited properties");
58 for (const file of ["../components/VirtualMenu.tsx", "../components/WorkspacePanel.tsx", "../components/editors/HljsDiff.tsx", "../components/editors/LineNumberCode.tsx"]) {
59 const source = readFileSync(resolve(testDir, file), "utf8");
60 const fileParts = file.split("/");
61 const label = fileParts[fileParts.length - 1];
62 eq(
63 source.includes("directDomUpdates: true") &&
64 source.includes("ref={virtualizer.containerRef}") &&
65 !source.includes("transform: `translateY(${row.start}px)`"),
66 true,
67 `${label} avoids measurement-triggered React update loops`,
68 );
69 }
70
71 const err = new TypeError("invalid argument");
72 err.stack = "TypeError: invalid argument\n at submit (src/App.tsx:12:3)";
73 const payload = buildCrashPayload("unhandledrejection", err, "component stack");
74
75 eq(normalizeCrashError("boom"), { errorType: "string", errorMessage: "boom" }, "normalizes string reasons");
76 eq(topFrameFromStack(err.stack), "at submit (src/App.tsx:12:3)", "extracts top app frame");
77 eq(payload.kind, "exception", "unhandled rejection is a nonfatal exception kind");
78 eq(payload.source, "frontend.global", "global handler payload identifies source");
79 eq(payload.errorType, "TypeError", "captures error type");
80 eq(payload.componentStack, "component stack", "captures component stack");
81 eq(payload.message.includes("[unhandledrejection]"), true, "keeps human-readable message");
82 eq(shouldReportGlobalCrashEvent({ defaultPrevented: false }), true, "reports unhandled global events by default");
83 eq(shouldReportGlobalCrashEvent({ defaultPrevented: true }), false, "ignores global events already handled by a filter");
84 eq(
85 shouldReportGlobalCrashEvent({ defaultPrevented: false, message: "ResizeObserver loop limit exceeded" }),
86 false,
87 "ignores Chromium ResizeObserver loop limit notices",
88 );
89 eq(
90 shouldReportGlobalCrashEvent({ defaultPrevented: false, message: "Minified React error #520; recovered synchronously" }),
91 false,
92 "suppresses React's recoverable concurrent-render diagnostic",
93 );
94 eq(
95 shouldReportGlobalCrashEvent({
96 defaultPrevented: false,
97 message: "ResizeObserver loop completed with undelivered notifications.",
98 }),
99 false,
100 "ignores Chromium ResizeObserver undelivered notification notices",
101 );
102 eq(isOpaqueScriptErrorEvent({ defaultPrevented: false, message: "Script error." }), true, "identifies locationless opaque script errors");
103 eq(
104 isOpaqueScriptErrorEvent({ defaultPrevented: false, message: "Script error.", filename: "wails://wails/assets/index.js" }),
105 false,
106 "keeps located script errors out of opaque grouping",
107 );
108 const opaqueHint = opaqueScriptFingerprintHint(
109 "wails://wails.localhost/tabs/123456789?token=private#abcdef123456",
110 [{ t: 1, cat: "tab hydration", msg: "private path /Users/alice/project" }],
111 "0123456789abcdefdeadbeef",
112 );
113 eq(opaqueHint, "build:0123456789abcdef|view:wails://wails.localhost/tabs/_|cats:tab_hydration", "opaque grouping uses stable safe context");
114 eq(opaqueHint.includes("alice"), false, "opaque grouping never includes breadcrumb messages");
115 eq(
116 shouldReportGlobalCrashEvent({ defaultPrevented: false, error: new Error("ResizeObserver loop limit exceeded") }),
117 false,
118 "ignores ResizeObserver notices delivered through ErrorEvent.error",
119 );
120 eq(
121 shouldReportGlobalCrashEvent({
122 defaultPrevented: false,
123 message: "",
124 error: new Error("ResizeObserver loop limit exceeded"),
125 }),
126 false,
127 "checks ErrorEvent.error when ErrorEvent.message is empty",
128 );
129 eq(
130 shouldReportGlobalCrashEvent({
131 defaultPrevented: false,
132 message: "Uncaught Error",
133 error: new Error("ResizeObserver loop limit exceeded"),
134 }),
135 false,
136 "checks ErrorEvent.error when ErrorEvent.message is a wrapper",
137 );
138 eq(
139 globalCrashReportReason({
140 defaultPrevented: false,
141 message: "Script error.",
142 filename: "wails://wails/assets/index-abc123.js",
143 lineno: 42,
144 colno: 7,
145 }),
146 "Script error.\nfilename=wails://wails/assets/index-abc123.js lineno=42 colno=7",
147 "adds script location to opaque window.error messages",
148 );
149 eq(
150 globalCrashReportReason({
151 defaultPrevented: false,
152 message: "Script error.",
153 }),
154 "Script error.",
155 "keeps opaque script errors bare when WebView provides no location",
156 );
157
158 const perf: PerformanceSnapshot = {
159 reason: "event loop lag 1300ms",
160 uptimeMs: 42_000,
161 visibility: "visible",
162 focused: true,
163 online: true,
164 hardwareConcurrency: 10,
165 deviceMemoryGb: 16,
166 jsHeap: { usedMb: 700, totalMb: 780, limitMb: 900, usagePercent: 77.7 },
167 eventLoopLag: { currentMs: 1300, maxMs: 1300, avgMs: 220, samples: 6 },
168 longTasks: {
169 count: 3,
170 totalMs: 1800,
171 maxMs: 900,
172 recent: [
173 { startMs: 40_000, durationMs: 900 },
174 { startMs: 41_000, durationMs: 500 },
175 ],
176 },
177 connection: { effectiveType: "4g", rttMs: 50, downlinkMbps: 20, saveData: false },
178 };
179 const perfPayload = buildPerformancePayload(perf);
180 eq(perfPayload.kind, "performance", "performance pressure reports use performance kind");
181 eq(perfPayload.source, "frontend.performance", "performance pressure reports identify source");
182 eq(perfPayload.label, "performance.lag", "performance pressure reports partition by stable pressure label");
183 eq(perfPayload.errorType, "PerformancePressure", "performance pressure reports use a stable error type");
184 eq(perfPayload.errorMessage.includes("1300"), false, "performance fingerprint message avoids dynamic durations");
185 eq(perfPayload.label.includes("1300"), false, "performance fingerprint label avoids dynamic durations");
186 eq(formatPerformanceContext(perf).includes("long tasks: 3"), true, "formats long task context");
187 eq(perfPayload.message.includes("event loop lag 1300ms"), true, "payload message keeps lag context");
188 eq(performanceLabelForReason("long task 900ms"), "performance.longtask", "labels long task pressure");
189 eq(performanceLabelForReason("js heap 87% of limit"), "performance.heap", "labels heap pressure");
190 eq(performanceFingerprintHintForReason("js heap 87% of limit"), "frontend.performance.heap.high", "tracks high heap pressure separately");
191 eq(performanceFingerprintHintForReason("js heap 97% of limit"), "frontend.performance.heap.critical", "tracks critical heap pressure separately");
192 eq(performanceFingerprintHintForReason("long task 900ms"), undefined, "does not repartition non-heap performance groups");
193 eq(
194 buildPerformancePayload({ ...perf, reason: "js heap 97% of limit" }).fingerprintHint,
195 "frontend.performance.heap.critical",
196 "adds the heap tier to the report fingerprint",
197 );
198 eq(shouldRecordLongTaskSample(14_000, 900, 15_000), false, "ignores startup long tasks before grace ends");
199 eq(shouldRecordLongTaskSample(16_000, 40, 15_000), false, "ignores short long-task observer entries");
200 eq(shouldRecordLongTaskSample(16_000, 900, 15_000), true, "records post-grace long tasks");
201 eq(shouldRecordLongTaskSample(60_000, 900, 15_000, true, 20_000), false, "ignores long tasks while the window is hidden");
202 eq(shouldRecordLongTaskSample(23_000, 900, 15_000, false, 20_000), false, "ignores long tasks immediately after visibility resumes");
203 eq(shouldRecordLongTaskSample(26_000, 900, 15_000, false, 20_000), true, "records long tasks after the visibility resume grace period");
204 eq(shouldRecordLongTaskSample(570_000, 92, 15_000, false, 20_000, false), false, "ignores long tasks while unfocused");
205 eq(shouldPromptForLongTasks({ count: 1, totalMs: 850, maxMs: 850 }), true, "prompts on a single 800ms+ long task");
206 eq(
207 shouldPromptForLongTasks({ count: 16, totalMs: 1_584, maxMs: 237 }),
208 false,
209 "tolerates streaming-render bursts below the 3s cumulative budget",
210 );
211 eq(shouldPromptForLongTasks({ count: 16, totalMs: 3_100, maxMs: 237 }), true, "prompts past the 3s cumulative budget");
212 eq(shouldPromptForLongTasks({ count: 2, totalMs: 3_100, maxMs: 790 }), false, "cumulative path needs at least 3 tasks");
213 eq(shouldPromptForEventLoopLag([6_007]), false, "ignores an isolated lag spike without long-task evidence");
214 eq(shouldPromptForEventLoopLag([1_350, 1_420]), true, "prompts on consecutive lag samples");
215 eq(
216 shouldPromptForEventLoopLag([1_350], { count: 1, totalMs: 900, maxMs: 900 }),
217 true,
218 "prompts on a lag spike corroborated by a blocking long task",
219 );
220 eq(
221 shouldPromptForEventLoopLag([1_350], { count: 2, totalMs: 300, maxMs: 180 }),
222 false,
223 "does not treat unrelated short long tasks as lag corroboration",
224 );
225
226 eq(formatLongTaskAttribution("self", [{ containerType: "window" }]), "", "hides the no-signal self/window attribution");
227 eq(formatLongTaskAttribution("unknown", undefined), "", "hides unknown attribution");
228 eq(
229 formatLongTaskAttribution("cross-origin-descendant", [{ containerType: "iframe", containerSrc: "https://embed.example" }]),
230 "cross-origin-descendant iframe:https://embed.example",
231 "surfaces cross-context culprits with their container",
232 );
233
234 const trace: ProfilerTrace = {
235 resources: ["wails://wails/assets/vendor-markdown.js"],
236 frames: [
237 { name: "post", resourceId: 0, line: 1, column: 130216 },
238 { name: "tick", resourceId: 0, line: 9 },
239 { name: "" },
240 ],
241 stacks: [{ frameId: 0 }, { frameId: 1, parentId: 0 }, { frameId: 2 }],
242 samples: [
243 { timestamp: 1_000, stackId: 0 },
244 { timestamp: 1_010, stackId: 0 },
245 { timestamp: 1_020, stackId: 1 },
246 { timestamp: 5_000, stackId: 0 }, // outside every long-task window
247 { timestamp: 1_030 }, // idle sample without a stack
248 { timestamp: 1_040, stackId: 2 },
249 ],
250 };
251 eq(
252 aggregateLongTaskProfile(trace, [{ startMs: 990, durationMs: 100 }]),
253 [
254 { label: "post (wails://wails/assets/vendor-markdown.js:1:130216)", samples: 2 },
255 { label: "tick (wails://wails/assets/vendor-markdown.js:9)", samples: 1 },
256 { label: "(anonymous)", samples: 1 },
257 ],
258 "counts leaf frames for samples inside long-task windows",
259 );
260 eq(aggregateLongTaskProfile(trace, []), [], "returns nothing without long-task windows");
261 eq(
262 aggregateLongTaskProfile(trace, [{ startMs: 990, durationMs: 100 }], 1),
263 [{ label: "post (wails://wails/assets/vendor-markdown.js:1:130216)", samples: 2 }],
264 "caps the frame list at maxFrames",
265 );
266
267 const framesSnapshot: PerformanceSnapshot = {
268 ...perf,
269 longTasks: {
270 count: 1,
271 totalMs: 900,
272 maxMs: 900,
273 recent: [{ startMs: 40_000, durationMs: 900, attribution: "cross-origin-descendant" }],
274 },
275 longTaskFrames: [{ label: "post (vendor-markdown.js:1)", samples: 42 }],
276 };
277 eq(
278 formatPerformanceContext(framesSnapshot).includes("900ms @ 40.0s (cross-origin-descendant)"),
279 true,
280 "recent long tasks carry their attribution",
281 );
282 eq(
283 formatPerformanceContext(framesSnapshot).includes("long task top frames (sampled):\n 42x post (vendor-markdown.js:1)"),
284 true,
285 "formats sampled top frames into the report context",
286 );
287 eq(
288 formatPerformanceContext(perf).includes("long task top frames"),
289 false,
290 "omits the frames section when no profile was captured",
291 );
292
293 eq(shouldRecordEventLoopLagSample(true, 60_000), false, "ignores event-loop lag while the window is hidden");
294 eq(shouldRecordEventLoopLagSample(false, 3_000), false, "ignores event-loop lag immediately after visibility resumes");
295 eq(shouldRecordEventLoopLagSample(false, 6_000), true, "records event-loop lag after the visibility resume grace period");
296 eq(shouldRecordEventLoopLagSample(false, 60_000, false), false, "ignores event-loop lag while unfocused");
297 eq(
298 shouldRecordEventLoopLagSample(false, 60_000, true, 3_000),
299 false,
300 "ignores event-loop lag immediately after focus resumes",
301 );
302 eq(shouldRecordEventLoopLagSample(false, 60_000, true, 6_000), true, "records event-loop lag once both resume grace windows pass");
303
304 eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false), true, "prompts an unhandled label past cooldown while visible");
305 eq(shouldPromptForPerformanceLabel(true, 11 * 60_000, false), false, "suppresses an already reported or dismissed label");
306 eq(shouldPromptForPerformanceLabel(false, 5 * 60_000, false), false, "respects the prompt cooldown window");
307 eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, true), false, "never prompts while the window is hidden");
308 eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false, false), false, "never prompts while unfocused");
309
310 {
311 let interval: (() => void) | undefined;
312 let now = 0;
313 let focused = true;
314 let promptPainted = false;
315 const previousWindow = (globalThis as any).window;
316 const previousDocument = (globalThis as any).document;
317 const previousPerformance = (globalThis as any).performance;
318 const previousPerformanceObserver = (globalThis as any).PerformanceObserver;
319 (globalThis as any).performance = { now: () => now };
320 // Listeners are intentionally no-ops: this exercises the sampler's own
321 // hidden/unfocused self-observation, i.e. the case where a throttled tick
322 // runs before the visibilitychange/focus task is delivered (the race behind
323 // the field reports #6419/#5909).
324 (globalThis as any).window = {
325 runtime: {},
326 location: { protocol: "app:", host: "test", pathname: "/", hash: "" },
327 addEventListener: () => {},
328 setInterval: (cb: () => void) => {
329 interval = cb;
330 return 1;
331 },
332 };
333 (globalThis as any).document = {
334 visibilityState: "visible",
335 hasFocus: () => focused,
336 addEventListener: () => {},
337 getElementById: () => {
338 promptPainted = true;
339 return null;
340 },
341 };
342 (globalThis as any).PerformanceObserver = undefined;
343 installPerformancePressureMonitor();
344 now = 26_000;
345 interval?.();
346 eq(promptPainted, false, "first post-grace event-loop tick primes without reporting startup backlog");
347
348 // Hidden-view timer throttling defers ticks; when the view is shown again the
349 // overdue tick can run before any visibilitychange handler. The accumulated
350 // delay must read as suspension, not as an event-loop lag report.
351 now = 27_000;
352 interval?.(); // records a 0ms sample in the steady visible state
353 (globalThis as any).document.visibilityState = "hidden";
354 now = 47_000;
355 interval?.(); // hidden tick: observed hidden, sample dropped (visibilitychange never delivered)
356 (globalThis as any).document.visibilityState = "visible";
357 now = 49_500;
358 try {
359 interval?.(); // resume-boundary tick, 1.5s overdue, visibilitychange still not delivered
360 } catch {
361 // a regressed sampler paints into the stubbed DOM and throws; the eq below reports it
362 }
363 eq(promptPainted, false, "resume-boundary tick does not report suspended-timer delay as event-loop lag");
364
365 now = 50_500;
366 interval?.(); // re-primes after the restart
367 now = 51_500;
368 interval?.();
369 now = 52_500;
370 interval?.();
371 now = 53_500;
372 interval?.();
373 now = 54_500;
374 interval?.(); // grace over, steady 0ms samples resume
375
376 // Focus-only cycle, self-observed: the window loses focus (a throttled tick
377 // observes it before any blur task), the app naps, and on refocus the overdue
378 // tick runs before the focus task. Without focus tracking this reads as a
379 // multi-second lag spike and prompts (the #6138 path #6424 must absorb).
380 focused = false;
381 now = 59_000;
382 interval?.(); // unfocused tick: arms the resume restart, records nothing
383 focused = true;
384 now = 61_500;
385 try {
386 interval?.(); // refocus-boundary tick, 1.5s overdue, focus task not yet delivered
387 } catch {
388 // a regressed sampler paints into the stubbed DOM and throws; the eq below reports it
389 }
390 eq(promptPainted, false, "refocus-boundary tick does not report napped-timer delay as event-loop lag");
391
392 now = 62_600;
393 interval?.(); // re-primes
394 now = 63_600;
395 interval?.();
396 now = 64_600;
397 interval?.();
398 now = 65_600;
399 interval?.();
400 now = 66_600;
401 interval?.(); // both grace windows over, steady samples resume
402
403 // A single delayed callback can still be a timer discontinuity. It is held
404 // until the next delayed sample (or a long-task entry) corroborates the freeze.
405 let promptAttempted = false;
406 (globalThis as any).document.getElementById = () => {
407 promptAttempted = true;
408 throw new Error("stop before painting into the stubbed DOM");
409 };
410 now = 112_600;
411 try {
412 interval?.(); // isolated 45s delay: not enough evidence by itself
413 } catch {
414 // paint intentionally stopped at getElementById
415 }
416 eq(promptAttempted, false, "an isolated settled-state timer discontinuity does not prompt");
417 now = 115_100;
418 try {
419 interval?.(); // a second consecutive 1.5s delay corroborates sustained lag
420 } catch {
421 // paint intentionally stopped at getElementById
422 }
423 eq(promptAttempted, true, "consecutive settled-state lag still prompts");
424 (globalThis as any).window = previousWindow;
425 (globalThis as any).document = previousDocument;
426 (globalThis as any).performance = previousPerformance;
427 (globalThis as any).PerformanceObserver = previousPerformanceObserver;
428 }
429
430 const reportedPerf = serializeReportedPerf(new Set(["performance.lag"]), "abc123");
431 eq([...parseReportedPerf(reportedPerf, "abc123")], ["performance.lag"], "round-trips reported labels for the same build");
432 eq([...parseReportedPerf(reportedPerf, "def456")], [], "re-surfaces reported labels on a new build");
433 eq([...parseReportedPerf(null, "abc123")], [], "tolerates missing storage");
434 eq([...parseReportedPerf("{not json", "abc123")], [], "tolerates corrupt storage");
435
436 {
437 const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
438 const previousWindow = (globalThis as any).window;
439 const previousHTMLElement = (globalThis as any).HTMLElement;
440 const setNavigator = (value: unknown) =>
441 Object.defineProperty(globalThis, "navigator", { value, configurable: true });
442
443 setNavigator({ clipboard: { writeText: async () => {} } });
444 eq(await writeClipboardText("report"), true, "copy reports success through the async clipboard API");
445
446 const rejectingClipboard = {
447 clipboard: {
448 writeText: async () => {
449 throw new Error("denied");
450 },
451 },
452 };
453 setNavigator(rejectingClipboard);
454 let bridgeCalls = 0;
455 (globalThis as any).window = {
456 runtime: {
457 ClipboardSetText: async (value: string) => {
458 bridgeCalls += 1;
459 return value.length > 0;
460 },
461 },
462 };
463 eq(await writeClipboardText("report"), true, "copy falls back to the Wails native clipboard bridge when the clipboard API rejects");
464 eq(bridgeCalls, 1, "the rejected clipboard write goes through the native bridge exactly once");
465
466 setNavigator(rejectingClipboard);
467 const execCommands: string[] = [];
468 (globalThis as any).window = {};
469 (globalThis as any).HTMLElement = class {};
470 (globalThis as any).document = {
471 activeElement: undefined,
472 getSelection: () => null,
473 createElement: () => ({ value: "", style: {}, setAttribute: () => {}, select: () => {}, remove: () => {} }),
474 body: { appendChild: () => {} },
475 execCommand: (command: string) => {
476 execCommands.push(command);
477 return true;
478 },
479 };
480 eq(await writeClipboardText("report"), true, "copy falls back to execCommand when both the clipboard API and bridge are unavailable");
481 eq(execCommands, ["copy"], "the last-resort path drives the execCommand copy");
482
483 // Some WebViews reject execCommand("copy") with NotAllowedError. It must
484 // surface as a resolved `false`, never a thrown rejection — otherwise the crash
485 // overlay's Copy button stays disabled (the #6388 unresponsive symptom).
486 let removed = false;
487 (globalThis as any).document = {
488 activeElement: undefined,
489 getSelection: () => null,
490 createElement: () => ({ value: "", style: {}, setAttribute: () => {}, select: () => {}, remove: () => { removed = true; } }),
491 body: { appendChild: () => {} },
492 execCommand: () => {
493 throw new DOMException("not allowed", "NotAllowedError");
494 },
495 };
496 let threw = false;
497 let result: boolean | undefined;
498 try {
499 result = await writeClipboardText("report");
500 } catch {
501 threw = true;
502 }
503 eq(threw, false, "writeClipboardText never rejects when execCommand throws");
504 eq(result, false, "an execCommand that throws resolves to a failed copy");
505 eq(removed, true, "the hidden textarea is still cleaned up when execCommand throws");
506 delete (globalThis as any).document;
507
508 (globalThis as any).window = previousWindow;
509 if (previousHTMLElement === undefined) delete (globalThis as any).HTMLElement;
510 else (globalThis as any).HTMLElement = previousHTMLElement;
511 if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator);
512 else delete (globalThis as any).navigator;
513 }
514
515 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
516 if (failed > 0) process.exit(1);
517
517 lines TYPESCRIPT