From 8a622dd453527817dc4369268fddcb929d98bd11 Mon Sep 17 00:00:00 2001 From: isonimus <19539979+Isonimus@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:44:00 +0200 Subject: [PATCH] test(web): close branch-coverage gaps in fetch + errors instrumentation The fetch and errors instrumentations had real suites but residual branch gaps. Cover them: - fetch.ts (branch 61.9% -> 90.5%, lines 100%): Headers-instance and array-of-tuples header merging, URL- and Request-object inputs, and the malformed-URL fallback in getPathname / the self-instrumentation guard. - errors.ts (branch -> 100%): the no-.error-object fallback to 'Error' (resource-load failures) and explicit file-path stripping in sanitize(). 77 tests in packages/web. --- packages/web/test/errors.test.ts | 23 ++++++++++++++++ packages/web/test/fetch.test.ts | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/web/test/errors.test.ts b/packages/web/test/errors.test.ts index c36ec16..ba9fc59 100644 --- a/packages/web/test/errors.test.ts +++ b/packages/web/test/errors.test.ts @@ -76,4 +76,27 @@ describe('initErrors', () => { const exc = span.events.find((e) => e.name === 'exception'); expect((exc?.attributes?.['exception.message'] as string).length).toBeLessThanOrEqual(256); }); + + it("falls back to 'Error' when the event carries no error object", () => { + // Resource-load failures and some synthetic events fire 'error' with no + // `.error` — the type must degrade gracefully rather than throw. + window.dispatchEvent(new ErrorEvent('error', { message: 'no error object' })); + const [span] = exporter.getFinishedSpans(); + const exc = span.events.find((e) => e.name === 'exception'); + expect(exc?.attributes?.['exception.type']).toBe('Error'); + }); + + it('strips file paths out of error messages', () => { + window.dispatchEvent( + new ErrorEvent('error', { + message: 'Boom at /src/app/checkout.js:42:7 in handler', + error: new Error(), + }), + ); + const [span] = exporter.getFinishedSpans(); + const exc = span.events.find((e) => e.name === 'exception'); + const msg = exc?.attributes?.['exception.message'] as string; + expect(msg).not.toContain('checkout.js'); + expect(msg).toContain('[file]'); + }); }); diff --git a/packages/web/test/fetch.test.ts b/packages/web/test/fetch.test.ts index ecdff2f..b58a6a6 100644 --- a/packages/web/test/fetch.test.ts +++ b/packages/web/test/fetch.test.ts @@ -118,6 +118,52 @@ describe('initFetch — self-instrumentation guard', () => { }); }); +describe('initFetch — header-shape normalization', () => { + it('merges traceparent into a Headers instance without dropping existing entries', async () => { + await window.fetch('/api/data', { headers: new Headers({ 'x-custom': 'keep-me' }) }); + const headers = lastInit?.headers as Record; + expect(headers['x-custom']).toBe('keep-me'); + expect(headers.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/); + }); + + it('merges traceparent into array-of-tuples headers without dropping existing entries', async () => { + await window.fetch('/api/data', { headers: [['x-custom', 'keep-me']] }); + const headers = lastInit?.headers as Record; + expect(headers['x-custom']).toBe('keep-me'); + expect(headers.traceparent).toBeDefined(); + }); +}); + +describe('initFetch — input types', () => { + it('instruments a URL-object input', async () => { + await window.fetch(new URL('https://api.example.com/things')); + const [span] = exporter.getFinishedSpans(); + expect(span.name).toBe('GET /things'); + expect(span.attributes['url.full']).toBe('https://api.example.com/things'); + }); + + it('derives the method from a Request-object input', async () => { + await window.fetch(new Request('/api/orders', { method: 'POST' })); + const [span] = exporter.getFinishedSpans(); + expect(span.attributes['http.request.method']).toBe('POST'); + expect(span.name).toBe('POST /api/orders'); + // The wrapper re-wraps the Request so the injected traceparent rides along. + expect((lastInit?.headers as Record).traceparent).toBeDefined(); + }); +}); + +describe('initFetch — malformed URL', () => { + it('falls back to the raw URL in the span name when URL parsing throws', async () => { + await window.fetch('http://[malformed'); + const [span] = exporter.getFinishedSpans(); + // getPathname() and the self-instrumentation guard both swallow the parse + // error; the request is still instrumented using the raw URL string. + expect(span.name).toBe('GET http://[malformed'); + expect(span.attributes['url.full']).toBe('http://[malformed'); + expect((lastInit?.headers as Record).traceparent).toBeDefined(); + }); +}); + describe('initFetch — privacy invariants', () => { it('does not capture request body or authorization headers', async () => { await window.fetch('/api/login', {