mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Problem Supabase Select 2026 has finished. The promo banners already hide via the scheduled expiry from #51006, but the campaign code, assets, and wiring are still in the tree. ## Solution Remove the Select 2026 sitewide promotion across www and Studio: - Delete shared `Select26*` banner code, font, and tests from `ui-patterns` - Delete Studio `BannerSelect2026*` and its Banner Stack registration - Unmount the www announcement banner and revert the State of Startups spacing that only existed for it - Drop the Select-only session-replay `data-band` allowlist entry and lint ratchet baseline Event go pages, blog posts, and other Select content are left alone. The `Announcement` shell stays for the next campaign. ## Review instructions 1. Open the www homepage on the deploy preview. Confirm there is no Select announcement bar above the nav. 2. Open `/state-of-startups` on the deploy preview. Confirm the hero still looks correct with no extra top gap from the removed banner. 3. Open a hosted Studio dashboard page on the deploy preview. Confirm the Banner Stack no longer shows a Select card. ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [ ] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs [style guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide)
588 lines
21 KiB
TypeScript
588 lines
21 KiB
TypeScript
import { buildSessionRecordingConfig, type CapturedNetworkRequest } from 'common'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
maskReplayAttribute,
|
|
maskReplayCssUrls,
|
|
maskReplayNetworkRequest,
|
|
maskReplayText,
|
|
SESSION_REPLAY_CONFIG,
|
|
} from './session-replay'
|
|
|
|
const elementWith = (attributes: Record<string, string>) => {
|
|
const element = document.createElement('span')
|
|
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value))
|
|
return element
|
|
}
|
|
|
|
const networkRequest = (name: string): CapturedNetworkRequest => ({
|
|
name,
|
|
entryType: 'resource',
|
|
startTime: 0,
|
|
duration: 0,
|
|
})
|
|
|
|
describe('maskReplayText', () => {
|
|
it('masks text by default', () => {
|
|
expect(maskReplayText('postgresql://postgres:hunter2@db.abc.supabase.co:5432')).toBe(
|
|
'*'.repeat('postgresql://postgres:hunter2@db.abc.supabase.co:5432'.length)
|
|
)
|
|
})
|
|
|
|
it('masks text when no element is given', () => {
|
|
expect(maskReplayText('secret', undefined)).toBe('******')
|
|
})
|
|
|
|
it('masks based on trimmed length so whitespace is not leaked', () => {
|
|
expect(maskReplayText(' abc ')).toBe('***')
|
|
})
|
|
|
|
it('captures text opted in with data-ph-capture', () => {
|
|
const element = elementWith({ 'data-ph-capture': 'true' })
|
|
expect(maskReplayText('Table editor', element)).toBe('Table editor')
|
|
})
|
|
|
|
it('masks text when data-ph-capture is not exactly "true"', () => {
|
|
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'false' }))).toBe('******')
|
|
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': '' }))).toBe('******')
|
|
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'TRUE' }))).toBe('******')
|
|
})
|
|
|
|
it('masks text on elements carrying unrelated data attributes', () => {
|
|
expect(maskReplayText('secret', elementWith({ 'data-capture': 'true' }))).toBe('******')
|
|
})
|
|
})
|
|
|
|
describe('maskReplayAttribute', () => {
|
|
it.each([
|
|
['placeholder', 'Search in customer-invoices...'],
|
|
['title', 'acme-production'],
|
|
['aria-label', 'Delete project acme'],
|
|
['aria-describedby-text', 'acme'],
|
|
['alt', 'Avatar for jane@acme.com'],
|
|
['value', 'postgresql://postgres:hunter2@db.abc.supabase.co:5432'],
|
|
['label', 'acme-production'],
|
|
['name', 'acme'],
|
|
['data-project-ref', 'abcdefghijklmnop'],
|
|
['data-testid', 'project-acme'],
|
|
])('masks %o, which can carry interpolated customer data', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe('*')
|
|
})
|
|
|
|
it.each(['href', 'src', 'srcset', 'action', 'poster', 'formaction', 'data-href'])(
|
|
'masks %o, since project and storage paths ride in URLs',
|
|
(name) => {
|
|
const url = 'https://abc.supabase.co/storage/v1/object/invoices/q4.pdf'
|
|
expect(maskReplayAttribute(name, url, elementWith({}))).toBe('*')
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
['class', 'flex items-center gap-2 text-foreground-light'],
|
|
['type', 'checkbox'],
|
|
['colspan', '3'],
|
|
['dir', 'ltr'],
|
|
['role', 'menuitem'],
|
|
['tabindex', '-1'],
|
|
['aria-hidden', 'true'],
|
|
['aria-expanded', 'false'],
|
|
['data-state', 'open'],
|
|
['data-orientation', 'vertical'],
|
|
])('leaves %o alone, since replay needs it to render', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['d', 'M4 6h16M4 12h16M4 18h16'],
|
|
['viewBox', '0 0 24 24'],
|
|
['stroke-width', '2'],
|
|
['fill', 'none'],
|
|
['xmlns', 'http://www.w3.org/2000/svg'],
|
|
])('leaves SVG geometry attribute %o alone, so icons still render', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['fill', 'url(#colorUv)'],
|
|
['clip-path', 'url(#clipPath-recharts-1)'],
|
|
['mask', 'url(#mask-1)'],
|
|
['filter', 'url(#shadow-1)'],
|
|
['marker-end', 'url(#arrow)'],
|
|
])('leaves SVG reference %o intact, since masking it drops the effect', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['rr_width', '1280'],
|
|
['rr_scrollTop', '240'],
|
|
['rr_open_mode', 'closed'],
|
|
['_cssText', '.a{color:red}'],
|
|
])(
|
|
'leaves rrweb-generated attribute %o alone, which posthog-js does not exempt for callbacks',
|
|
(name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
}
|
|
)
|
|
|
|
it('masks attributes it has never seen, so a new leak is masked rather than recorded', () => {
|
|
expect(maskReplayAttribute('data-some-future-attribute', 'acme')).toBe('*')
|
|
expect(maskReplayAttribute('tooltip', 'acme')).toBe('*')
|
|
})
|
|
|
|
it('matches attribute names case-insensitively', () => {
|
|
expect(maskReplayAttribute('CLASS', 'flex')).toBe('flex')
|
|
expect(maskReplayAttribute('ARIA-LABEL', 'acme')).toBe('*')
|
|
})
|
|
|
|
it('keeps stylesheet URLs, which replay needs for sheets rrweb could not inline', () => {
|
|
const href = 'https://supabase.com/dashboard/_next/static/css/main.css'
|
|
const link = document.createElement('link')
|
|
link.setAttribute('rel', 'stylesheet')
|
|
expect(maskReplayAttribute('href', href, link)).toBe(href)
|
|
})
|
|
|
|
it.each(['preload', 'icon', 'preconnect', ''])(
|
|
'masks a <link rel=%o> href, which is not a stylesheet',
|
|
(rel) => {
|
|
const link = document.createElement('link')
|
|
link.setAttribute('rel', rel)
|
|
expect(
|
|
maskReplayAttribute('href', 'https://x.test/customer-avatar.png?token=secret', link)
|
|
).toBe('*')
|
|
}
|
|
)
|
|
|
|
it('masks a <link> href with no rel at all', () => {
|
|
const link = document.createElement('link')
|
|
expect(maskReplayAttribute('href', 'https://x.test/a.png?token=secret', link)).toBe('*')
|
|
})
|
|
|
|
it('keeps a stylesheet href in an XHTML document, where tagName is lowercased', () => {
|
|
const xhtml = document.implementation.createDocument(
|
|
'http://www.w3.org/1999/xhtml',
|
|
'html',
|
|
null
|
|
)
|
|
const link = xhtml.createElementNS('http://www.w3.org/1999/xhtml', 'link')
|
|
link.setAttribute('rel', 'stylesheet')
|
|
expect(link.tagName).toBe('link')
|
|
expect(maskReplayAttribute('href', '/app.css', link)).toBe('/app.css')
|
|
})
|
|
|
|
it('masks anchor URLs even though stylesheet URLs are kept', () => {
|
|
const href = 'https://supabase.com/dashboard/project/abcdefghijklmnop/editor'
|
|
const anchor = document.createElement('a')
|
|
expect(maskReplayAttribute('href', href, anchor)).toBe('*')
|
|
})
|
|
|
|
it('masks when no element is given', () => {
|
|
const href = 'https://supabase.com/dashboard/project/abc'
|
|
expect(maskReplayAttribute('href', href, undefined)).toBe('*')
|
|
})
|
|
|
|
it('masks HTML ids, which Studio binds to storage bucket names', () => {
|
|
expect(maskReplayAttribute('id', 'customer-invoices', elementWith({}))).toBe('*')
|
|
expect(maskReplayAttribute('for', 'customer-invoices')).toBe('*')
|
|
})
|
|
|
|
it('keeps SVG ids, which fill="url(#id)" references for gradients and clip paths', () => {
|
|
const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
|
|
expect(maskReplayAttribute('id', 'recharts-gradient-1', gradient)).toBe('recharts-gradient-1')
|
|
})
|
|
|
|
it('masks the interpolated placeholders found in Studio', () => {
|
|
const storageSearch = 'Search in customer-invoices...'
|
|
expect(maskReplayAttribute('placeholder', storageSearch)).toBe('*')
|
|
|
|
const authCallback = 'https://abcdefghijklmnop.supabase.co/auth/v1/callback'
|
|
expect(maskReplayAttribute('placeholder', authCallback)).toBe('*')
|
|
})
|
|
|
|
it('keeps CSS that carries no url()', () => {
|
|
expect(maskReplayAttribute('style', 'transform: translateX(4px)')).toBe(
|
|
'transform: translateX(4px)'
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
'background-image: url("data:image/png;base64,iVBORw0KGgoAAAA")',
|
|
"background-image: url('https://abc.supabase.co/storage/v1/object/sign/invoices/q4.pdf')",
|
|
'background-image: url(https://abc.supabase.co/avatars/jane.png)',
|
|
])('masks the url() target in %o', (css) => {
|
|
const masked = maskReplayAttribute('style', css)
|
|
expect(masked).toBe('background-image: url(*)')
|
|
})
|
|
|
|
it('leaves url() inside inlined stylesheets alone, which are our own CSS', () => {
|
|
const sheet = '.a{background:url("https://x.com/a.png")}'
|
|
expect(maskReplayAttribute('_cssText', sheet)).toBe(sheet)
|
|
})
|
|
|
|
it('keeps the surrounding declaration when masking a url()', () => {
|
|
expect(
|
|
maskReplayAttribute('style', 'background: url("https://x.com/a.png") no-repeat center')
|
|
).toBe('background: url(*) no-repeat center')
|
|
})
|
|
|
|
it('masks every url() in a value, not just the first', () => {
|
|
expect(maskReplayCssUrls('a{background:url(x.png)}b{background:url(y.png)}')).toBe(
|
|
'a{background:url(*)}b{background:url(*)}'
|
|
)
|
|
})
|
|
|
|
it('masks a url() target containing a bracket, legal in a storage object name', () => {
|
|
expect(maskReplayCssUrls('background: url("invoices/q4 (final).pdf")')).toBe(
|
|
'background: url(*)'
|
|
)
|
|
})
|
|
|
|
it('leaves gradients alone, since they carry no URL', () => {
|
|
const gradient = 'background: linear-gradient(to right, #fff 0%, #000 100%)'
|
|
expect(maskReplayCssUrls(gradient)).toBe(gradient)
|
|
})
|
|
|
|
it('masks to a constant, so value length is not leaked', () => {
|
|
expect(maskReplayAttribute('title', 'ab')).toBe('*')
|
|
expect(maskReplayAttribute('title', 'abcdefgh')).toBe('*')
|
|
expect(maskReplayAttribute('title', 'a'.repeat(500))).toBe('*')
|
|
})
|
|
|
|
it('masks a url() target whose name contains an escaped quote', () => {
|
|
expect(maskReplayCssUrls('background: url("invoices/(acme) \\"q4\\".pdf")')).toBe(
|
|
'background: url(*)'
|
|
)
|
|
})
|
|
|
|
it('masks an unquoted url() target containing an escaped bracket', () => {
|
|
expect(maskReplayCssUrls('background: url(invoices/q4\\)secret.pdf)')).toBe(
|
|
'background: url(*)'
|
|
)
|
|
})
|
|
|
|
it('masks an unterminated url() token rather than letting it through', () => {
|
|
expect(maskReplayCssUrls('background: url(trailing\\')).not.toContain('trailing')
|
|
expect(maskReplayCssUrls('background: url(secret\\)')).not.toContain('secret')
|
|
})
|
|
|
|
it.each([
|
|
'background: URL("https://x.test/secret.png")',
|
|
'background: Url(https://x.test/secret.png)',
|
|
])('masks url() regardless of case in %o', (css) => {
|
|
expect(maskReplayCssUrls(css)).toContain('url(*)')
|
|
expect(maskReplayCssUrls(css)).not.toContain('secret')
|
|
})
|
|
|
|
it.each([
|
|
['fill', 'URL(https://x.test/secret.svg#paint)'],
|
|
['clip-path', 'Url(https://x.test/secret.svg#c)'],
|
|
])('masks an uppercase URL() in SVG reference %o', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe('*')
|
|
})
|
|
|
|
it.each([
|
|
['fill', 'URL(#colorUv)'],
|
|
['clip-path', 'Url(#clipPath-1)'],
|
|
])('keeps an uppercase fragment-only URL() in %o', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it('keeps a fragment-only url(), which names a node in the recording', () => {
|
|
expect(maskReplayAttribute('style', 'clip-path: url(#clip-1)')).toBe('clip-path: url(#clip-1)')
|
|
expect(maskReplayCssUrls('mask: url("#m")')).toBe('mask: url("#m")')
|
|
})
|
|
|
|
it('masks an external url() even when a fragment is appended', () => {
|
|
expect(maskReplayCssUrls('fill: url(https://x.test/customer.svg#paint)')).toBe('fill: url(*)')
|
|
})
|
|
|
|
it.each([
|
|
['fill', 'url(https://x.test/customer-secret.svg#paint)'],
|
|
['stroke', 'url("https://x.test/secret.svg#s")'],
|
|
['clip-path', 'url(https://x.test/secret.svg#c)'],
|
|
['mask', 'url(https://x.test/secret.svg#m)'],
|
|
['filter', 'url(https://x.test/secret.svg#f)'],
|
|
['marker-end', 'url(https://x.test/secret.svg#a)'],
|
|
])('masks SVG reference %o pointing at an external URL', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe('*')
|
|
})
|
|
|
|
it.each([
|
|
['fill', '#24b47e'],
|
|
['fill', 'none'],
|
|
['stroke', 'currentColor'],
|
|
])('keeps plain SVG presentation value %o %o', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each(['dark', 'light', 'system'])(
|
|
'keeps data-theme=%o, which monaco.css and grid.css select on',
|
|
(theme) => {
|
|
expect(maskReplayAttribute('data-theme', theme)).toBe(theme)
|
|
}
|
|
)
|
|
|
|
it('masks a data-theme value outside the known themes', () => {
|
|
expect(maskReplayAttribute('data-theme', 'acme-production')).toBe('*')
|
|
})
|
|
|
|
it.each([
|
|
['data-collapsible', 'icon'],
|
|
['data-variant', 'destructive'],
|
|
['data-sidebar', 'menu-button'],
|
|
['data-active', 'true'],
|
|
['data-size', 'sm'],
|
|
['data-selected', 'true'],
|
|
['data-expanded', 'false'],
|
|
['data-motion', 'from-start'],
|
|
['data-invalid', 'true'],
|
|
['data-front', 'true'],
|
|
['data-invisible', 'true'],
|
|
['data-separator', 'active'],
|
|
['data-vaul-drawer-direction', 'right'],
|
|
['aria-invalid', 'true'],
|
|
['aria-pressed', 'false'],
|
|
])('keeps %o, which a Tailwind variant in packages/ui selects on', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['rows', '8'],
|
|
['cols', '40'],
|
|
])('keeps textarea %o, since masking it collapses the box in replay', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it('masks data-value, which cmdk fills with the item value', () => {
|
|
expect(maskReplayAttribute('data-value', 'customer_email')).toBe('*')
|
|
})
|
|
|
|
it('keeps data-chart, which the chart stylesheet selector has to match', () => {
|
|
expect(maskReplayAttribute('data-chart', 'chart-r1a')).toBe('chart-r1a')
|
|
})
|
|
|
|
it('keeps data-index, which monaco-editor selects on by value', () => {
|
|
expect(maskReplayAttribute('data-index', '0')).toBe('0')
|
|
})
|
|
|
|
it.each([
|
|
['data-sonner-toast', ''],
|
|
['data-mounted', 'true'],
|
|
['data-visible', 'false'],
|
|
['data-x-position', 'right'],
|
|
['data-y-position', 'bottom'],
|
|
['data-type', 'error'],
|
|
['data-styled', 'true'],
|
|
['data-removed', 'false'],
|
|
])('keeps sonner marker %o, without which every toast is invisible in replay', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['data-vaul-overlay', ''],
|
|
['data-vaul-snap-points', 'false'],
|
|
['data-vaul-drawer', ''],
|
|
])('keeps vaul marker %o', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['data-radix-portal', ''],
|
|
['data-radix-popper-content-wrapper', ''],
|
|
['data-footnote-ref', ''],
|
|
])('keeps %o, which Studio stylesheets and Radix positioning select on', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['text-anchor', 'middle'],
|
|
['dominant-baseline', 'central'],
|
|
['dy', '0.71em'],
|
|
['dx', '-4'],
|
|
['gradientUnits', 'userSpaceOnUse'],
|
|
['gradientTransform', 'translate(263 73.5) rotate(-143.669)'],
|
|
// Names as they appear in the DOM, which is what rrweb reads. `maskUnits` and
|
|
// `gradientUnits` are camelCase in the SVG spec; `shape-rendering` is hyphenated,
|
|
// and React's `shapeRendering` prop sets that name.
|
|
['maskUnits', 'userSpaceOnUse'],
|
|
['shape-rendering', 'geometricPrecision'],
|
|
])('keeps SVG placement attribute %o, so geometry stays put', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it('masks the React prop spelling, since rrweb never sees it', () => {
|
|
// A guard against allowlisting a camelCase prop name that no DOM attribute matches.
|
|
expect(maskReplayAttribute('shapeRendering', 'geometricPrecision')).toBe('*')
|
|
})
|
|
|
|
it.each([
|
|
['patternUnits', 'userSpaceOnUse'],
|
|
['patternTransform', 'translate(-8,-8)'],
|
|
['markerUnits', 'strokeWidth'],
|
|
['orient', 'auto-start-reverse'],
|
|
['refX', '5'],
|
|
['font-size', '10'],
|
|
])('keeps %o, which a rendering library emits and depends on', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it.each([
|
|
['draggable', 'true'],
|
|
['align', 'right'],
|
|
['data-field', 'instance-details'],
|
|
])('keeps %o, selected on by value in Studio stylesheets', (name, value) => {
|
|
expect(maskReplayAttribute(name, value)).toBe(value)
|
|
})
|
|
|
|
it('keeps stroke-dashoffset, which draws the fill level of a usage ring', () => {
|
|
// Masked it is invalid, so the browser falls back to 0 and the ring replays full.
|
|
expect(maskReplayAttribute('stroke-dashoffset', 'calc(75.39822 - 18.8)')).toBe(
|
|
'calc(75.39822 - 18.8)'
|
|
)
|
|
expect(maskReplayAttribute('stroke-dasharray', '75.39822')).toBe('75.39822')
|
|
})
|
|
|
|
it('passes _cssText through, since it carries our own inlined stylesheets', () => {
|
|
const fontFace = "@font-face{src:url('../fonts/inter/InterVariable.woff2') format('woff2')}"
|
|
expect(maskReplayAttribute('_cssText', fontFace)).toBe(fontFace)
|
|
|
|
const checkbox =
|
|
'.rdg-cell [type=checkbox]:checked{background-image:url("data:image/svg+xml,%3Csvg%3E")}'
|
|
expect(maskReplayAttribute('_cssText', checkbox)).toBe(checkbox)
|
|
})
|
|
|
|
it('still masks url() in an inline style attribute', () => {
|
|
expect(maskReplayAttribute('style', 'background-image: url("https://x.test/secret.png")')).toBe(
|
|
'background-image: url(*)'
|
|
)
|
|
})
|
|
|
|
it('keeps the SVG namespace exemption to id only', () => {
|
|
const svgAnchor = document.createElementNS('http://www.w3.org/2000/svg', 'a')
|
|
expect(maskReplayAttribute('href', 'https://x.test/secret', svgAnchor)).toBe('*')
|
|
const svgImage = document.createElementNS('http://www.w3.org/2000/svg', 'image')
|
|
expect(maskReplayAttribute('href', 'https://x.test/secret.png', svgImage)).toBe('*')
|
|
})
|
|
})
|
|
|
|
describe('maskReplayNetworkRequest', () => {
|
|
it('strips query strings', () => {
|
|
expect(
|
|
maskReplayNetworkRequest(networkRequest('https://api.supabase.com/v1/x?token=abc')).name
|
|
).toBe('https://api.supabase.com/v1/x')
|
|
})
|
|
|
|
it('strips fragments, which carry GoTrue access tokens on auth callbacks', () => {
|
|
expect(
|
|
maskReplayNetworkRequest(networkRequest('https://supabase.com/dashboard#access_token=abc'))
|
|
.name
|
|
).toBe('https://supabase.com/dashboard')
|
|
})
|
|
|
|
it('strips from the first separator when both are present', () => {
|
|
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a?b=1#c=2')).name).toBe(
|
|
'https://x.com/a'
|
|
)
|
|
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a#c=2?b=1')).name).toBe(
|
|
'https://x.com/a'
|
|
)
|
|
})
|
|
|
|
it('leaves URLs without a query string or fragment alone', () => {
|
|
expect(maskReplayNetworkRequest(networkRequest('https://x.com/project/abc/editor')).name).toBe(
|
|
'https://x.com/project/abc/editor'
|
|
)
|
|
})
|
|
|
|
it('returns the request rather than dropping it, so timings are still captured', () => {
|
|
const request = networkRequest('https://x.com/a?b=1')
|
|
expect(maskReplayNetworkRequest(request)).toBe(request)
|
|
})
|
|
})
|
|
|
|
describe('SESSION_REPLAY_CONFIG', () => {
|
|
it('masks all text and inputs', () => {
|
|
expect(SESSION_REPLAY_CONFIG.maskTextSelector).toBe('*')
|
|
expect(SESSION_REPLAY_CONFIG.maskAllInputs).toBe(true)
|
|
expect(SESSION_REPLAY_CONFIG.maskTextFn).toBe(maskReplayText)
|
|
})
|
|
|
|
it('never records request or response payloads', () => {
|
|
expect(SESSION_REPLAY_CONFIG.recordHeaders).toBe(false)
|
|
expect(SESSION_REPLAY_CONFIG.recordBody).toBe(false)
|
|
})
|
|
|
|
it('never records canvas, which text masking cannot reach', () => {
|
|
expect(SESSION_REPLAY_CONFIG.captureCanvas).toEqual({ recordCanvas: false })
|
|
})
|
|
|
|
it('strips sensitive URL parts via maskReplayNetworkRequest', () => {
|
|
expect(SESSION_REPLAY_CONFIG.maskCapturedNetworkRequestFn).toBe(maskReplayNetworkRequest)
|
|
})
|
|
|
|
it('masks attributes, which maskTextFn cannot reach', () => {
|
|
expect(SESSION_REPLAY_CONFIG.maskAttributeFn).toBe(maskReplayAttribute)
|
|
})
|
|
|
|
it('pins maskAllElementAttributes off, since true would discard maskAttributeFn', () => {
|
|
expect(SESSION_REPLAY_CONFIG.maskAllElementAttributes).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('buildSessionRecordingConfig', () => {
|
|
it('disables recording when given no policy', () => {
|
|
const config = buildSessionRecordingConfig()
|
|
|
|
expect(config.disable_session_recording).toBe(true)
|
|
expect(config).not.toHaveProperty('session_recording')
|
|
})
|
|
|
|
it('disables recording when the policy is undefined', () => {
|
|
const config = buildSessionRecordingConfig(undefined)
|
|
|
|
expect(config.disable_session_recording).toBe(true)
|
|
expect(config).not.toHaveProperty('session_recording')
|
|
})
|
|
|
|
it('enables recording and forwards the policy when given one', () => {
|
|
const config = buildSessionRecordingConfig(SESSION_REPLAY_CONFIG)
|
|
|
|
expect(config.disable_session_recording).toBe(false)
|
|
expect(config.session_recording).toBe(SESSION_REPLAY_CONFIG)
|
|
})
|
|
|
|
it.each([undefined, SESSION_REPLAY_CONFIG])(
|
|
'never records console logs, which masking cannot reach (%#)',
|
|
(sessionReplay) => {
|
|
expect(buildSessionRecordingConfig(sessionReplay).enable_recording_console_log).toBe(false)
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('IS_SESSION_REPLAY_ENABLED', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('is true only for the exact string "true"', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', 'true')
|
|
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
|
|
expect(IS_SESSION_REPLAY_ENABLED).toBe(true)
|
|
})
|
|
|
|
it.each(['false', '', 'TRUE', '1'])('is false for %o', async (value) => {
|
|
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', value)
|
|
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
|
|
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
|
|
})
|
|
|
|
it('is false when unset', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', undefined)
|
|
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
|
|
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
|
|
})
|
|
})
|