Files
nexu-io__open-design/apps/web/tests/analytics/error-tracking.test.ts
lefarcen 50843ff149 give the unattributable client failures a real error code (#5960)
* give the unattributable client failures a real error code

Three buckets in the daily stability report name nothing, and in every case the
evidence was already in scope at the emission site:

- BYOK connection test: 801 failures across 201 devices in 24h — 10% of ALL
  connection tests — reported `result.kind` verbatim, so every response the
  daemon's status→kind map had no case for collapsed into `unknown`. The user
  is told "connection failed" and we cannot say whether it was an out-of-credits
  402, a self-signed cert, or a base URL pointing at an HTML login portal.
  `ConnectionTestResponse` already carried `status` and a redacted `detail`;
  only the emission site threw them away.

- Frontend exceptions: the Top-5 table literally renders a row as
  `Error  — 235 次 / ~74 台`. Two independent sources — `buildExceptionList`
  copied `error.name`/`error.message` straight through and both can be '', and
  `parseStack` returned a zero-frame stacktrace for a stackless error, which
  PostHog drops at ingestion so neither `$exception_type` nor
  `$exception_value` survives.

- Export failures (8.1%, 210/24h): everything that was not a sidecar-skew
  message fell through to `err.name`, i.e. the literal "Error".

The daemon side of the same path flattened every sidecar contract violation to
`UPSTREAM_UNAVAILABLE`, which forced the web layer to regex-match English prose
to tell a version-skewed mesh from a dead renderer; it now forwards the
sidecar's real code, and the web classifier treats the remaining generic
envelope codes as non-classifications so a specific message still wins.

Two existing export-code expectations changed on purpose and say why inline: a
generic envelope `.code` no longer outranks message classification, and a
message carrying an HTTP status now reports that status instead of "Error".

* keep the forwarded sidecar code inside ApiErrorCode

Follow-up on the same PR. Forwarding the sidecar contract violation's `code`
compiled, but it was leaving the declared union: `ctx.http.sendApiError` is
typed `(...args: any[]) => any` in server-context.ts, so nothing caught that
`SIDECAR_UNKNOWN_MESSAGE` is not an `ApiErrorCode`. Every web-side handler
typed against that union would have failed to match the code the daemon
actually put on the wire.

Declare the two SIDECAR_* codes in API_ERROR_CODES and narrow the helper's
return type to ApiErrorCode, allowlisting exactly those two so a future sidecar
code cannot silently leave the union again.

No consumer of the export 502 switches on the old code — the UPSTREAM_UNAVAILABLE
call sites are finalize (useFinalizeProject), AMR run guidance, and ChatPane run
errors, none of which are on this route — so this is additive, not a behavior
change for anything that exists today.

* make the attribution actually fire on the production paths

Three review findings, all correct, all verified against the real code paths
rather than the synthetic ones the first cut tested.

1. The sidecar code passthrough was dead. `requestJsonIpc` rejects with
   `new Error(response.error?.message)` — its response type does not even
   declare a `code` — so SIDECAR_UNKNOWN_MESSAGE never reaches the route, and
   the helper always fell back to UPSTREAM_UNAVAILABLE. Removed the passthrough
   (and the two API_ERROR_CODES entries added for it: nothing emits them). The
   message-based classifier already achieves the attribution and is tested;
   dead code that reads as working is worse than no code. Threading the code
   through IPC → API → web export result is a three-layer change and belongs in
   its own PR.

2. The zero-frame guard checked the input instead of the output. A header-only
   stack ("Error: Script error.") is truthy, so it skipped the fallback, and the
   parser then dropped that single line as a message — yielding `frames: []`,
   the exact ingestion failure this change exists to prevent. The guard now runs
   on the PARSED result, covering missing / empty / header-only / unparseable
   stacks, with a fixture matrix over all four.

3. The certificate bucket could not fire in production. undici reports a TLS
   failure as a TypeError whose message is the generic `fetch failed`, with the
   reason only on `cause.code`, and `networkErrorToKind` maps just an allowlist
   — so a self-signed cert became `kind: unknown` + `detail: "fetch failed"`.
   The unit test passed only because it was fed a detail string production never
   emits. The daemon now appends the cause code to `detail`, and the client
   tests assert against that real shape.

* stop a 2xx status from claiming a failure it cannot explain

Review catch, and it made this PR's headline case unreachable. The status
fallback ran before every detail classifier, so the daemon's
`{ kind: 'unknown', status: 200, detail: 'Unexpected token < …' }` — which is
exactly the 'base URL points at an HTML login portal' shape, returned when a 2xx
body fails JSON.parse (connectionTest.ts) — was reported as HTTP_200, and
INVALID_JSON_RESPONSE could never fire.

Reproduced before fixing:
  byokErrorCode({kind:'unknown',status:200,detail:'Unexpected token < …'})
    → 'HTTP_200'

The cut is now at 300 rather than 0. A 2xx that still failed did so for a reason
the status cannot express, so it falls through to the detail classifiers; 3xx
stays on the status side because the daemon fetches with `redirect: 'error'`,
making a redirect a real failure the status does name.

* read the daemon-appended cause code instead of enumerating codes

Review catch on a gap my own previous commit opened: forwarding cause.code from
the daemon put OpenSSL's TLS codes on the wire, and this file could not read
them. UNABLE_TO_GET_ISSUER_CERT_LOCALLY, CERT_NOT_YET_VALID and CERT_HAS_EXPIRED
have no Node-errno shape, and the `\bcertificate\b` fallback never matches a
name that spells it CERT — so the signal was preserved on the wire and dropped
here, landing back in UNKNOWN_NO_SIGNAL.

Read the daemon's known trailing `(CODE)` position directly rather than
extending an allowlist: an enumeration would silently drop every code added
upstream after this file was written, which is the same failure mode one layer
down. Inline codes keep a widened pattern, and the TLS fallback now also matches
CERT-shaped names.

Fixtures cover all seven codes the daemon can now forward.
2026-07-22 06:57:48 +00:00

547 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearExceptionTrackingContext,
installErrorHandlers,
patchExceptionTrackingAppVersion,
reportHandledException,
setExceptionTrackingContext,
} from '../../src/analytics/error-tracking';
/**
* These tests exercise the always-on, consent-independent exception
* pipeline. They do NOT touch posthog-js. The pipeline's full contract:
*
* 1. `installErrorHandlers()` hooks `window.error` and
* `unhandledrejection` at module load. Idempotent.
* 2. Captured exceptions buffer in memory until
* `setExceptionTrackingContext({apiKey, host, distinctId})` arrives.
* 3. Once a context is set, buffered events drain to the PostHog ingest
* endpoint via `fetch`. Subsequent captures dispatch immediately.
* 4. Buffer is capped at 50 entries to bound memory in an error loop.
* 5. Scrubbing (file path redaction) runs before dispatch.
*/
const fetchMock = vi.fn();
const ORIGINAL_FETCH = globalThis.fetch;
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('', { status: 200 }));
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
clearExceptionTrackingContext();
});
afterEach(() => {
clearExceptionTrackingContext();
globalThis.fetch = ORIGINAL_FETCH;
delete (globalThis as { _posthogChunkIds?: Record<string, string> })._posthogChunkIds;
});
function lastFetchedBody(): Record<string, unknown> {
expect(fetchMock).toHaveBeenCalled();
const lastCall = fetchMock.mock.calls.at(-1)!;
const init = lastCall[1] as RequestInit;
return JSON.parse(init.body as string) as Record<string, unknown>;
}
describe('error-tracking', () => {
it('buffers captures dispatched before a context is set, then flushes', async () => {
installErrorHandlers();
// Fire a captured (handled) exception BEFORE the context is wired up.
reportHandledException(new Error('early-boom'));
expect(fetchMock).not.toHaveBeenCalled();
// Now the bootstrap completes — buffer should drain.
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-1',
appVersion: '1.2.3',
sessionId: 'session-abc',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastFetchedBody();
expect(body).toMatchObject({
api_key: 'phc_test',
event: '$exception',
distinct_id: 'user-1',
properties: expect.objectContaining({
$exception_type: 'Error',
$exception_message: 'early-boom',
app_version: '1.2.3',
session_id: 'session-abc',
}),
});
});
it('dispatches immediately when a context is already set', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-2',
});
reportHandledException(new TypeError('immediate'));
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_type: 'TypeError',
$exception_message: 'immediate',
handled: true,
});
});
it('stamps chunk_id on frames whose chunk registered one, so PostHog can match the uploaded sourcemap', () => {
// Mirror what `@posthog/cli sourcemap inject` registers at chunk load:
// key = the chunk's own Error().stack, value = its chunk id. The stack is
// multi-frame — the injected IIFE (newest, the chunk itself) sits above a
// webpack runtime frame (older) — so the id MUST key onto the newest
// frame's file (page-abc.js), not the outermost one. A loop that picked
// the wrong end would key it onto webpack-runtime.js and leave page-abc.js
// unmatched.
(globalThis as { _posthogChunkIds?: Record<string, string> })._posthogChunkIds = {
[[
'Error',
' at od://app/_next/static/chunks/page-abc.js:1:100',
' at __webpack_require__ (od://app/_next/static/chunks/webpack-runtime.js:2:200)',
].join('\n')]: 'chunk-page-abc',
};
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-chunk',
});
const err = new Error('kaboom');
// Two frames: the first is from the chunk that registered an id, the
// second from a chunk that did not.
err.stack = [
'Error: kaboom',
' at render (od://app/_next/static/chunks/page-abc.js:42:13)',
' at commit (od://app/_next/static/chunks/framework-xyz.js:7:9)',
].join('\n');
reportHandledException(err);
const body = lastFetchedBody();
const list = (body.properties as Record<string, unknown>).$exception_list as Array<{
stacktrace?: { frames?: Array<Record<string, unknown>> };
}>;
const frames = list[0]?.stacktrace?.frames ?? [];
const withChunkId = frames.filter((f) => typeof f.chunk_id === 'string');
// Exactly the frame from the registered chunk is stamped — and with the
// id PostHog will use to locate the uploaded map. No spurious ids leak
// onto frames whose chunk never registered one.
expect(withChunkId).toHaveLength(1);
expect(withChunkId[0]!.chunk_id).toBe('chunk-page-abc');
});
it('captures unhandledrejection events via the window hook', () => {
installErrorHandlers();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-3',
});
// jsdom's PromiseRejectionEvent constructor exists but doesn't auto-fire
// when a promise rejects; we synthesize one to drive the listener.
const reason = new RangeError('boom-async');
const event = new Event('unhandledrejection') as Event & {
reason?: unknown;
promise?: Promise<unknown>;
};
event.reason = reason;
event.promise = Promise.reject(reason);
// Silence Node's actual unhandled-rejection warning on the synthesized
// promise. jsdom forwards it to process otherwise.
event.promise.catch(() => undefined);
window.dispatchEvent(event);
expect(fetchMock).toHaveBeenCalled();
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_type: 'RangeError',
$exception_message: 'boom-async',
handled: false,
});
});
it('captures synchronous window.error events', () => {
installErrorHandlers();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-4',
});
const error = new Error('sync-boom');
const event = new Event('error') as Event & {
error?: unknown;
message?: string;
filename?: string;
lineno?: number;
colno?: number;
};
event.error = error;
event.message = 'sync-boom';
event.filename = 'app://apps/web/src/foo.tsx';
event.lineno = 42;
event.colno = 7;
window.dispatchEvent(event);
expect(fetchMock).toHaveBeenCalled();
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_type: 'Error',
$exception_message: 'sync-boom',
handled: false,
});
});
it('caps the buffer at 50 entries to bound memory in an error loop', () => {
// No context — every capture lands in the buffer.
for (let i = 0; i < 75; i += 1) {
reportHandledException(new Error(`loop-${i}`));
}
expect(fetchMock).not.toHaveBeenCalled();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-loop',
});
// Buffer cap is 50; the oldest 25 should have been evicted in FIFO order.
expect(fetchMock).toHaveBeenCalledTimes(50);
const firstBody = JSON.parse(
(fetchMock.mock.calls[0]![1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(
(firstBody.properties as Record<string, unknown>).$exception_message,
).toBe('loop-25');
const lastBody = lastFetchedBody();
expect(
(lastBody.properties as Record<string, unknown>).$exception_message,
).toBe('loop-74');
});
it('scrubs absolute filesystem paths from stack frames before dispatch', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-scrub',
});
// Synthesize an Error with a stack that contains a packaged-app path —
// the scrub layer should rewrite it to the repo-relative form.
const error = new Error('scrub-target');
error.stack = [
'Error: scrub-target',
' at handleClick (file:///Applications/Open Design.app/Contents/Resources/apps/web/src/FileViewer.tsx:147:23)',
' at /Users/jane/dev/checkout/apps/web/src/index.tsx:12:1',
].join('\n');
reportHandledException(error);
const body = lastFetchedBody();
const list = (body.properties as Record<string, unknown>).$exception_list as Array<{
stacktrace?: { frames?: Array<Record<string, unknown>> };
}>;
const frames = list[0]?.stacktrace?.frames;
expect(frames).toBeTruthy();
expect(frames!.length).toBeGreaterThanOrEqual(2);
for (const frame of frames!) {
const filename = frame.filename;
if (typeof filename === 'string') {
expect(filename).toMatch(/^app:\/\/apps\/web\//);
expect(filename).not.toContain('Applications/Open Design.app');
expect(filename).not.toContain('/Users/jane');
}
}
});
it('treats reportHandledException string input as a non-Error message', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-str',
});
reportHandledException('weird global signal');
const body = lastFetchedBody();
expect(body.properties).toMatchObject({
$exception_message: 'weird global signal',
handled: true,
});
});
it('drops events silently when no context is ever set (no key in env)', () => {
reportHandledException(new Error('orphan'));
expect(fetchMock).not.toHaveBeenCalled();
// Even after explicitly clearing — the buffer is bounded and harmless.
clearExceptionTrackingContext();
expect(fetchMock).not.toHaveBeenCalled();
});
it('patchExceptionTrackingAppVersion updates appVersion on dispatched events', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-patch',
appVersion: '0.0.0',
});
patchExceptionTrackingAppVersion('1.2.3');
reportHandledException(new Error('patched'));
const body = lastFetchedBody();
expect((body.properties as Record<string, unknown>).app_version).toBe('1.2.3');
});
it('patchExceptionTrackingAppVersion is a no-op when no context is set', () => {
expect(() => patchExceptionTrackingAppVersion('1.2.3')).not.toThrow();
reportHandledException(new Error('no context'));
expect(fetchMock).not.toHaveBeenCalled();
});
// Regression: 100% of our hand-built `$exception` events were failing to
// ingest with "missing field platform" because the frames omitted the
// `platform` key PostHog's exception pipeline requires. posthog-js stamps
// `web:javascript` on every frame; we must too.
it('stamps platform on every stack frame so PostHog ingestion accepts the event', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-platform',
});
const error = new Error('needs-platform');
error.stack = [
'Error: needs-platform',
' at handleClick (app://apps/web/src/FileViewer.tsx:147:23)',
' at app://apps/web/src/index.tsx:12:1',
].join('\n');
reportHandledException(error);
const body = lastFetchedBody();
const list = (body.properties as Record<string, unknown>).$exception_list as Array<{
stacktrace?: { frames?: Array<Record<string, unknown>> };
}>;
const frames = list[0]?.stacktrace?.frames;
expect(frames).toBeTruthy();
expect(frames!.length).toBeGreaterThanOrEqual(2);
for (const frame of frames!) {
expect(frame.platform).toBe('web:javascript');
}
});
// Regression: `TypeError: Failed to fetch` from the packaged renderer's
// daemon connection dropping (restart, boot race, navigation abort,
// offline) was ~90% of all captured exceptions — environmental noise.
// Drop it, but ONLY when it originates in packaged app code (od:// scheme).
it('drops packaged-app fetch noise but keeps the same error from the web app', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-noise',
});
// Packaged: the failing fetch ran in od:// app code → dropped.
const packaged = new TypeError('Failed to fetch');
packaged.stack = [
'TypeError: Failed to fetch',
' at window.fetch (od://app/_next/static/chunks/abc.js:1:100)',
' at poll (od://app/_next/static/chunks/abc.js:1:200)',
].join('\n');
reportHandledException(packaged);
expect(fetchMock).not.toHaveBeenCalled();
// Web app: SAME message, but the fetch failed in non-packaged code. In a
// browser this can be the only signal of a broken /api/* deploy or a
// CORS/TLS regression, so it must stay captured.
const web = new TypeError('Failed to fetch');
web.stack = [
'TypeError: Failed to fetch',
' at loadProjects (https://app.example.com/_next/static/chunks/x.js:1:50)',
].join('\n');
reportHandledException(web);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((lastFetchedBody().properties as Record<string, unknown>).$exception_message).toBe(
'Failed to fetch',
);
});
// Packaged exceptions don't only arrive as od:// frames — source-mapped
// frames surface as `file:///…/<Channel>.app/Contents/Resources/…` (the
// shape scrub.ts rewrites). Those packaged fetch failures must be dropped
// too, otherwise part of the noise path leaks through.
it('drops packaged fetch noise from file:// app-bundle stack frames', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-bundle',
});
const bundled = new TypeError('Failed to fetch');
bundled.stack = [
'TypeError: Failed to fetch',
' at fetchProjects (file:///Applications/Open Design.app/Contents/Resources/apps/web/src/state/projects.ts:88:14)',
].join('\n');
reportHandledException(bundled);
expect(fetchMock).not.toHaveBeenCalled();
// Beta/preview channels use a different app-bundle name — still dropped.
const beta = new TypeError('Failed to fetch');
beta.stack = [
'TypeError: Failed to fetch',
' at fetchProjects (file:///Applications/Open Design Beta.app/Contents/Resources/apps/web/src/state/projects.ts:88:14)',
].join('\n');
reportHandledException(beta);
expect(fetchMock).not.toHaveBeenCalled();
});
// AbortError is NOT blanket-dropped: it isn't necessarily fetch-lifecycle
// cancellation and was never a measured noise source. It flows through.
it('does not drop AbortError', () => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-abort',
});
const aborted = new Error('the operation was aborted.');
aborted.name = 'AbortError';
aborted.stack = 'AbortError: the operation was aborted.\n at x (od://app/_next/static/chunks/y.js:1:1)';
reportHandledException(aborted);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((lastFetchedBody().properties as Record<string, unknown>).$exception_type).toBe(
'AbortError',
);
});
// Regression: a failed telemetry beacon (PostHog unreachable) must not
// manufacture another exception. In the real browser the beacon's own
// `fetch` rejection is swallowed by the `.catch()` in dispatch(); if it
// ever surfaces anyway — as an unhandledrejection whose TypeError
// originates in our od:// transport code — the packaged noise filter is the
// backstop that stops it re-entering as a second `$exception`/beacon. This
// exercises that backstop (the part observable under jsdom — Node routes
// promise rejections to `process`, not `window.onunhandledrejection`, so
// the `.catch()` itself isn't unit-observable here).
it('a failed beacon surfacing as an unhandledrejection does not spawn a second beacon', () => {
installErrorHandlers();
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-loop',
});
// A genuine exception dispatches exactly one beacon.
reportHandledException(new Error('real-bug'));
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockClear();
// Now simulate that beacon's own request failing and surfacing as an
// unhandledrejection from our od:// transport code.
const beaconFailure = new TypeError('Failed to fetch');
beaconFailure.stack = [
'TypeError: Failed to fetch',
' at dispatch (od://app/_next/static/chunks/error-tracking.js:1:42)',
].join('\n');
const rejection = new Event('unhandledrejection') as Event & {
reason?: unknown;
promise?: Promise<unknown>;
};
rejection.reason = beaconFailure;
rejection.promise = Promise.reject(beaconFailure);
rejection.promise.catch(() => undefined);
window.dispatchEvent(rejection);
// No second beacon — the loop is broken.
expect(fetchMock).not.toHaveBeenCalled();
});
});
/**
* Attribution guards for the un-typable exception.
*
* The stability report's frontend Top 5 carries a row literally rendered as
* `Error — 235 次 / ~74 台`: an exception with a type but no value, which no
* amount of dashboard work can turn into a cause. Two independent sources:
*
* 1. `buildExceptionList` copied `error.name` / `error.message` straight
* through, and both can legitimately be '' (a bare `new Error()`, or a
* subclass whose `name` a minifier mangled away).
* 2. `parseStack` returned `[]` for a stackless error. PostHog derives
* `$exception_types` / `$exception_values` from `$exception_list` at
* ingestion and drops entries failing frame validation, so a zero-frame
* entry lands with neither field set — this is how a cross-origin
* "Script error." disappears.
*/
describe('exception attribution', () => {
beforeEach(() => {
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'user-attrib',
});
});
it('never ships an empty exception value for a message-less Error', () => {
reportHandledException(new Error(''));
const props = lastFetchedBody().properties as Record<string, unknown>;
const list = props.$exception_list as Array<{ type: string; value: string }>;
expect(list[0]!.type).not.toBe('');
expect(list[0]!.value).not.toBe('');
expect(props.$exception_message).not.toBe('');
});
it('never ships an empty exception type when the name was mangled away', () => {
const mangled = new Error('boom');
Object.defineProperty(mangled, 'name', { value: '', configurable: true });
reportHandledException(mangled);
const list = (lastFetchedBody().properties as Record<string, unknown>)
.$exception_list as Array<{ type: string; value: string }>;
expect(list[0]!.type).toBe('Error');
expect(list[0]!.value).toBe('boom');
});
it.each([
['missing stack', undefined],
['empty stack', ''],
['header-only stack', 'Error: Script error.'],
['unparseable stack', 'some vendor noise\nmore noise'],
])('ships a non-empty stacktrace for a %s', (_label, stackValue) => {
const err = new Error('Script error.');
if (stackValue === undefined) {
delete (err as { stack?: string }).stack;
} else {
Object.defineProperty(err, 'stack', { value: stackValue, configurable: true });
}
reportHandledException(err);
const list = (lastFetchedBody().properties as Record<string, unknown>)
.$exception_list as Array<{ stacktrace?: { frames?: unknown[] } }>;
expect(list[0]!.stacktrace?.frames?.length).toBeGreaterThan(0);
});
it('ships a synthetic frame rather than a zero-frame stacktrace', () => {
// A cross-origin "Script error.": no stack, no filename, nothing to parse.
const stackless = new Error('Script error.');
delete (stackless as { stack?: string }).stack;
reportHandledException(stackless);
const list = (lastFetchedBody().properties as Record<string, unknown>)
.$exception_list as Array<{ stacktrace?: { frames?: unknown[] } }>;
expect(list[0]!.stacktrace?.frames?.length).toBeGreaterThan(0);
});
});