>>24,f>>>=p,c-=p,p=g>>>16&255,0===p)A[n++]=65535&g;else{if(!(16&p)){if(0==(64&p)){g=u[(65535&g)+(f&(1<>>=p,c-=p),c<15&&(f+=z[a++]<>>24,f>>>=p,c-=p,p=g>>>16&255,!(16&p)){if(0==(64&p)){g=w[(65535&g)+(f&(1<o){t.msg="invalid distance too far back",E.mode=16209;break t}if(f>>>=p,c-=p,p=n-s,v>p){if(p=v-p,p>h&&E.sane){t.msg="invalid distance too far back",E.mode=16209;break t}if(y=0,x=_,0===d){if(y+=l-p,p2;)A[n++]=x[y++],A[n++]=x[y++],A[n++]=x[y++],k-=3;k&&(A[n++]=x[y++],k>1&&(A[n++]=x[y++]))}else{y=n-v;do{A[n++]=A[y++],A[n++]=A[y++],A[n++]=A[y++],k-=3}while(k>2);k&&(A[n++]=A[y++],k>1&&(A[n++]=A[y++]))}break}}break}}while(a>3,a-=k,c-=k<<3,f&=(1<{const l=o.bits;let h,d,_,f,c,u,w=0,m=0,b=0,g=0,p=0,k=0,v=0,y=0,x=0,z=0,A=null;const E=new Uint16Array(16),R=new Uint16Array(16);let Z,S,U,D=null;for(w=0;w<=15;w++)E[w]=0;for(m=0;m=1&&0===E[g];g--);if(p>g&&(p=g),0===g)return n[s++]=20971520,n[s++]=20971520,o.bits=1,0;for(b=1;b0&&(0===t||1!==g))return-1;for(R[1]=0,w=1;w<15;w++)R[w+1]=R[w]+E[w];for(m=0;m852||2===t&&x>592)return 1;for(;;){Z=w-v,r[m]+1=u?(S=D[r[m]-u],U=A[r[m]-u]):(S=96,U=0),h=1<>v)+d]=Z<<24|S<<16|U|0}while(0!==d);for(h=1<>=1;if(0!==h?(z&=h-1,z+=h):z=0,m++,0==--E[w]){if(w===g)break;w=e[a+r[m]]}if(w>p&&(z&f)!==_){for(0===v&&(v=p),c+=b,k=w-v,y=1<852||2===t&&x>592)return 1;_=z&f,n[_]=p<<24|k<<16|c-s|0}}return 0!==z&&(n[c+z]=w-v<<24|64<<16|0),o.bits=p,0};const{Z_FINISH:se,Z_BLOCK:re,Z_TREES:oe,Z_OK:le,Z_STREAM_END:he,Z_NEED_DICT:de,Z_STREAM_ERROR:_e,Z_DATA_ERROR:fe,Z_MEM_ERROR:ce,Z_BUF_ERROR:ue,Z_DEFLATED:we}=B,me=16209,be=t=>(t>>>24&255)+(t>>>8&65280)+((65280&t)<<8)+((255&t)<<24);function ge(){this.strm=null,this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}const pe=t=>{if(!t)return 1;const e=t.state;return!e||e.strm!==t||e.mode<16180||e.mode>16211?1:0},ke=t=>{if(pe(t))return _e;const e=t.state;return t.total_in=t.total_out=e.total=0,t.msg="",e.wrap&&(t.adler=1&e.wrap),e.mode=16180,e.last=0,e.havedict=0,e.flags=-1,e.dmax=32768,e.head=null,e.hold=0,e.bits=0,e.lencode=e.lendyn=new Int32Array(852),e.distcode=e.distdyn=new Int32Array(592),e.sane=1,e.back=-1,le},ve=t=>{if(pe(t))return _e;const e=t.state;return e.wsize=0,e.whave=0,e.wnext=0,ke(t)},ye=(t,e)=>{let a;if(pe(t))return _e;const i=t.state;return e<0?(a=0,e=-e):(a=5+(e>>4),e<48&&(e&=15)),e&&(e<8||e>15)?_e:(null!==i.window&&i.wbits!==e&&(i.window=null),i.wrap=a,i.wbits=e,ve(t))},xe=(t,e)=>{if(!t)return _e;const a=new ge;t.state=a,a.strm=t,a.window=null,a.mode=16180;const i=ye(t,e);return i!==le&&(t.state=null),i};let ze,Ae,Ee=!0;const Re=t=>{if(Ee){ze=new Int32Array(512),Ae=new Int32Array(32);let e=0;for(;e<144;)t.lens[e++]=8;for(;e<256;)t.lens[e++]=9;for(;e<280;)t.lens[e++]=7;for(;e<288;)t.lens[e++]=8;for(ne(1,t.lens,0,288,ze,0,t.work,{bits:9}),e=0;e<32;)t.lens[e++]=5;ne(2,t.lens,0,32,Ae,0,t.work,{bits:5}),Ee=!1}t.lencode=ze,t.lenbits=9,t.distcode=Ae,t.distbits=5},Ze=(t,e,a,i)=>{let n;const s=t.state;return null===s.window&&(s.wsize=1<=s.wsize?(s.window.set(e.subarray(a-s.wsize,a),0),s.wnext=0,s.whave=s.wsize):(n=s.wsize-s.wnext,n>i&&(n=i),s.window.set(e.subarray(a-i,a-i+n),s.wnext),(i-=n)?(s.window.set(e.subarray(a-i,a),0),s.wnext=i,s.whave=s.wsize):(s.wnext+=n,s.wnext===s.wsize&&(s.wnext=0),s.whavexe(t,15),inflateInit2:xe,inflate:(t,e)=>{let a,i,n,s,r,o,l,h,d,_,f,c,u,w,m,b,g,p,k,v,y,x,z=0;const A=new Uint8Array(4);let E,R;const Z=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(pe(t)||!t.output||!t.input&&0!==t.avail_in)return _e;a=t.state,16191===a.mode&&(a.mode=16192),r=t.next_out,n=t.output,l=t.avail_out,s=t.next_in,i=t.input,o=t.avail_in,h=a.hold,d=a.bits,_=o,f=l,x=le;t:for(;;)switch(a.mode){case 16180:if(0===a.wrap){a.mode=16192;break}for(;d<16;){if(0===o)break t;o--,h+=i[s++]<>>8&255,a.check=L(a.check,A,2,0),h=0,d=0,a.mode=16181;break}if(a.head&&(a.head.done=!1),!(1&a.wrap)||(((255&h)<<8)+(h>>8))%31){t.msg="incorrect header check",a.mode=me;break}if((15&h)!==we){t.msg="unknown compression method",a.mode=me;break}if(h>>>=4,d-=4,y=8+(15&h),0===a.wbits&&(a.wbits=y),y>15||y>a.wbits){t.msg="invalid window size",a.mode=me;break}a.dmax=1<>8&1),512&a.flags&&4&a.wrap&&(A[0]=255&h,A[1]=h>>>8&255,a.check=L(a.check,A,2,0)),h=0,d=0,a.mode=16182;case 16182:for(;d<32;){if(0===o)break t;o--,h+=i[s++]<>>8&255,A[2]=h>>>16&255,A[3]=h>>>24&255,a.check=L(a.check,A,4,0)),h=0,d=0,a.mode=16183;case 16183:for(;d<16;){if(0===o)break t;o--,h+=i[s++]<>8),512&a.flags&&4&a.wrap&&(A[0]=255&h,A[1]=h>>>8&255,a.check=L(a.check,A,2,0)),h=0,d=0,a.mode=16184;case 16184:if(1024&a.flags){for(;d<16;){if(0===o)break t;o--,h+=i[s++]<>>8&255,a.check=L(a.check,A,2,0)),h=0,d=0}else a.head&&(a.head.extra=null);a.mode=16185;case 16185:if(1024&a.flags&&(c=a.length,c>o&&(c=o),c&&(a.head&&(y=a.head.extra_len-a.length,a.head.extra||(a.head.extra=new Uint8Array(a.head.extra_len)),a.head.extra.set(i.subarray(s,s+c),y)),512&a.flags&&4&a.wrap&&(a.check=L(a.check,i,c,s)),o-=c,s+=c,a.length-=c),a.length))break t;a.length=0,a.mode=16186;case 16186:if(2048&a.flags){if(0===o)break t;c=0;do{y=i[s+c++],a.head&&y&&a.length<65536&&(a.head.name+=String.fromCharCode(y))}while(y&&c>9&1,a.head.done=!0),t.adler=a.check=0,a.mode=16191;break;case 16189:for(;d<32;){if(0===o)break t;o--,h+=i[s++]<>>=7&d,d-=7&d,a.mode=16206;break}for(;d<3;){if(0===o)break t;o--,h+=i[s++]<>>=1,d-=1,3&h){case 0:a.mode=16193;break;case 1:if(Re(a),a.mode=16199,e===oe){h>>>=2,d-=2;break t}break;case 2:a.mode=16196;break;case 3:t.msg="invalid block type",a.mode=me}h>>>=2,d-=2;break;case 16193:for(h>>>=7&d,d-=7&d;d<32;){if(0===o)break t;o--,h+=i[s++]<>>16^65535)){t.msg="invalid stored block lengths",a.mode=me;break}if(a.length=65535&h,h=0,d=0,a.mode=16194,e===oe)break t;case 16194:a.mode=16195;case 16195:if(c=a.length,c){if(c>o&&(c=o),c>l&&(c=l),0===c)break t;n.set(i.subarray(s,s+c),r),o-=c,s+=c,l-=c,r+=c,a.length-=c;break}a.mode=16191;break;case 16196:for(;d<14;){if(0===o)break t;o--,h+=i[s++]<>>=5,d-=5,a.ndist=1+(31&h),h>>>=5,d-=5,a.ncode=4+(15&h),h>>>=4,d-=4,a.nlen>286||a.ndist>30){t.msg="too many length or distance symbols",a.mode=me;break}a.have=0,a.mode=16197;case 16197:for(;a.have>>=3,d-=3}for(;a.have<19;)a.lens[Z[a.have++]]=0;if(a.lencode=a.lendyn,a.lenbits=7,E={bits:a.lenbits},x=ne(0,a.lens,0,19,a.lencode,0,a.work,E),a.lenbits=E.bits,x){t.msg="invalid code lengths set",a.mode=me;break}a.have=0,a.mode=16198;case 16198:for(;a.have>>24,b=z>>>16&255,g=65535&z,!(m<=d);){if(0===o)break t;o--,h+=i[s++]<>>=m,d-=m,a.lens[a.have++]=g;else{if(16===g){for(R=m+2;d>>=m,d-=m,0===a.have){t.msg="invalid bit length repeat",a.mode=me;break}y=a.lens[a.have-1],c=3+(3&h),h>>>=2,d-=2}else if(17===g){for(R=m+3;d>>=m,d-=m,y=0,c=3+(7&h),h>>>=3,d-=3}else{for(R=m+7;d>>=m,d-=m,y=0,c=11+(127&h),h>>>=7,d-=7}if(a.have+c>a.nlen+a.ndist){t.msg="invalid bit length repeat",a.mode=me;break}for(;c--;)a.lens[a.have++]=y}}if(a.mode===me)break;if(0===a.lens[256]){t.msg="invalid code -- missing end-of-block",a.mode=me;break}if(a.lenbits=9,E={bits:a.lenbits},x=ne(1,a.lens,0,a.nlen,a.lencode,0,a.work,E),a.lenbits=E.bits,x){t.msg="invalid literal/lengths set",a.mode=me;break}if(a.distbits=6,a.distcode=a.distdyn,E={bits:a.distbits},x=ne(2,a.lens,a.nlen,a.ndist,a.distcode,0,a.work,E),a.distbits=E.bits,x){t.msg="invalid distances set",a.mode=me;break}if(a.mode=16199,e===oe)break t;case 16199:a.mode=16200;case 16200:if(o>=6&&l>=258){t.next_out=r,t.avail_out=l,t.next_in=s,t.avail_in=o,a.hold=h,a.bits=d,$t(t,f),r=t.next_out,n=t.output,l=t.avail_out,s=t.next_in,i=t.input,o=t.avail_in,h=a.hold,d=a.bits,16191===a.mode&&(a.back=-1);break}for(a.back=0;z=a.lencode[h&(1<>>24,b=z>>>16&255,g=65535&z,!(m<=d);){if(0===o)break t;o--,h+=i[s++]<>p)],m=z>>>24,b=z>>>16&255,g=65535&z,!(p+m<=d);){if(0===o)break t;o--,h+=i[s++]<>>=p,d-=p,a.back+=p}if(h>>>=m,d-=m,a.back+=m,a.length=g,0===b){a.mode=16205;break}if(32&b){a.back=-1,a.mode=16191;break}if(64&b){t.msg="invalid literal/length code",a.mode=me;break}a.extra=15&b,a.mode=16201;case 16201:if(a.extra){for(R=a.extra;d>>=a.extra,d-=a.extra,a.back+=a.extra}a.was=a.length,a.mode=16202;case 16202:for(;z=a.distcode[h&(1<>>24,b=z>>>16&255,g=65535&z,!(m<=d);){if(0===o)break t;o--,h+=i[s++]<>p)],m=z>>>24,b=z>>>16&255,g=65535&z,!(p+m<=d);){if(0===o)break t;o--,h+=i[s++]<>>=p,d-=p,a.back+=p}if(h>>>=m,d-=m,a.back+=m,64&b){t.msg="invalid distance code",a.mode=me;break}a.offset=g,a.extra=15&b,a.mode=16203;case 16203:if(a.extra){for(R=a.extra;d>>=a.extra,d-=a.extra,a.back+=a.extra}if(a.offset>a.dmax){t.msg="invalid distance too far back",a.mode=me;break}a.mode=16204;case 16204:if(0===l)break t;if(c=f-l,a.offset>c){if(c=a.offset-c,c>a.whave&&a.sane){t.msg="invalid distance too far back",a.mode=me;break}c>a.wnext?(c-=a.wnext,u=a.wsize-c):u=a.wnext-c,c>a.length&&(c=a.length),w=a.window}else w=n,u=r-a.offset,c=a.length;c>l&&(c=l),l-=c,a.length-=c;do{n[r++]=w[u++]}while(--c);0===a.length&&(a.mode=16200);break;case 16205:if(0===l)break t;n[r++]=a.length,l--,a.mode=16200;break;case 16206:if(a.wrap){for(;d<32;){if(0===o)break t;o--,h|=i[s++]<{if(pe(t))return _e;let e=t.state;return e.window&&(e.window=null),t.state=null,le},inflateGetHeader:(t,e)=>{if(pe(t))return _e;const a=t.state;return 0==(2&a.wrap)?_e:(a.head=e,e.done=!1,le)},inflateSetDictionary:(t,e)=>{const a=e.length;let i,n,s;return pe(t)?_e:(i=t.state,0!==i.wrap&&16190!==i.mode?_e:16190===i.mode&&(n=1,n=N(n,e,a,0),n!==i.check)?fe:(s=Ze(t,e,a,a),s?(i.mode=16210,ce):(i.havedict=1,le)))},inflateInfo:"pako inflate (from Nodeca project)"};var Ue=function(){this.text=0,this.time=0,this.xflags=0,this.os=0,this.extra=null,this.extra_len=0,this.name="",this.comment="",this.hcrc=0,this.done=!1};const De=Object.prototype.toString,{Z_NO_FLUSH:Oe,Z_FINISH:Te,Z_OK:Ne,Z_STREAM_END:Fe,Z_NEED_DICT:Le,Z_STREAM_ERROR:Ie,Z_DATA_ERROR:Be,Z_MEM_ERROR:Ce}=B;function He(t){this.options=Ot({chunkSize:65536,windowBits:15,to:""},t||{});const e=this.options;e.raw&&e.windowBits>=0&&e.windowBits<16&&(e.windowBits=-e.windowBits,0===e.windowBits&&(e.windowBits=-15)),!(e.windowBits>=0&&e.windowBits<16)||t&&t.windowBits||(e.windowBits+=32),e.windowBits>15&&e.windowBits<48&&0==(15&e.windowBits)&&(e.windowBits|=15),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Ct,this.strm.avail_out=0;let a=Se.inflateInit2(this.strm,e.windowBits);if(a!==Ne)throw new Error(I[a]);if(this.header=new Ue,Se.inflateGetHeader(this.strm,this.header),e.dictionary&&("string"==typeof e.dictionary?e.dictionary=Lt(e.dictionary):"[object ArrayBuffer]"===De.call(e.dictionary)&&(e.dictionary=new Uint8Array(e.dictionary)),e.raw&&(a=Se.inflateSetDictionary(this.strm,e.dictionary),a!==Ne)))throw new Error(I[a])}He.prototype.push=function(t,e){const a=this.strm,i=this.options.chunkSize,n=this.options.dictionary;let s,r,o;if(this.ended)return!1;for(r=e===~~e?e:!0===e?Te:Oe,"[object ArrayBuffer]"===De.call(t)?a.input=new Uint8Array(t):a.input=t,a.next_in=0,a.avail_in=a.input.length;;){for(0===a.avail_out&&(a.output=new Uint8Array(i),a.next_out=0,a.avail_out=i),s=Se.inflate(a,r),s===Le&&n&&(s=Se.inflateSetDictionary(a,n),s===Ne?s=Se.inflate(a,r):s===Be&&(s=Le));a.avail_in>0&&s===Fe&&a.state.wrap>0&&0!==t[a.next_in];)Se.inflateReset(a),s=Se.inflate(a,r);switch(s){case Ie:case Be:case Le:case Ce:return this.onEnd(s),this.ended=!0,!1}if(o=a.avail_out,a.next_out&&(0===a.avail_out||s===Fe))if("string"===this.options.to){let t=Bt(a.output,a.next_out),e=a.next_out-t,n=It(a.output,t);a.next_out=e,a.avail_out=i-e,e&&a.output.set(a.output.subarray(t,t+e),0),this.onData(n)}else this.onData(a.output.length===a.next_out?a.output:a.output.subarray(0,a.next_out));if(s!==Ne||0!==o){if(s===Fe)return s=Se.inflateEnd(this.strm),this.onEnd(s),this.ended=!0,!0;if(0===a.avail_in)break}}return!0},He.prototype.onData=function(t){this.chunks.push(t)},He.prototype.onEnd=function(t){t===Ne&&("string"===this.options.to?this.result=this.chunks.join(""):this.result=Tt(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};const{Deflate:Me,deflate:je,deflateRaw:Ke,gzip:Pe}=Vt;var Ye=Me,Ge=B,Xe=new(function(){function t(){this.init()}return t.prototype.init=function(){this.added=0,this.deflate=new Ye,this.deflate.push("[",Ge.Z_NO_FLUSH)},t.prototype.addEvent=function(t){if(t){var e=this.added>0?",":"";this.deflate.push(e+JSON.stringify(t),Ge.Z_NO_FLUSH),this.added++}},t.prototype.finish=function(){if(this.deflate.push("]",Ge.Z_FINISH),this.deflate.err)throw this.deflate.err;var t=this.deflate.result;return this.init(),t},t}()),Je={init:function(){return Xe.init(),""},addEvent:function(t){return Xe.addEvent(t),""},finish:function(){return Xe.finish()}};addEventListener("message",(function(t){var e=t.data.method,a=t.data.id,i=(t.data.args?JSON.parse(t.data.args):[])[0];if(e in Je&&"function"==typeof Je[e])try{var n=Je[e](i);postMessage({id:a,method:e,success:!0,response:n})}catch(t){postMessage({id:a,method:e,success:!1,response:t}),console.error(t)}}));`;
diff --git a/packages/replay/src/worker/worker.js.d.ts b/packages/replay/src/worker/worker.js.d.ts
new file mode 100644
index 000000000000..1dd215b1e7b2
--- /dev/null
+++ b/packages/replay/src/worker/worker.js.d.ts
@@ -0,0 +1,2 @@
+declare const workerString: string;
+export default workerString;
diff --git a/packages/replay/test/fixtures/error.ts b/packages/replay/test/fixtures/error.ts
new file mode 100644
index 000000000000..c7223c3c7be4
--- /dev/null
+++ b/packages/replay/test/fixtures/error.ts
@@ -0,0 +1,112 @@
+import { SeverityLevel } from '@sentry/browser';
+import { Event } from '@sentry/types';
+
+export function Error(obj?: Event) {
+ const timestamp = new Date().getTime() / 1000;
+
+ return {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'testing error',
+ stacktrace: {
+ frames: [
+ {
+ filename: 'webpack-internal:///../../getsentry/static/getsentry/gsApp/components/replayInit.tsx',
+ function: 'eval',
+ in_app: true,
+ lineno: 64,
+ colno: 13,
+ },
+ ],
+ },
+ mechanism: {
+ type: 'instrument',
+ handled: true,
+ data: {
+ function: 'setTimeout',
+ },
+ },
+ },
+ ],
+ },
+ level: 'error' as SeverityLevel,
+ event_id: 'event_id',
+ platform: 'javascript',
+ timestamp,
+ environment: 'prod',
+ release: 'frontend@22.11.0',
+ sdk: {
+ // {{{
+ integrations: [
+ 'InboundFilters',
+ 'FunctionToString',
+ 'TryCatch',
+ 'Breadcrumbs',
+ 'GlobalHandlers',
+ 'LinkedErrors',
+ 'Dedupe',
+ 'HttpContext',
+ 'ExtraErrorData',
+ 'BrowserTracing',
+ ],
+ name: 'sentry.javascript.react',
+ version: '7.18.0',
+ packages: [
+ {
+ name: 'npm:@sentry/react',
+ version: '7.18.0',
+ },
+ ],
+ }, // }}}
+ tags: {
+ // {{{
+ organization: '1',
+ 'organization.slug': 'sentry-emerging-tech',
+ plan: 'am1_business_ent_auf',
+ 'plan.name': 'Business',
+ 'plan.max_members': 'null',
+ 'plan.total_members': '15',
+ 'plan.tier': 'am1',
+ 'timeOrigin.mode': 'navigationStart',
+ }, // }}}
+ user: {
+ ip_address: '0.0.0.0',
+ email: 'billy@sentry.io',
+ id: '1',
+ name: 'Billy Vong',
+ },
+ contexts: {
+ organization: {
+ id: '1',
+ slug: 'sentry-emerging-tech',
+ },
+ Error: {},
+ },
+ breadcrumbs: [
+ {
+ timestamp,
+ category: 'console',
+ data: {
+ arguments: [
+ 'Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n* Move code with side effects to componentDidMount, and set initial state in the constructor.\n* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.\n\nPlease update the following components: %s',
+ 'Router, RouterContext',
+ ],
+ logger: 'console',
+ },
+ message:
+ 'Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n* Move code with side effects to componentDidMount, and set initial state in the constructor.\n* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.\n\nPlease update the following components: %s Router, RouterContext',
+ },
+ ],
+ sdkProcessingMetadata: {},
+ request: {
+ url: 'https://example.org',
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36',
+ },
+ },
+ ...obj,
+ };
+}
diff --git a/packages/replay/test/fixtures/performanceEntry/lcp.ts b/packages/replay/test/fixtures/performanceEntry/lcp.ts
new file mode 100644
index 000000000000..2f14236d4268
--- /dev/null
+++ b/packages/replay/test/fixtures/performanceEntry/lcp.ts
@@ -0,0 +1,19 @@
+export function PerformanceEntryLcp(obj?: Partial): PerformancePaintTiming {
+ const entry = {
+ name: '',
+ entryType: 'largest-contentful-paint',
+ startTime: 108.299,
+ duration: 0,
+ size: 7619,
+ renderTime: 108.299,
+ loadTime: 0,
+ firstAnimatedFrameTime: 0,
+ id: '',
+ url: '',
+ ...obj,
+
+ toJSON: () => entry,
+ };
+
+ return entry;
+}
diff --git a/packages/replay/test/fixtures/performanceEntry/navigation.ts b/packages/replay/test/fixtures/performanceEntry/navigation.ts
new file mode 100644
index 000000000000..6cd4bec85dff
--- /dev/null
+++ b/packages/replay/test/fixtures/performanceEntry/navigation.ts
@@ -0,0 +1,42 @@
+export function PerformanceEntryNavigation(obj?: Partial): PerformanceNavigationTiming {
+ const entry = {
+ name: 'https://sentry.io/organizations/sentry/discover/',
+ entryType: 'navigation',
+ startTime: 0,
+ duration: 682.6999999284744,
+ initiatorType: 'navigation',
+ nextHopProtocol: 'http/1.1',
+ workerStart: 0,
+ redirectStart: 0,
+ redirectEnd: 0,
+ fetchStart: 1.5,
+ domainLookupStart: 1.5,
+ domainLookupEnd: 1.5,
+ connectStart: 1.5,
+ connectEnd: 1.5,
+ secureConnectionStart: 1.5,
+ requestStart: 41.60000002384186,
+ responseStart: 165.5,
+ responseEnd: 167.19999992847443,
+ transferSize: 19974,
+ encodedBodySize: 19674,
+ decodedBodySize: 19674,
+ serverTiming: [],
+ unloadEventStart: 0,
+ unloadEventEnd: 0,
+ domInteractive: 303.2999999523163,
+ domContentLoadedEventStart: 303.2999999523163,
+ domContentLoadedEventEnd: 304.6999999284744,
+ domComplete: 682.5,
+ loadEventStart: 682.5,
+ loadEventEnd: 682.6999999284744,
+ type: 'navigate' as NavigationType,
+ redirectCount: 0,
+ ...obj,
+ };
+
+ return {
+ ...entry,
+ toJSON: () => entry,
+ };
+}
diff --git a/packages/replay/test/fixtures/performanceEntry/resource.ts b/packages/replay/test/fixtures/performanceEntry/resource.ts
new file mode 100644
index 000000000000..e52640e6cec4
--- /dev/null
+++ b/packages/replay/test/fixtures/performanceEntry/resource.ts
@@ -0,0 +1,31 @@
+export function PerformanceEntryResource(obj?: Partial): PerformanceResourceTiming {
+ const entry = {
+ name: 'https://dev.getsentry.net:7999/_assets/sentry.js',
+ entryType: 'resource',
+ startTime: 0,
+ duration: 101.90000003576279,
+ initiatorType: 'script',
+ nextHopProtocol: 'http/1.1',
+ workerStart: 0,
+ redirectStart: 0,
+ redirectEnd: 0,
+ fetchStart: 325.19999998807907,
+ domainLookupStart: 325.19999998807907,
+ domainLookupEnd: 325.19999998807907,
+ connectStart: 325.19999998807907,
+ connectEnd: 325.19999998807907,
+ secureConnectionStart: 325.19999998807907,
+ requestStart: 394.19999998807907,
+ responseStart: 399.69999998807907,
+ responseEnd: 427.10000002384186,
+ transferSize: 287606,
+ encodedBodySize: 287306,
+ decodedBodySize: 1190668,
+ serverTiming: [],
+ ...obj,
+
+ toJSON: () => entry,
+ };
+
+ return entry;
+}
diff --git a/packages/replay/test/fixtures/transaction.ts b/packages/replay/test/fixtures/transaction.ts
new file mode 100644
index 000000000000..695a2b8aae40
--- /dev/null
+++ b/packages/replay/test/fixtures/transaction.ts
@@ -0,0 +1,305 @@
+import { Event, SeverityLevel } from '@sentry/types';
+
+export function Transaction(obj?: Partial) {
+ const timestamp = new Date().getTime() / 1000;
+
+ return {
+ contexts: {
+ // {{{
+ organization: {
+ id: '1',
+ slug: 'sentry-emerging-tech',
+ },
+ trace: {
+ op: 'navigation',
+ span_id: 'b44b173b1c74a782',
+ tags: {
+ 'routing.instrumentation': 'react-router-v3',
+ from: '/organizations/:orgId/replays/',
+ 'ui.longTaskCount.grouped': '<=1',
+ effectiveConnectionType: '4g',
+ deviceMemory: '8 GB',
+ hardwareConcurrency: '10',
+ sentry_reportAllChanges: false,
+ },
+ trace_id: 'trace_id',
+ },
+ }, // }}}
+ spans: [
+ // {{{
+ {
+ description: '',
+ op: 'ui.react.mount',
+ parent_span_id: 'b44b173b1c74a782',
+ span_id: '9ea106e8efbce4a0',
+ start_timestamp: 1668184224.4743,
+ timestamp: 1668184224.5091,
+ trace_id: '3e0ff8aff4dc4236a80b77a37ef66c7d',
+ },
+ {
+ description: '',
+ op: 'ui.react.update',
+ parent_span_id: 'b44b173b1c74a782',
+ span_id: 'b4c7b421761d903a',
+ start_timestamp: 1668184224.4843998,
+ timestamp: 1668184224.5091999,
+ trace_id: '3e0ff8aff4dc4236a80b77a37ef66c7d',
+ },
+ {
+ description: 'Main UI thread blocked',
+ op: 'ui.long-task',
+ parent_span_id: 'b44b173b1c74a782',
+ span_id: '808967f15cae9251',
+ start_timestamp: 1668184224.4343,
+ timestamp: 1668184224.5483,
+ trace_id: '3e0ff8aff4dc4236a80b77a37ef66c7d',
+ },
+ {
+ data: {
+ method: 'GET',
+ url: '/api/0/projects/sentry-emerging-tech/billy-test/replays/c11bd625b0e14081a0827a22a0a9be4e/',
+ type: 'fetch',
+ },
+ description: 'GET /api/0/projects/sentry-emerging-tech/billy-test/replays/c11bd625b0e14081a0827a22a0a9be4e/',
+ op: 'http.client',
+ parent_span_id: 'b44b173b1c74a782',
+ span_id: '87497c337838d561',
+ start_timestamp: 1668184224.7844,
+ status: 'ok',
+ tags: {
+ 'http.status_code': '200',
+ },
+ timestamp: 1668184225.0802999,
+ trace_id: '3e0ff8aff4dc4236a80b77a37ef66c7d',
+ },
+ {
+ data: {
+ 'Transfer Size': 1097,
+ 'Encoded Body Size': 797,
+ 'Decoded Body Size': 1885,
+ },
+ description: '/favicon.ico',
+ op: 'resource.other',
+ parent_span_id: 'b44b173b1c74a782',
+ span_id: 'b7fad2cd42783af4',
+ start_timestamp: 1668184224.5532,
+ timestamp: 1668184224.5562,
+ trace_id: '3e0ff8aff4dc4236a80b77a37ef66c7d',
+ },
+ ], // }}}
+ start_timestamp: 1668184224.447,
+ tags: {
+ organization: '1',
+ 'organization.slug': 'sentry-emerging-tech',
+ plan: 'am1_business_ent_auf',
+ 'plan.name': 'Business',
+ 'plan.max_members': 'null',
+ 'plan.total_members': '15',
+ 'plan.tier': 'am1',
+ 'routing.instrumentation': 'react-router-v3',
+ from: '/organizations/:orgId/replays/',
+ 'ui.longTaskCount.grouped': '<=1',
+ effectiveConnectionType: '4g',
+ deviceMemory: '8 GB',
+ hardwareConcurrency: '10',
+ sentry_reportAllChanges: false,
+ 'timeOrigin.mode': 'navigationStart',
+ },
+ transaction: '/organizations/:orgId/replays/:replaySlug/',
+ type: 'transaction' as const,
+ sdkProcessingMetadata: {
+ // {{{
+ source: 'route',
+ dynamicSamplingContext: {
+ environment: 'prod',
+ release: 'frontend@22.11.0+e5bd7ea3280849b58158c7adf1505e7d950e7f31',
+ transaction: '/organizations/:orgId/replays/:replaySlug/',
+ public_key: '6991720ac36e4ddd9f8dc3331187628f',
+ trace_id: '3e0ff8aff4dc4236a80b77a37ef66c7d',
+ sample_rate: '1',
+ },
+ spanMetadata: {
+ '9ea106e8efbce4a0': {
+ logMessage:
+ "[Tracing] Starting 'ui.react.mount' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b4c7b421761d903a: {
+ logMessage:
+ "[Tracing] Starting 'ui.react.update' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '808967f15cae9251': {
+ logMessage:
+ "[Tracing] Starting 'ui.long-task' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '9a0de85dfd88085c': {
+ logMessage:
+ "[Tracing] Starting 'ui.react.render' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '863c6099f1929910': {
+ logMessage:
+ "[Tracing] Starting 'ui.react.update' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '87497c337838d561': {
+ logMessage:
+ "[Tracing] Starting 'http.client' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '81638bb5251f9e3f': {
+ logMessage:
+ "[Tracing] Starting 'http.client' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ bf25ff92b2dc7498: {
+ logMessage:
+ "[Tracing] Starting 'ui.long-task' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ a7c3320e88b04076: {
+ logMessage:
+ "[Tracing] Starting 'ui.react.update' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '95c892e987b8e0f5': {
+ logMessage:
+ "[Tracing] Starting 'ui.long-task' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b734c15c4d94b7b6: {
+ logMessage:
+ "[Tracing] Starting 'ui.long-task' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '934881bfe9a8e043': {
+ logMessage:
+ "[Tracing] Starting 'http.client' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '9bc8a019012e4692': {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b3c5eb78c15aa492: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '84a72c34a78232b7': {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b523103902ac1f0d: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '84f863de30175a64': {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b0532b585ec47f05: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '9c3612dc22c5aea5': {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '82b74a44ef06ae13': {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b2d11e8d407329fd: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ '98146db11c338f31': {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ a05774f4b2885c47: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ a0c0d4f8ec6bfcd7: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ ab2e6ef0852bfc64: {
+ logMessage:
+ "[Tracing] Starting 'resource.script' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ b7fad2cd42783af4: {
+ logMessage:
+ "[Tracing] Starting 'resource.other' span on transaction '/organizations/:orgId/replays/:replaySlug/' (b44b173b1c74a782).",
+ },
+ },
+ changes: [],
+ propagations: 2,
+ sampleRate: 1,
+ }, // }}}
+ transaction_info: {
+ source: 'route',
+ changes: [],
+ propagations: 2,
+ },
+ measurements: {
+ longTaskCount: {
+ value: 0,
+ unit: '',
+ },
+ longTaskDuration: {
+ value: 0,
+ unit: '',
+ },
+ },
+ platform: 'javascript',
+ event_id: 'f02630b140c0431fb6c8809f5b06d8be',
+ environment: 'prod',
+ release: 'frontend@22.11.0',
+ sdk: {
+ integrations: [
+ 'InboundFilters',
+ 'FunctionToString',
+ 'TryCatch',
+ 'Breadcrumbs',
+ 'GlobalHandlers',
+ 'LinkedErrors',
+ 'Dedupe',
+ 'HttpContext',
+ 'ExtraErrorData',
+ 'BrowserTracing',
+ ],
+ name: 'sentry.javascript.react',
+ version: '7.18.0',
+ packages: [
+ {
+ name: 'npm:@sentry/react',
+ version: '7.18.0',
+ },
+ ],
+ },
+ user: {
+ ip_address: '0.0.0.0',
+ email: 'billy@sentry.io',
+ id: '1',
+ name: 'Billy Vong',
+ },
+ breadcrumbs: [
+ {
+ timestamp,
+ category: 'console',
+ data: {
+ arguments: [
+ 'Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n* Move code with side effects to componentDidMount, and set initial state in the constructor.\n* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.\n\nPlease update the following components: %s',
+ 'Router, RouterContext',
+ ],
+ logger: 'console',
+ },
+ level: 'warning' as SeverityLevel,
+ message:
+ 'Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n* Move code with side effects to componentDidMount, and set initial state in the constructor.\n* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.\n\nPlease update the following components: %s Router, RouterContext',
+ },
+ ],
+ request: {
+ url: 'https://example.org',
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36',
+ },
+ },
+
+ timestamp,
+ ...obj,
+ };
+}
diff --git a/packages/replay/test/index.ts b/packages/replay/test/index.ts
new file mode 100644
index 000000000000..ed4b82a6c780
--- /dev/null
+++ b/packages/replay/test/index.ts
@@ -0,0 +1,4 @@
+export * from './mocks/mockRrweb'; // XXX: Needs to happen before `mockSdk` or importing Replay!
+export * from './mocks/mockSdk';
+
+export const BASE_TIMESTAMP = new Date('2020-02-02 00:00:00').getTime(); // 1580619600000
diff --git a/packages/replay/test/mocks/index.ts b/packages/replay/test/mocks/index.ts
new file mode 100644
index 000000000000..c744a4c6a6de
--- /dev/null
+++ b/packages/replay/test/mocks/index.ts
@@ -0,0 +1,61 @@
+import { getCurrentHub } from '@sentry/core';
+import { BASE_TIMESTAMP, RecordMock } from '@test';
+import { DomHandler, MockTransportSend } from '@test/types';
+import { Replay } from 'src';
+
+import { ReplayConfiguration } from '../../src/types';
+
+export async function resetSdkMock(options?: ReplayConfiguration): Promise<{
+ domHandler: DomHandler;
+ mockRecord: RecordMock;
+ mockTransportSend: MockTransportSend;
+ replay: Replay;
+ spyCaptureException: jest.SpyInstance;
+}> {
+ let domHandler: DomHandler;
+
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ jest.clearAllMocks();
+ jest.resetModules();
+ // NOTE: The listeners added to `addInstrumentationHandler` are leaking
+ // @ts-ignore Don't know if there's a cleaner way to clean up old event processors
+ globalThis.__SENTRY__.globalEventProcessors = [];
+ const SentryUtils = await import('@sentry/utils');
+ jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => {
+ if (type === 'dom') {
+ domHandler = handler;
+ }
+ });
+ const { mockRrweb } = await import('./mockRrweb');
+ const { record: mockRecord } = mockRrweb();
+
+ // Because of `resetModules`, we need to import and add a spy for
+ // `@sentry/core` here before `mockSdk` is called
+ // XXX: This is probably going to make writing future tests difficult and/or
+ // bloat this area of code
+ const SentryCore = await import('@sentry/core');
+ const spyCaptureException = jest.spyOn(SentryCore, 'captureException');
+
+ const { mockSdk } = await import('./mockSdk');
+ const { replay } = await mockSdk({
+ replayOptions: {
+ ...options,
+ },
+ });
+
+ const mockTransportSend = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend;
+
+ // XXX: This is needed to ensure `domHandler` is set
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+
+ return {
+ // @ts-ignore use before assign
+ domHandler,
+ mockRecord,
+ mockTransportSend,
+ replay,
+ spyCaptureException,
+ };
+}
diff --git a/packages/replay/test/mocks/mockRrweb.ts b/packages/replay/test/mocks/mockRrweb.ts
new file mode 100644
index 000000000000..7df829c4d27d
--- /dev/null
+++ b/packages/replay/test/mocks/mockRrweb.ts
@@ -0,0 +1,56 @@
+import { RecordingEvent } from '../../src/types';
+
+type RecordAdditionalProperties = {
+ takeFullSnapshot: jest.Mock;
+
+ // Below are not mocked
+ addCustomEvent: () => void;
+ freezePage: () => void;
+ mirror: unknown;
+
+ // Custom property to fire events in tests, does not exist in rrweb.record
+ _emitter: (event: RecordingEvent, ...args: any[]) => void;
+};
+
+export type RecordMock = jest.MockedFunction & RecordAdditionalProperties;
+
+function createCheckoutPayload(isCheckout: boolean = true) {
+ return {
+ data: { isCheckout },
+ timestamp: new Date().getTime(),
+ type: isCheckout ? 2 : 3,
+ };
+}
+
+jest.mock('rrweb', () => {
+ const ActualRrweb = jest.requireActual('rrweb');
+ const mockRecordFn: jest.Mock & Partial = jest.fn(({ emit }) => {
+ mockRecordFn._emitter = emit;
+
+ emit(createCheckoutPayload());
+ return function stop() {
+ mockRecordFn._emitter = jest.fn();
+ };
+ });
+ mockRecordFn.takeFullSnapshot = jest.fn((isCheckout: boolean) => {
+ if (!mockRecordFn._emitter) {
+ return;
+ }
+
+ mockRecordFn._emitter(createCheckoutPayload(isCheckout), isCheckout);
+ });
+
+ return {
+ ...ActualRrweb,
+ record: mockRecordFn,
+ };
+});
+
+// XXX: Intended to be after `mock('rrweb')`
+import * as rrweb from 'rrweb';
+
+export function mockRrweb() {
+ return {
+ record: rrweb.record as RecordMock,
+ };
+}
diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts
new file mode 100644
index 000000000000..8c561cb1d0d7
--- /dev/null
+++ b/packages/replay/test/mocks/mockSdk.ts
@@ -0,0 +1,58 @@
+jest.unmock('@sentry/browser');
+
+import { BrowserOptions, init } from '@sentry/browser';
+import { Envelope, Transport } from '@sentry/types';
+
+import { Replay as ReplayClass } from '../../src';
+import { ReplayConfiguration } from '../../src/types';
+
+interface MockSdkParams {
+ replayOptions?: ReplayConfiguration;
+ sentryOptions?: BrowserOptions;
+}
+
+class MockTransport implements Transport {
+ send: (request: Envelope) => PromiseLike = jest.fn(async () => {
+ return;
+ });
+ async flush() {
+ return true;
+ }
+ async sendEvent(_e: Event) {
+ return {
+ status: 'skipped',
+ event: 'ok',
+ type: 'transaction',
+ };
+ }
+ async sendSession() {
+ return;
+ }
+ async recordLostEvent() {
+ return;
+ }
+ async close() {
+ return;
+ }
+}
+
+export async function mockSdk({
+ replayOptions = {
+ stickySession: true,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0.0,
+ },
+ sentryOptions = {
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ autoSessionTracking: false,
+ sendClientReports: false,
+ transport: () => new MockTransport(),
+ },
+}: MockSdkParams = {}): Promise<{ replay: ReplayClass }> {
+ const { Replay } = await import('../../src');
+ const replay = new Replay(replayOptions);
+
+ init({ ...sentryOptions, integrations: [replay] });
+
+ return { replay };
+}
diff --git a/packages/replay/test/types.ts b/packages/replay/test/types.ts
new file mode 100644
index 000000000000..d8d17ee59924
--- /dev/null
+++ b/packages/replay/test/types.ts
@@ -0,0 +1,4 @@
+import { Transport } from '@sentry/types';
+
+export type MockTransportSend = jest.MockedFunction;
+export type DomHandler = (args: any) => any;
diff --git a/packages/replay/test/unit/blockAllMedia.test.ts b/packages/replay/test/unit/blockAllMedia.test.ts
new file mode 100644
index 000000000000..0a91cf45dce5
--- /dev/null
+++ b/packages/replay/test/unit/blockAllMedia.test.ts
@@ -0,0 +1,33 @@
+import { mockSdk } from '@test';
+
+import { Replay } from '../../src';
+
+let replay: Replay;
+
+beforeEach(() => {
+ jest.resetModules();
+});
+
+it('sets the correct configuration when `blockAllMedia` is disabled', async () => {
+ ({ replay } = await mockSdk({ replayOptions: { blockAllMedia: false } }));
+
+ expect(replay.recordingOptions.blockSelector).toBe('[data-sentry-block]');
+});
+
+it('sets the correct configuration when `blockSelector` is empty and `blockAllMedia` is enabled', async () => {
+ ({ replay } = await mockSdk({ replayOptions: { blockSelector: '' } }));
+
+ expect(replay.recordingOptions.blockSelector).toMatchInlineSnapshot(
+ '"img,image,svg,path,rect,area,video,object,picture,embed,map,audio"',
+ );
+});
+
+it('preserves `blockSelector` when `blockAllMedia` is enabled', async () => {
+ ({ replay } = await mockSdk({
+ replayOptions: { blockSelector: '[data-test-blockSelector]' },
+ }));
+
+ expect(replay.recordingOptions.blockSelector).toMatchInlineSnapshot(
+ '"[data-test-blockSelector],img,image,svg,path,rect,area,video,object,picture,embed,map,audio"',
+ );
+});
diff --git a/packages/replay/test/unit/coreHandlers/handleFetch.test.ts b/packages/replay/test/unit/coreHandlers/handleFetch.test.ts
new file mode 100644
index 000000000000..49662ef9b056
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleFetch.test.ts
@@ -0,0 +1,54 @@
+import { mockSdk } from '@test';
+
+import { handleFetch } from '../../../src/coreHandlers/handleFetch';
+
+jest.unmock('@sentry/browser');
+
+beforeAll(function () {
+ mockSdk();
+});
+
+const DEFAULT_DATA = {
+ args: ['/api/0/organizations/sentry/', { method: 'GET', headers: {}, credentials: 'include' }] as Parameters<
+ typeof fetch
+ >,
+ endTimestamp: 15000,
+ fetchData: {
+ method: 'GET',
+ url: '/api/0/organizations/sentry/',
+ },
+ response: {
+ type: 'basic',
+ url: '',
+ redirected: false,
+ status: 200,
+ ok: true,
+ },
+ startTimestamp: 10000,
+};
+
+it('formats fetch calls from core SDK to replay breadcrumbs', function () {
+ expect(handleFetch(DEFAULT_DATA)).toEqual({
+ type: 'resource.fetch',
+ name: '/api/0/organizations/sentry/',
+ start: 10,
+ end: 15,
+ data: {
+ method: 'GET',
+ statusCode: 200,
+ },
+ });
+});
+
+it('ignores fetches that have not completed yet', function () {
+ const data = {
+ ...DEFAULT_DATA,
+ };
+
+ // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790)
+ delete data.endTimestamp;
+ // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790)
+ delete data.response;
+
+ expect(handleFetch(data)).toEqual(null);
+});
diff --git a/packages/replay/test/unit/coreHandlers/handleScope-unit.test.ts b/packages/replay/test/unit/coreHandlers/handleScope-unit.test.ts
new file mode 100644
index 000000000000..f70420d2613f
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleScope-unit.test.ts
@@ -0,0 +1,31 @@
+import { getCurrentHub } from '@sentry/core';
+import { mockSdk } from '@test';
+
+import * as HandleScope from '../../../src/coreHandlers/handleScope';
+
+let mockHandleScope: jest.MockedFunction;
+
+jest.useFakeTimers();
+
+beforeAll(async function () {
+ await mockSdk();
+ jest.spyOn(HandleScope, 'handleScope');
+ mockHandleScope = HandleScope.handleScope as jest.MockedFunction;
+
+ jest.runAllTimers();
+});
+
+it('returns a breadcrumb only if last breadcrumb has changed (integration)', function () {
+ getCurrentHub().getScope()?.addBreadcrumb({ message: 'testing' });
+
+ expect(mockHandleScope).toHaveBeenCalledTimes(1);
+ expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing' }));
+
+ mockHandleScope.mockClear();
+
+ // This will trigger breadcrumb/scope listener, but handleScope should return
+ // null because breadcrumbs has not changed
+ getCurrentHub().getScope()?.setUser({ email: 'foo@foo.com' });
+ expect(mockHandleScope).toHaveBeenCalledTimes(1);
+ expect(mockHandleScope).toHaveReturnedWith(null);
+});
diff --git a/packages/replay/test/unit/coreHandlers/handleScope.test.ts b/packages/replay/test/unit/coreHandlers/handleScope.test.ts
new file mode 100644
index 000000000000..459a3e53711f
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleScope.test.ts
@@ -0,0 +1,46 @@
+import type { Breadcrumb, Scope } from '@sentry/types';
+
+import * as HandleScope from '../../../src/coreHandlers/handleScope';
+
+jest.spyOn(HandleScope, 'handleScope');
+const mockHandleScope = HandleScope.handleScope as jest.MockedFunction;
+
+it('returns a breadcrumb only if last breadcrumb has changed (unit)', function () {
+ const scope = {
+ _breadcrumbs: [],
+ } as unknown as Scope;
+
+ function addBreadcrumb(breadcrumb: Breadcrumb) {
+ // @ts-ignore using private member
+ scope._breadcrumbs.push(breadcrumb);
+ }
+
+ const testMsg = {
+ timestamp: new Date().getTime() / 1000,
+ message: 'testing',
+ category: 'console',
+ };
+
+ addBreadcrumb(testMsg);
+ // integration testing here is a bit tricky, because the core SDK can
+ // interfere with console output from test runner
+ HandleScope.handleScope(scope);
+ expect(mockHandleScope).toHaveBeenCalledTimes(1);
+ expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing', category: 'console' }));
+
+ // This will trigger breadcrumb/scope listener, but handleScope should return
+ // null because breadcrumbs has not changed
+ mockHandleScope.mockClear();
+ HandleScope.handleScope(scope);
+ expect(mockHandleScope).toHaveBeenCalledTimes(1);
+ expect(mockHandleScope).toHaveReturnedWith(null);
+
+ mockHandleScope.mockClear();
+ addBreadcrumb({
+ message: 'f00',
+ category: 'console',
+ });
+ HandleScope.handleScope(scope);
+ expect(mockHandleScope).toHaveBeenCalledTimes(1);
+ expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'f00', category: 'console' }));
+});
diff --git a/packages/replay/test/unit/createPerformanceEntry.test.ts b/packages/replay/test/unit/createPerformanceEntry.test.ts
new file mode 100644
index 000000000000..dfe0278d5bb0
--- /dev/null
+++ b/packages/replay/test/unit/createPerformanceEntry.test.ts
@@ -0,0 +1,40 @@
+import { mockSdk } from '@test';
+
+import { createPerformanceEntries } from '../../src/createPerformanceEntry';
+
+jest.unmock('@sentry/browser');
+
+beforeAll(function () {
+ mockSdk();
+});
+
+it('ignores sdks own requests', function () {
+ const data = {
+ name: 'https://ingest.f00.f00/api/1/envelope/?sentry_key=dsn&sentry_version=7',
+ entryType: 'resource',
+ startTime: 234462.69999998808,
+ duration: 55.70000001788139,
+ initiatorType: 'fetch',
+ nextHopProtocol: '',
+ workerStart: 0,
+ redirectStart: 0,
+ redirectEnd: 0,
+ fetchStart: 234462.69999998808,
+ domainLookupStart: 0,
+ domainLookupEnd: 0,
+ connectStart: 0,
+ connectEnd: 0,
+ secureConnectionStart: 0,
+ requestStart: 0,
+ responseStart: 0,
+ responseEnd: 234518.40000000596,
+ transferSize: 0,
+ encodedBodySize: 0,
+ decodedBodySize: 0,
+ serverTiming: [],
+ workerTiming: [],
+ } as const;
+
+ // @ts-ignore Needs a PerformanceEntry mock
+ expect(createPerformanceEntries([data])).toEqual([]);
+});
diff --git a/packages/replay/test/unit/eventBuffer.test.ts b/packages/replay/test/unit/eventBuffer.test.ts
new file mode 100644
index 000000000000..86e3ec3a940a
--- /dev/null
+++ b/packages/replay/test/unit/eventBuffer.test.ts
@@ -0,0 +1,119 @@
+import 'jsdom-worker';
+
+import { BASE_TIMESTAMP } from '@test';
+import pako from 'pako';
+
+import { createEventBuffer, EventBufferCompressionWorker } from './../../src/eventBuffer';
+
+const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+
+it('adds events to normal event buffer', async function () {
+ const buffer = createEventBuffer({ useCompression: false });
+
+ buffer.addEvent(TEST_EVENT);
+ buffer.addEvent(TEST_EVENT);
+
+ const result = await buffer.finish();
+
+ expect(result).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT]));
+});
+
+it('adds checkout event to normal event buffer', async function () {
+ const buffer = createEventBuffer({ useCompression: false });
+
+ buffer.addEvent(TEST_EVENT);
+ buffer.addEvent(TEST_EVENT);
+ buffer.addEvent(TEST_EVENT);
+
+ buffer.addEvent(TEST_EVENT, true);
+ const result = await buffer.finish();
+
+ expect(result).toEqual(JSON.stringify([TEST_EVENT]));
+});
+
+it('calling `finish()` multiple times does not result in duplicated events', async function () {
+ const buffer = createEventBuffer({ useCompression: false });
+
+ buffer.addEvent(TEST_EVENT);
+
+ const promise1 = buffer.finish();
+ const promise2 = buffer.finish();
+
+ const result1 = (await promise1) as Uint8Array;
+ const result2 = (await promise2) as Uint8Array;
+
+ expect(result1).toEqual(JSON.stringify([TEST_EVENT]));
+ expect(result2).toEqual(JSON.stringify([]));
+});
+
+it('adds events to event buffer with compression worker', async function () {
+ const buffer = createEventBuffer({
+ useCompression: true,
+ }) as EventBufferCompressionWorker;
+
+ buffer.addEvent(TEST_EVENT);
+ buffer.addEvent(TEST_EVENT);
+
+ const result = await buffer.finish();
+ const restored = pako.inflate(result, { to: 'string' });
+
+ expect(restored).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT]));
+});
+
+it('adds checkout events to event buffer with compression worker', async function () {
+ const buffer = createEventBuffer({
+ useCompression: true,
+ }) as EventBufferCompressionWorker;
+
+ await buffer.addEvent(TEST_EVENT);
+ await buffer.addEvent(TEST_EVENT);
+
+ // This should clear previous buffer
+ await buffer.addEvent({ ...TEST_EVENT, type: 2 }, true);
+
+ const result = await buffer.finish();
+ const restored = pako.inflate(result, { to: 'string' });
+
+ expect(restored).toEqual(JSON.stringify([{ ...TEST_EVENT, type: 2 }]));
+});
+
+it('calling `finish()` multiple times does not result in duplicated events', async function () {
+ const buffer = createEventBuffer({
+ useCompression: true,
+ }) as EventBufferCompressionWorker;
+
+ buffer.addEvent(TEST_EVENT);
+
+ const promise1 = buffer.finish();
+ const promise2 = buffer.finish();
+
+ const result1 = (await promise1) as Uint8Array;
+ const result2 = (await promise2) as Uint8Array;
+ const restored1 = pako.inflate(result1, { to: 'string' });
+ const restored2 = pako.inflate(result2, { to: 'string' });
+
+ expect(restored1).toEqual(JSON.stringify([TEST_EVENT]));
+ expect(restored2).toEqual(JSON.stringify([]));
+});
+
+it('calling `finish()` multiple times, with events in between, does not result in duplicated or dropped events', async function () {
+ const buffer = createEventBuffer({
+ useCompression: true,
+ }) as EventBufferCompressionWorker;
+
+ buffer.addEvent(TEST_EVENT);
+
+ const promise1 = buffer.finish();
+
+ buffer.addEvent({ ...TEST_EVENT, type: 5 });
+ const promise2 = buffer.finish();
+
+ const result1 = (await promise1) as Uint8Array;
+ const result2 = (await promise2) as Uint8Array;
+
+ const restored1 = pako.inflate(result1, { to: 'string' });
+ const restored2 = pako.inflate(result2, { to: 'string' });
+
+ expect(restored1).toEqual(JSON.stringify([TEST_EVENT]));
+ expect(restored2).toEqual(JSON.stringify([{ ...TEST_EVENT, type: 5 }]));
+});
diff --git a/packages/replay/test/unit/flush.test.ts b/packages/replay/test/unit/flush.test.ts
new file mode 100644
index 000000000000..57135ffa7994
--- /dev/null
+++ b/packages/replay/test/unit/flush.test.ts
@@ -0,0 +1,230 @@
+import * as SentryUtils from '@sentry/utils';
+import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '@test';
+
+import { Replay } from './../../src';
+import { createPerformanceEntries } from './../../src/createPerformanceEntry';
+import { SESSION_IDLE_DURATION } from './../../src/session/constants';
+import { useFakeTimers } from './../../test/utils/use-fake-timers';
+
+useFakeTimers();
+
+async function advanceTimers(time: number) {
+ jest.advanceTimersByTime(time);
+ await new Promise(process.nextTick);
+}
+
+type MockSendReplay = jest.MockedFunction;
+type MockAddPerformanceEntries = jest.MockedFunction;
+type MockAddMemoryEntry = jest.MockedFunction;
+type MockEventBufferFinish = jest.MockedFunction['finish']>;
+type MockFlush = jest.MockedFunction;
+type MockRunFlush = jest.MockedFunction;
+
+const prevLocation = window.location;
+let domHandler: (args: any) => any;
+
+const { record: mockRecord } = mockRrweb();
+
+let replay: Replay;
+let mockSendReplay: MockSendReplay;
+let mockFlush: MockFlush;
+let mockRunFlush: MockRunFlush;
+let mockEventBufferFinish: MockEventBufferFinish;
+let mockAddMemoryEntry: MockAddMemoryEntry;
+let mockAddPerformanceEntries: MockAddPerformanceEntries;
+
+beforeAll(async () => {
+ jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => {
+ if (type === 'dom') {
+ domHandler = handler;
+ }
+ });
+
+ ({ replay } = await mockSdk());
+ jest.spyOn(replay, 'sendReplay');
+ mockSendReplay = replay.sendReplay as MockSendReplay;
+ mockSendReplay.mockImplementation(
+ jest.fn(async () => {
+ return;
+ }),
+ );
+
+ jest.spyOn(replay, 'flush');
+ mockFlush = replay.flush as MockFlush;
+
+ jest.spyOn(replay, 'runFlush');
+ mockRunFlush = replay.runFlush as MockRunFlush;
+
+ jest.spyOn(replay, 'addPerformanceEntries');
+ mockAddPerformanceEntries = replay.addPerformanceEntries as MockAddPerformanceEntries;
+
+ mockAddPerformanceEntries.mockImplementation(async () => {
+ return [];
+ });
+
+ jest.spyOn(replay, 'addMemoryEntry');
+ mockAddMemoryEntry = replay.addMemoryEntry as MockAddMemoryEntry;
+});
+
+beforeEach(() => {
+ jest.runAllTimers();
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ mockSendReplay.mockClear();
+ replay.eventBuffer?.destroy();
+ mockAddPerformanceEntries.mockClear();
+ mockFlush.mockClear();
+ mockRunFlush.mockClear();
+ mockAddMemoryEntry.mockClear();
+
+ if (replay.eventBuffer) {
+ jest.spyOn(replay.eventBuffer, 'finish');
+ }
+ mockEventBufferFinish = replay.eventBuffer?.finish as MockEventBufferFinish;
+ mockEventBufferFinish.mockClear();
+});
+
+afterEach(async () => {
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ sessionStorage.clear();
+ replay.clearSession();
+ replay.loadSession({ expiry: SESSION_IDLE_DURATION });
+ mockRecord.takeFullSnapshot.mockClear();
+ // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790)
+ delete window.location;
+ Object.defineProperty(window, 'location', {
+ value: prevLocation,
+ writable: true,
+ });
+});
+
+afterAll(() => {
+ replay && replay.stop();
+});
+
+it('flushes twice after multiple flush() calls)', async () => {
+ // blur events cause an immediate flush (as well as a flush due to adding a
+ // breadcrumb) -- this means that the first blur event will be flushed and
+ // the following blur events will all call a debounced flush function, which
+ // should end up queueing a second flush
+
+ window.dispatchEvent(new Event('blur'));
+ window.dispatchEvent(new Event('blur'));
+ window.dispatchEvent(new Event('blur'));
+ window.dispatchEvent(new Event('blur'));
+
+ expect(replay.flush).toHaveBeenCalledTimes(4);
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay.runFlush).toHaveBeenCalledTimes(1);
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay.runFlush).toHaveBeenCalledTimes(2);
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay.runFlush).toHaveBeenCalledTimes(2);
+});
+
+it('long first flush enqueues following events', async () => {
+ // Mock this to resolve after 20 seconds so that we can queue up following flushes
+ mockAddPerformanceEntries.mockImplementationOnce(async () => {
+ return await new Promise(resolve => setTimeout(resolve, 20000));
+ });
+
+ expect(mockAddPerformanceEntries).not.toHaveBeenCalled();
+
+ // flush #1 @ t=0s - due to blur
+ window.dispatchEvent(new Event('blur'));
+ expect(replay.flush).toHaveBeenCalledTimes(1);
+ expect(replay.runFlush).toHaveBeenCalledTimes(1);
+
+ // This will attempt to flush in 5 seconds (flushMinDelay)
+ domHandler({
+ name: 'click',
+ });
+ await advanceTimers(5000);
+ // flush #2 @ t=5s - due to click
+ expect(replay.flush).toHaveBeenCalledTimes(2);
+
+ await advanceTimers(1000);
+ // flush #3 @ t=6s - due to blur
+ window.dispatchEvent(new Event('blur'));
+ expect(replay.flush).toHaveBeenCalledTimes(3);
+
+ // NOTE: Blur also adds a breadcrumb which calls `addUpdate`, meaning it will
+ // flush after `flushMinDelay`, but this gets cancelled by the blur
+ await advanceTimers(8000);
+ expect(replay.flush).toHaveBeenCalledTimes(3);
+
+ // flush #4 @ t=14s - due to blur
+ window.dispatchEvent(new Event('blur'));
+ expect(replay.flush).toHaveBeenCalledTimes(4);
+
+ expect(replay.runFlush).toHaveBeenCalledTimes(1);
+ await advanceTimers(6000);
+ // t=20s
+ // addPerformanceEntries is finished, `flushLock` promise is resolved, calls
+ // debouncedFlush, which will call `flush` in 1 second
+ expect(replay.flush).toHaveBeenCalledTimes(4);
+ // sendReplay is called with replayId, events, segment
+ expect(mockSendReplay).toHaveBeenLastCalledWith({
+ events: expect.any(String),
+ replayId: expect.any(String),
+ includeReplayStartTimestamp: true,
+ segmentId: 0,
+ eventContext: expect.anything(),
+ });
+
+ // Add this to test that segment ID increases
+ mockAddPerformanceEntries.mockImplementationOnce(async () => {
+ return replay.createPerformanceSpans(
+ createPerformanceEntries([
+ {
+ name: 'https://sentry.io/foo.js',
+ entryType: 'resource',
+ startTime: 176.59999990463257,
+ duration: 5.600000023841858,
+ initiatorType: 'link',
+ nextHopProtocol: 'h2',
+ workerStart: 177.5,
+ redirectStart: 0,
+ redirectEnd: 0,
+ fetchStart: 177.69999992847443,
+ domainLookupStart: 177.69999992847443,
+ domainLookupEnd: 177.69999992847443,
+ connectStart: 177.69999992847443,
+ connectEnd: 177.69999992847443,
+ secureConnectionStart: 177.69999992847443,
+ requestStart: 177.5,
+ responseStart: 181,
+ responseEnd: 182.19999992847443,
+ transferSize: 0,
+ encodedBodySize: 0,
+ decodedBodySize: 0,
+ serverTiming: [],
+ } as unknown as PerformanceResourceTiming,
+ ]),
+ );
+ });
+ // flush #5 @ t=25s - debounced flush calls `flush`
+ // 20s + `flushMinDelay` which is 5 seconds
+ await advanceTimers(5000);
+ expect(replay.flush).toHaveBeenCalledTimes(5);
+ expect(replay.runFlush).toHaveBeenCalledTimes(2);
+ expect(mockSendReplay).toHaveBeenLastCalledWith({
+ events: expect.any(String),
+ replayId: expect.any(String),
+ includeReplayStartTimestamp: false,
+ segmentId: 1,
+ eventContext: expect.anything(),
+ });
+
+ // Make sure there's no other calls
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(mockSendReplay).toHaveBeenCalledTimes(2);
+});
diff --git a/packages/replay/test/unit/index-errorSampleRate.test.ts b/packages/replay/test/unit/index-errorSampleRate.test.ts
new file mode 100644
index 000000000000..9367e0d7f782
--- /dev/null
+++ b/packages/replay/test/unit/index-errorSampleRate.test.ts
@@ -0,0 +1,413 @@
+jest.unmock('@sentry/browser');
+
+import { captureException } from '@sentry/browser';
+import { BASE_TIMESTAMP, RecordMock } from '@test';
+import { PerformanceEntryResource } from '@test/fixtures/performanceEntry/resource';
+import { resetSdkMock } from '@test/mocks';
+import { DomHandler, MockTransportSend } from '@test/types';
+
+import { Replay } from './../../src';
+import { REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT } from './../../src/session/constants';
+import { useFakeTimers } from './../utils/use-fake-timers';
+
+useFakeTimers();
+
+async function advanceTimers(time: number) {
+ jest.advanceTimersByTime(time);
+ await new Promise(process.nextTick);
+}
+
+describe('Replay (errorSampleRate)', () => {
+ let replay: Replay;
+ let mockRecord: RecordMock;
+ let mockTransportSend: MockTransportSend;
+ let domHandler: DomHandler;
+
+ beforeEach(async () => {
+ ({ mockRecord, mockTransportSend, domHandler, replay } = await resetSdkMock({
+ errorSampleRate: 1.0,
+ sessionSampleRate: 0.0,
+ stickySession: true,
+ }));
+ // jest.advanceTimersToNextTimer();
+ });
+
+ afterEach(async () => {
+ replay.clearSession();
+ replay.stop();
+ });
+
+ it('uploads a replay when `Sentry.captureException` is called and continues recording', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+
+ // Does not capture mouse click
+ domHandler({
+ name: 'click',
+ });
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay).not.toHaveSentReplay();
+
+ captureException(new Error('testing'));
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ replayEventPayload: expect.objectContaining({
+ tags: expect.objectContaining({
+ errorSampleRate: 1,
+ replayType: 'error',
+ sessionSampleRate: 0,
+ }),
+ }),
+ events: JSON.stringify([
+ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 },
+ TEST_EVENT,
+ {
+ type: 5,
+ timestamp: BASE_TIMESTAMP,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: BASE_TIMESTAMP / 1000,
+ type: 'default',
+ category: 'ui.click',
+ message: '',
+ data: {},
+ },
+ },
+ },
+ ]),
+ });
+
+ mockTransportSend.mockClear();
+ expect(replay).not.toHaveSentReplay();
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ // New checkout when we call `startRecording` again after uploading segment
+ // after an error occurs
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([
+ {
+ data: { isCheckout: true },
+ timestamp: BASE_TIMESTAMP + 5000 + 20,
+ type: 2,
+ },
+ ]),
+ });
+
+ // Check that click will get captured
+ domHandler({
+ name: 'click',
+ });
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([
+ {
+ type: 5,
+ timestamp: BASE_TIMESTAMP + 15000 + 60,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: (BASE_TIMESTAMP + 15000 + 60) / 1000,
+ type: 'default',
+ category: 'ui.click',
+ message: '',
+ data: {},
+ },
+ },
+ },
+ ]),
+ });
+ });
+
+ it('does not send a replay when triggering a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
+
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('does not send a replay if user hides the tab and comes back within 60 seconds', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveSentReplay();
+
+ // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 100);
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('does not upload a replay event when document becomes hidden', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ jest.advanceTimersByTime(ELAPSED);
+
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
+ replay.addEvent(TEST_EVENT);
+
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('does not upload a replay event if 5 seconds have elapsed since the last replay event occurred', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('does not upload a replay event if 15 seconds have elapsed since the last replay upload', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ // Fire a new event every 4 seconds, 4 times
+ [...Array(4)].forEach(() => {
+ mockRecord._emitter(TEST_EVENT);
+ jest.advanceTimersByTime(4000);
+ });
+
+ // We are at time = +16seconds now (relative to BASE_TIMESTAMP)
+ // The next event should cause an upload immediately
+ mockRecord._emitter(TEST_EVENT);
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveSentReplay();
+
+ // There should also not be another attempt at an upload 5 seconds after the last replay event
+ await advanceTimers(5000);
+ expect(replay).not.toHaveSentReplay();
+
+ // Let's make sure it continues to work
+ mockRecord._emitter(TEST_EVENT);
+ await advanceTimers(5000);
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('does not upload if user has been idle for more than 15 minutes and comes back to move their mouse', async () => {
+ // Idle for 15 minutes
+ jest.advanceTimersByTime(15 * 60000);
+
+ // TBD: We are currently deciding that this event will get dropped, but
+ // this could/should change in the future.
+ const TEST_EVENT = {
+ data: { name: 'lost event' },
+ timestamp: BASE_TIMESTAMP,
+ type: 3,
+ };
+ mockRecord._emitter(TEST_EVENT);
+ expect(replay).not.toHaveSentReplay();
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ // Instead of recording the above event, a full snapshot will occur.
+ //
+ // TODO: We could potentially figure out a way to save the last session,
+ // and produce a checkout based on a previous checkout + updates, and then
+ // replay the event on top. Or maybe replay the event on top of a refresh
+ // snapshot.
+
+ expect(replay).not.toHaveSentReplay();
+ expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true);
+ });
+
+ it('has the correct timestamps with deferred root event and last replay update', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ jest.advanceTimersByTime(5000);
+
+ captureException(new Error('testing'));
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]),
+ replayEventPayload: expect.objectContaining({
+ replay_start_timestamp: BASE_TIMESTAMP / 1000,
+ // the exception happens roughly 10 seconds after BASE_TIMESTAMP
+ // (advance timers + waiting for flush after the checkout) and
+ // extra time is likely due to async of `addMemoryEntry()`
+
+ timestamp: (BASE_TIMESTAMP + 5000 + 5000 + 20) / 1000,
+ error_ids: [expect.any(String)],
+ trace_ids: [],
+ urls: ['http://localhost/'],
+ replay_id: expect.any(String),
+ }),
+ recordingPayloadHeader: { segment_id: 0 },
+ });
+ });
+
+ /**
+ * This is testing a case that should only happen with error-only sessions.
+ * Previously we had assumed that loading a session from session storage meant
+ * that the session was not new. However, this is not the case with error-only
+ * sampling since we can load a saved session that did not have an error (and
+ * thus no replay was created).
+ */
+ it('sends a replay after loading the session multiple times', async () => {
+ // Pretend that a session is already saved before loading replay
+ window.sessionStorage.setItem(
+ REPLAY_SESSION_KEY,
+ `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"error","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`,
+ );
+ ({ mockRecord, mockTransportSend, replay } = await resetSdkMock({
+ stickySession: true,
+ }));
+ replay.start();
+
+ jest.runAllTimers();
+
+ await new Promise(process.nextTick);
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+
+ expect(replay).not.toHaveSentReplay();
+
+ captureException(new Error('testing'));
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]),
+ });
+
+ mockTransportSend.mockClear();
+ expect(replay).not.toHaveSentReplay();
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ // New checkout when we call `startRecording` again after uploading segment
+ // after an error occurs
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([
+ {
+ data: { isCheckout: true },
+ timestamp: BASE_TIMESTAMP + 10000 + 20,
+ type: 2,
+ },
+ ]),
+ });
+ });
+
+ it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => {
+ const ELAPSED = 60000;
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+
+ // add a mock performance event
+ replay.performanceEvents.push(PerformanceEntryResource());
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+
+ jest.advanceTimersByTime(ELAPSED);
+
+ // in production, this happens at a time interval
+ // session started time should be updated to this current timestamp
+ mockRecord.takeFullSnapshot(true);
+
+ jest.runAllTimers();
+ jest.advanceTimersByTime(20);
+ await new Promise(process.nextTick);
+
+ captureException(new Error('testing'));
+
+ jest.runAllTimers();
+ jest.advanceTimersByTime(20);
+ await new Promise(process.nextTick);
+
+ expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20);
+
+ // Does not capture mouse click
+ expect(replay).toHaveSentReplay({
+ replayEventPayload: expect.objectContaining({
+ // Make sure the old performance event is thrown out
+ replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000,
+ }),
+ events: JSON.stringify([
+ {
+ data: { isCheckout: true },
+ timestamp: BASE_TIMESTAMP + ELAPSED + 20,
+ type: 2,
+ },
+ ]),
+ });
+ });
+});
diff --git a/packages/replay/test/unit/index-handleGlobalEvent.test.ts b/packages/replay/test/unit/index-handleGlobalEvent.test.ts
new file mode 100644
index 000000000000..e19fa5804cb7
--- /dev/null
+++ b/packages/replay/test/unit/index-handleGlobalEvent.test.ts
@@ -0,0 +1,105 @@
+import { Error } from '@test/fixtures/error';
+import { Transaction } from '@test/fixtures/transaction';
+import { resetSdkMock } from '@test/mocks';
+
+import { Replay } from './../../src';
+import { REPLAY_EVENT_NAME } from './../../src/session/constants';
+import { useFakeTimers } from './../utils/use-fake-timers';
+
+useFakeTimers();
+let replay: Replay;
+
+beforeEach(async () => {
+ ({ replay } = await resetSdkMock({
+ errorSampleRate: 1.0,
+ sessionSampleRate: 0.0,
+ stickySession: false,
+ }));
+});
+
+afterEach(() => {
+ replay.stop();
+});
+
+it('deletes breadcrumbs from replay events', () => {
+ const replayEvent = {
+ type: REPLAY_EVENT_NAME,
+ breadcrumbs: [{ type: 'fakecrumb' }],
+ };
+
+ // @ts-ignore replay event type
+ expect(replay.handleGlobalEvent(replayEvent)).toEqual({
+ type: REPLAY_EVENT_NAME,
+ });
+});
+
+it('does not delete breadcrumbs from error and transaction events', () => {
+ expect(
+ replay.handleGlobalEvent({
+ breadcrumbs: [{ type: 'fakecrumb' }],
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ breadcrumbs: [{ type: 'fakecrumb' }],
+ }),
+ );
+ expect(
+ replay.handleGlobalEvent({
+ type: 'transaction',
+ breadcrumbs: [{ type: 'fakecrumb' }],
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ breadcrumbs: [{ type: 'fakecrumb' }],
+ }),
+ );
+});
+
+it('only tags errors with replay id, adds trace and error id to context for error samples', async () => {
+ const transaction = Transaction();
+ const error = Error();
+ // @ts-ignore idc
+ expect(replay.handleGlobalEvent(transaction)).toEqual(
+ expect.objectContaining({
+ tags: expect.not.objectContaining({ replayId: expect.anything() }),
+ }),
+ );
+ expect(replay.handleGlobalEvent(error)).toEqual(
+ expect.objectContaining({
+ tags: expect.objectContaining({ replayId: expect.any(String) }),
+ }),
+ );
+
+ // @ts-ignore private
+ expect(replay.context.traceIds).toContain('trace_id');
+ // @ts-ignore private
+ expect(replay.context.errorIds).toContain('event_id');
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick); // wait for flush
+
+ // Turns off `waitForError` mode
+ // @ts-ignore private
+ expect(replay.waitForError).toBe(false);
+});
+
+it('tags errors and transactions with replay id for session samples', async () => {
+ ({ replay } = await resetSdkMock({
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+ }));
+ replay.start();
+ const transaction = Transaction();
+ const error = Error();
+ // @ts-ignore idc
+ expect(replay.handleGlobalEvent(transaction)).toEqual(
+ expect.objectContaining({
+ tags: expect.objectContaining({ replayId: expect.any(String) }),
+ }),
+ );
+ expect(replay.handleGlobalEvent(error)).toEqual(
+ expect.objectContaining({
+ tags: expect.objectContaining({ replayId: expect.any(String) }),
+ }),
+ );
+});
diff --git a/packages/replay/test/unit/index-noSticky.test.ts b/packages/replay/test/unit/index-noSticky.test.ts
new file mode 100644
index 000000000000..8cddc6c3776a
--- /dev/null
+++ b/packages/replay/test/unit/index-noSticky.test.ts
@@ -0,0 +1,281 @@
+import { getCurrentHub } from '@sentry/core';
+import { Transport } from '@sentry/types';
+import * as SentryUtils from '@sentry/utils';
+import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '@test';
+
+import { Replay } from './../../src';
+import { SESSION_IDLE_DURATION, VISIBILITY_CHANGE_TIMEOUT } from './../../src/session/constants';
+import { useFakeTimers } from './../utils/use-fake-timers';
+
+useFakeTimers();
+
+async function advanceTimers(time: number) {
+ jest.advanceTimersByTime(time);
+ await new Promise(process.nextTick);
+}
+
+type MockTransport = jest.MockedFunction;
+
+describe('Replay (no sticky)', () => {
+ let replay: Replay;
+ let mockTransport: MockTransport;
+ let domHandler: (args: any) => any;
+ const { record: mockRecord } = mockRrweb();
+
+ beforeAll(async () => {
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => {
+ if (type === 'dom') {
+ domHandler = handler;
+ }
+ });
+
+ ({ replay } = await mockSdk({
+ replayOptions: {
+ stickySession: false,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+ },
+ }));
+ jest.runAllTimers();
+ mockTransport = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransport;
+ });
+
+ beforeEach(() => {
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ mockRecord.takeFullSnapshot.mockClear();
+ mockTransport.mockClear();
+ });
+
+ afterEach(async () => {
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ replay.clearSession();
+ replay.loadSession({ expiry: SESSION_IDLE_DURATION });
+ });
+
+ afterAll(() => {
+ replay && replay.stop();
+ });
+
+ it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+
+ const initialSession = replay.session;
+
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
+
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true);
+
+ // Should have created a new session
+ expect(replay).not.toHaveSameSession(initialSession);
+ });
+
+ it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => {
+ const initialSession = replay.session;
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).toHaveSameSession(initialSession);
+
+ // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1);
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ // Should NOT have created a new session
+ expect(replay).toHaveSameSession(initialSession);
+ });
+
+ it('uploads a replay event when document becomes hidden', async () => {
+ mockRecord.takeFullSnapshot.mockClear();
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ jest.advanceTimersByTime(ELAPSED);
+
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
+ replay.addEvent(TEST_EVENT);
+
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ await new Promise(process.nextTick);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+
+ expect(replay).toHaveSentReplay({ events: JSON.stringify([TEST_EVENT]) });
+
+ // Session's last activity is not updated because we do not consider
+ // visibilitystate as user being active
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+ });
+
+ it('update last activity when user clicks mouse', async () => {
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+
+ domHandler({
+ name: 'click',
+ });
+
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ jest.advanceTimersByTime(ELAPSED);
+
+ domHandler({
+ name: 'click',
+ });
+
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED);
+ });
+
+ it('uploads a replay event if 5 seconds have elapsed since the last replay event occurred', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+
+ expect(replay).toHaveSentReplay({ events: JSON.stringify([TEST_EVENT]) });
+
+ // No user activity to trigger an update
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+ });
+
+ it('uploads a replay event if 15 seconds have elapsed since the last replay upload', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ // Fire a new event every 4 seconds, 4 times
+ [...Array(4)].forEach(() => {
+ mockRecord._emitter(TEST_EVENT);
+ jest.advanceTimersByTime(4000);
+ });
+
+ // We are at time = +16seconds now (relative to BASE_TIMESTAMP)
+ // The next event should cause an upload immediately
+ mockRecord._emitter(TEST_EVENT);
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)),
+ });
+
+ // There should also not be another attempt at an upload 5 seconds after the last replay event
+ mockTransport.mockClear();
+ await advanceTimers(5000);
+ expect(replay).not.toHaveSentReplay();
+
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+
+ // Let's make sure it continues to work
+ mockTransport.mockClear();
+ mockRecord._emitter(TEST_EVENT);
+ await advanceTimers(5000);
+ expect(replay).toHaveSentReplay({ events: JSON.stringify([TEST_EVENT]) });
+ });
+
+ it('creates a new session if user has been idle for more than 15 minutes and comes back to move their mouse', async () => {
+ const initialSession = replay.session;
+
+ expect(initialSession?.id).toBeDefined();
+
+ // Idle for 15 minutes
+ const FIFTEEN_MINUTES = 15 * 60000;
+ jest.advanceTimersByTime(FIFTEEN_MINUTES);
+
+ // TBD: We are currently deciding that this event will get dropped, but
+ // this could/should change in the future.
+ const TEST_EVENT = {
+ data: { name: 'lost event' },
+ timestamp: BASE_TIMESTAMP,
+ type: 3,
+ };
+ mockRecord._emitter(TEST_EVENT);
+ expect(replay).not.toHaveSentReplay();
+
+ await new Promise(process.nextTick);
+
+ // Instead of recording the above event, a full snapshot will occur.
+ //
+ // TODO: We could potentially figure out a way to save the last session,
+ // and produce a checkout based on a previous checkout + updates, and then
+ // replay the event on top. Or maybe replay the event on top of a refresh
+ // snapshot.
+ expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true);
+
+ // Should be a new session
+ expect(replay).not.toHaveSameSession(initialSession);
+
+ // Replay does not send immediately because checkout was due to expired session
+ expect(replay).not.toHaveSentReplay();
+
+ // Now do a click
+ domHandler({
+ name: 'click',
+ });
+
+ await advanceTimers(5000);
+
+ const newTimestamp = BASE_TIMESTAMP + FIFTEEN_MINUTES;
+ const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from
+
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([
+ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
+ {
+ type: 5,
+ timestamp: breadcrumbTimestamp,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: breadcrumbTimestamp / 1000,
+ type: 'default',
+ category: 'ui.click',
+ message: '',
+ data: {},
+ },
+ },
+ },
+ ]),
+ });
+ });
+});
diff --git a/packages/replay/test/unit/index-sampling.test.ts b/packages/replay/test/unit/index-sampling.test.ts
new file mode 100644
index 000000000000..507dceca5457
--- /dev/null
+++ b/packages/replay/test/unit/index-sampling.test.ts
@@ -0,0 +1,38 @@
+jest.unmock('@sentry/browser');
+
+// mock functions need to be imported first
+import { mockRrweb, mockSdk } from '@test';
+
+import { useFakeTimers } from './../utils/use-fake-timers';
+
+useFakeTimers();
+
+describe('Replay (sampling)', () => {
+ it('does nothing if not sampled', async () => {
+ const { record: mockRecord } = mockRrweb();
+ const { replay } = await mockSdk({
+ replayOptions: {
+ stickySession: true,
+ sessionSampleRate: 0.0,
+ errorSampleRate: 0.0,
+ },
+ });
+
+ jest.spyOn(replay, 'loadSession');
+ jest.spyOn(replay, 'addListeners');
+ // @ts-ignore private
+ expect(replay.initialState).toEqual(undefined);
+ jest.runAllTimers();
+
+ expect(replay.session?.sampled).toBe(false);
+ // @ts-ignore private
+ expect(replay.context).toEqual(
+ expect.objectContaining({
+ initialTimestamp: expect.any(Number),
+ initialUrl: 'http://localhost/',
+ }),
+ );
+ expect(mockRecord).not.toHaveBeenCalled();
+ expect(replay.addListeners).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/replay/test/unit/index.test.ts b/packages/replay/test/unit/index.test.ts
new file mode 100644
index 000000000000..ec15575b7df5
--- /dev/null
+++ b/packages/replay/test/unit/index.test.ts
@@ -0,0 +1,868 @@
+jest.mock('./../../src/util/isInternal', () => ({
+ isInternal: jest.fn(() => true),
+}));
+import { BASE_TIMESTAMP, RecordMock } from '@test';
+import { PerformanceEntryResource } from '@test/fixtures/performanceEntry/resource';
+import { resetSdkMock } from '@test/mocks';
+import { DomHandler, MockTransportSend } from '@test/types';
+
+import { Replay } from '../../src';
+import { MAX_SESSION_LIFE, REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT } from '../../src/session/constants';
+import { useFakeTimers } from '../utils/use-fake-timers';
+
+useFakeTimers();
+
+async function advanceTimers(time: number) {
+ jest.advanceTimersByTime(time);
+ await new Promise(process.nextTick);
+}
+
+describe('Replay', () => {
+ let replay: Replay;
+ let mockRecord: RecordMock;
+ let mockTransportSend: MockTransportSend;
+ let domHandler: DomHandler;
+ let spyCaptureException: jest.MockedFunction;
+ const prevLocation = window.location;
+
+ type MockSendReplayRequest = jest.MockedFunction;
+ let mockSendReplayRequest: MockSendReplayRequest;
+
+ beforeAll(async () => {
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ jest.runAllTimers();
+ });
+
+ beforeEach(async () => {
+ ({ mockRecord, mockTransportSend, domHandler, replay, spyCaptureException } = await resetSdkMock({
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0.0,
+ stickySession: true,
+ }));
+
+ jest.spyOn(replay, 'flush');
+ jest.spyOn(replay, 'runFlush');
+ jest.spyOn(replay, 'sendReplayRequest');
+
+ // Create a new session and clear mocks because a segment (from initial
+ // checkout) will have already been uploaded by the time the tests run
+ replay.clearSession();
+ replay.loadSession({ expiry: 0 });
+ mockTransportSend.mockClear();
+ mockSendReplayRequest = replay.sendReplayRequest as MockSendReplayRequest;
+ mockSendReplayRequest.mockClear();
+ });
+
+ afterEach(async () => {
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790)
+ delete window.location;
+ Object.defineProperty(window, 'location', {
+ value: prevLocation,
+ writable: true,
+ });
+ replay.clearSession();
+ jest.clearAllMocks();
+ mockSendReplayRequest.mockRestore();
+ mockRecord.takeFullSnapshot.mockClear();
+ replay.stop();
+ });
+
+ it('calls rrweb.record with custom options', async () => {
+ ({ mockRecord, mockTransportSend, domHandler, replay } = await resetSdkMock({
+ ignoreClass: 'sentry-test-ignore',
+ }));
+ expect(mockRecord.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Object {
+ "blockClass": "sentry-block",
+ "blockSelector": "[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio",
+ "emit": [Function],
+ "ignoreClass": "sentry-test-ignore",
+ "maskAllInputs": true,
+ "maskTextClass": "sentry-mask",
+ "maskTextSelector": "*",
+ }
+ `);
+ });
+
+ it('should have a session after setup', () => {
+ expect(replay.session).toMatchObject({
+ lastActivity: BASE_TIMESTAMP,
+ started: BASE_TIMESTAMP,
+ });
+ expect(replay.session?.id).toBeDefined();
+ expect(replay.session?.segmentId).toBeDefined();
+ });
+
+ it('clears session', () => {
+ replay.clearSession();
+ expect(window.sessionStorage.getItem(REPLAY_SESSION_KEY)).toBe(null);
+ expect(replay.session).toBe(undefined);
+ });
+
+ it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+
+ const initialSession = replay.session;
+
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
+
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true);
+
+ // Should have created a new session
+ expect(replay).not.toHaveSameSession(initialSession);
+ });
+
+ it('creates a new session and triggers a full dom snapshot when document becomes focused after [VISIBILITY_CHANGE_TIMEOUT]ms', () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+
+ const initialSession = replay.session;
+
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
+
+ window.dispatchEvent(new Event('focus'));
+
+ expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true);
+
+ // Should have created a new session
+ expect(replay).not.toHaveSameSession(initialSession);
+ });
+
+ it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => {
+ const initialSession = replay.session;
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).toHaveSameSession(initialSession);
+
+ // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses
+ jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1);
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'visible';
+ },
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ // Should NOT have created a new session
+ expect(replay).toHaveSameSession(initialSession);
+ });
+
+ it('uploads a replay event when window is blurred', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ jest.advanceTimersByTime(ELAPSED);
+
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
+ const hiddenBreadcrumb = {
+ type: 5,
+ timestamp: +new Date(BASE_TIMESTAMP + ELAPSED) / 1000,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: +new Date(BASE_TIMESTAMP + ELAPSED) / 1000,
+ type: 'default',
+ category: 'ui.blur',
+ },
+ },
+ };
+
+ replay.addEvent(TEST_EVENT);
+ window.dispatchEvent(new Event('blur'));
+ await new Promise(process.nextTick);
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([TEST_EVENT, hiddenBreadcrumb]),
+ });
+ // Session's last activity should not be updated
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+ });
+
+ it('uploads a replay event when document becomes hidden', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ jest.advanceTimersByTime(ELAPSED);
+
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
+
+ replay.addEvent(TEST_EVENT);
+ document.dispatchEvent(new Event('visibilitychange'));
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).toHaveSentReplay({ events: JSON.stringify([TEST_EVENT]) });
+
+ // Session's last activity is not updated because we do not consider
+ // visibilitystate as user being active
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+ });
+
+ it('uploads a replay event if 5 seconds have elapsed since the last replay event occurred', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ mockRecord._emitter(TEST_EVENT);
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(mockTransportSend).toHaveBeenCalledTimes(1);
+ expect(replay).toHaveSentReplay({ events: JSON.stringify([TEST_EVENT]) });
+
+ // No user activity to trigger an update
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+ });
+
+ it('uploads a replay event if 15 seconds have elapsed since the last replay upload', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ // Fire a new event every 4 seconds, 4 times
+ [...Array(4)].forEach(() => {
+ mockRecord._emitter(TEST_EVENT);
+ jest.advanceTimersByTime(4000);
+ });
+
+ // We are at time = +16seconds now (relative to BASE_TIMESTAMP)
+ // The next event should cause an upload immediately
+ mockRecord._emitter(TEST_EVENT);
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)),
+ });
+
+ // There should also not be another attempt at an upload 5 seconds after the last replay event
+ mockTransportSend.mockClear();
+ await advanceTimers(5000);
+
+ expect(replay).not.toHaveSentReplay();
+
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+ // events array should be empty
+ expect(replay.eventBuffer?.length).toBe(0);
+
+ // Let's make sure it continues to work
+ mockTransportSend.mockClear();
+ mockRecord._emitter(TEST_EVENT);
+ await advanceTimers(5000);
+ expect(replay).toHaveSentReplay({ events: JSON.stringify([TEST_EVENT]) });
+ });
+
+ it('creates a new session if user has been idle for 15 minutes and comes back to click their mouse', async () => {
+ const initialSession = replay.session;
+
+ expect(initialSession?.id).toBeDefined();
+ // @ts-ignore private member
+ expect(replay.context).toEqual(
+ expect.objectContaining({
+ initialUrl: 'http://localhost/',
+ initialTimestamp: BASE_TIMESTAMP,
+ }),
+ );
+
+ const url = 'http://dummy/';
+ Object.defineProperty(window, 'location', {
+ value: new URL(url),
+ });
+
+ // Idle for 15 minutes
+ const FIFTEEN_MINUTES = 15 * 60000;
+ jest.advanceTimersByTime(FIFTEEN_MINUTES);
+
+ // TBD: We are currently deciding that this event will get dropped, but
+ // this could/should change in the future.
+ const TEST_EVENT = {
+ data: { name: 'lost event' },
+ timestamp: BASE_TIMESTAMP,
+ type: 3,
+ };
+ mockRecord._emitter(TEST_EVENT);
+ expect(replay).not.toHaveSentReplay();
+
+ await new Promise(process.nextTick);
+
+ // Instead of recording the above event, a full snapshot will occur.
+ //
+ // TODO: We could potentially figure out a way to save the last session,
+ // and produce a checkout based on a previous checkout + updates, and then
+ // replay the event on top. Or maybe replay the event on top of a refresh
+ // snapshot.
+ expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true);
+
+ expect(replay).not.toHaveSentReplay();
+
+ // Should be a new session
+ expect(replay).not.toHaveSameSession(initialSession);
+
+ // Now do a click
+ domHandler({
+ name: 'click',
+ });
+
+ await advanceTimers(5000);
+
+ const newTimestamp = BASE_TIMESTAMP + FIFTEEN_MINUTES;
+ const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from
+
+ expect(replay).toHaveSentReplay({
+ recordingPayloadHeader: { segment_id: 0 },
+ events: JSON.stringify([
+ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
+ {
+ type: 5,
+ timestamp: breadcrumbTimestamp,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: breadcrumbTimestamp / 1000,
+ type: 'default',
+ category: 'ui.click',
+ message: '',
+ data: {},
+ },
+ },
+ },
+ ]),
+ });
+
+ // `context` should be reset when a new session is created
+ // @ts-ignore private member
+ expect(replay.context).toEqual(
+ expect.objectContaining({
+ initialUrl: 'http://dummy/',
+ initialTimestamp: newTimestamp,
+ }),
+ );
+ });
+
+ it('does not record if user has been idle for more than MAX_SESSION_LIFE and only starts a new session after a user action', async () => {
+ jest.clearAllMocks();
+ const initialSession = replay.session;
+
+ expect(initialSession?.id).toBeDefined();
+ // @ts-ignore private member
+ expect(replay.context).toEqual(
+ expect.objectContaining({
+ initialUrl: 'http://localhost/',
+ initialTimestamp: BASE_TIMESTAMP,
+ }),
+ );
+
+ const url = 'http://dummy/';
+ Object.defineProperty(window, 'location', {
+ value: new URL(url),
+ });
+
+ // Idle for MAX_SESSION_LIFE
+ jest.advanceTimersByTime(MAX_SESSION_LIFE);
+
+ // These events will not get flushed and will eventually be dropped because user is idle and session is expired
+ const TEST_EVENT = {
+ data: { name: 'lost event' },
+ timestamp: MAX_SESSION_LIFE,
+ type: 3,
+ };
+ mockRecord._emitter(TEST_EVENT);
+ // performance events can still be collected while recording is stopped
+ // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive
+ replay.addUpdate(() => {
+ replay.createPerformanceSpans([
+ {
+ type: 'navigation.navigate',
+ name: 'foo',
+ start: BASE_TIMESTAMP + MAX_SESSION_LIFE,
+ end: BASE_TIMESTAMP + MAX_SESSION_LIFE + 100,
+ },
+ ]);
+ return true;
+ });
+
+ window.dispatchEvent(new Event('blur'));
+ await advanceTimers(5000);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+ // Should be the same session because user has been idle and no events have caused a new session to be created
+ expect(replay).toHaveSameSession(initialSession);
+
+ // @ts-ignore private
+ expect(replay.stopRecording).toBeUndefined();
+
+ // Now do a click
+ domHandler({
+ name: 'click',
+ });
+ // This should still be thrown away
+ mockRecord._emitter(TEST_EVENT);
+
+ const NEW_TEST_EVENT = {
+ data: { name: 'test' },
+ timestamp: BASE_TIMESTAMP + MAX_SESSION_LIFE + 5000 + 20,
+ type: 3,
+ };
+
+ mockRecord._emitter(NEW_TEST_EVENT);
+
+ // new session is created
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveSameSession(initialSession);
+ await advanceTimers(5000);
+
+ const newTimestamp = BASE_TIMESTAMP + MAX_SESSION_LIFE + 5000 + 20; // I don't know where this 20ms comes from
+ const breadcrumbTimestamp = newTimestamp;
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ recordingPayloadHeader: { segment_id: 0 },
+ events: JSON.stringify([
+ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
+ {
+ type: 5,
+ timestamp: breadcrumbTimestamp,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: breadcrumbTimestamp / 1000,
+ type: 'default',
+ category: 'ui.click',
+ message: '',
+ data: {},
+ },
+ },
+ },
+ NEW_TEST_EVENT,
+ ]),
+ });
+
+ // `context` should be reset when a new session is created
+ // @ts-ignore private member
+ expect(replay.context).toEqual(
+ expect.objectContaining({
+ initialUrl: 'http://dummy/',
+ initialTimestamp: newTimestamp,
+ }),
+ );
+ });
+
+ it('uploads a dom breadcrumb 5 seconds after listener receives an event', async () => {
+ domHandler({
+ name: 'click',
+ });
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([
+ {
+ type: 5,
+ timestamp: BASE_TIMESTAMP,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: BASE_TIMESTAMP / 1000,
+ type: 'default',
+ category: 'ui.click',
+ message: '',
+ data: {},
+ },
+ },
+ },
+ ]),
+ });
+
+ expect(replay.session?.segmentId).toBe(1);
+ });
+
+ it('fails to upload data on first two calls and succeeds on the third', async () => {
+ expect(replay.session?.segmentId).toBe(0);
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+
+ // Suppress console.errors
+ jest.spyOn(console, 'error').mockImplementation(jest.fn());
+ const mockConsole = console.error as jest.MockedFunction;
+
+ // fail the first and second requests and pass the third one
+ mockTransportSend.mockImplementationOnce(() => {
+ throw new Error('Something bad happened');
+ });
+ mockRecord._emitter(TEST_EVENT);
+ await advanceTimers(5000);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ mockTransportSend.mockImplementationOnce(() => {
+ throw new Error('Something bad happened');
+ });
+ await advanceTimers(5000);
+
+ // next tick should retry and succeed
+ mockConsole.mockRestore();
+
+ await advanceTimers(8000);
+ await advanceTimers(2000);
+
+ expect(replay).toHaveSentReplay({
+ replayEventPayload: expect.objectContaining({
+ error_ids: [],
+ replay_id: expect.any(String),
+ replay_start_timestamp: BASE_TIMESTAMP / 1000,
+ // 20seconds = Add up all of the previous `advanceTimers()`
+ timestamp: (BASE_TIMESTAMP + 20000) / 1000 + 0.02,
+ trace_ids: [],
+ urls: ['http://localhost/'],
+ }),
+ recordingPayloadHeader: { segment_id: 0 },
+ events: JSON.stringify([TEST_EVENT]),
+ });
+
+ mockTransportSend.mockClear();
+ // No activity has occurred, session's last activity should remain the same
+ expect(replay.session?.lastActivity).toBeGreaterThanOrEqual(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+
+ // next tick should do nothing
+ await advanceTimers(5000);
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('fails to upload data and hits retry max and stops', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ jest.spyOn(replay, 'sendReplay');
+
+ // Suppress console.errors
+ jest.spyOn(console, 'error').mockImplementation(jest.fn());
+ const mockConsole = console.error as jest.MockedFunction;
+
+ expect(replay.session?.segmentId).toBe(0);
+
+ // fail the first and second requests and pass the third one
+ mockSendReplayRequest.mockReset();
+ mockSendReplayRequest.mockImplementation(() => {
+ throw new Error('Something bad happened');
+ });
+ mockRecord._emitter(TEST_EVENT);
+
+ await advanceTimers(5000);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(1);
+
+ await advanceTimers(5000);
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(2);
+
+ await advanceTimers(10000);
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(3);
+
+ await advanceTimers(30000);
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4);
+ expect(replay.sendReplay).toHaveBeenCalledTimes(4);
+
+ mockConsole.mockReset();
+
+ // Make sure it doesn't retry again
+ jest.runAllTimers();
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4);
+ expect(replay.sendReplay).toHaveBeenCalledTimes(4);
+
+ expect(spyCaptureException).toHaveBeenCalledTimes(5);
+ // Retries = 3 (total tries = 4 including initial attempt)
+ // + last exception is max retries exceeded
+ expect(spyCaptureException).toHaveBeenCalledTimes(5);
+ expect(spyCaptureException).toHaveBeenLastCalledWith(new Error('Unable to send Replay - max retries exceeded'));
+
+ // No activity has occurred, session's last activity should remain the same
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
+
+ // segmentId increases despite error
+ expect(replay.session?.segmentId).toBe(1);
+ });
+
+ it('increases segment id after each event', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
+
+ replay.addEvent(TEST_EVENT);
+ window.dispatchEvent(new Event('blur'));
+ await new Promise(process.nextTick);
+ expect(replay).toHaveSentReplay({
+ recordingPayloadHeader: { segment_id: 0 },
+ });
+ expect(replay.session?.segmentId).toBe(1);
+
+ replay.addEvent(TEST_EVENT);
+ window.dispatchEvent(new Event('blur'));
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay.session?.segmentId).toBe(2);
+ expect(replay).toHaveSentReplay({
+ recordingPayloadHeader: { segment_id: 1 },
+ });
+ });
+
+ it('does not create replay event when there are no events to send', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ document.dispatchEvent(new Event('visibilitychange'));
+ await new Promise(process.nextTick);
+ expect(replay).not.toHaveSentReplay();
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ const TEST_EVENT = {
+ data: {},
+ timestamp: BASE_TIMESTAMP + ELAPSED,
+ type: 2,
+ };
+
+ replay.addEvent(TEST_EVENT);
+ window.dispatchEvent(new Event('blur'));
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveSentReplay({
+ replayEventPayload: expect.objectContaining({
+ replay_start_timestamp: BASE_TIMESTAMP / 1000,
+ urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough
+ }),
+ });
+ });
+
+ // TODO: ... this doesn't really test anything anymore since replay event and recording are sent in the same envelope
+ it('does not create replay event if recording upload completely fails', async () => {
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+ // Suppress console.errors
+ jest.spyOn(console, 'error').mockImplementation(jest.fn());
+ const mockConsole = console.error as jest.MockedFunction;
+ // fail the first and second requests and pass the third one
+ mockSendReplayRequest.mockImplementationOnce(() => {
+ throw new Error('Something bad happened');
+ });
+ mockRecord._emitter(TEST_EVENT);
+
+ await advanceTimers(5000);
+
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+
+ // Reset console.error mock to minimize the amount of time we are hiding
+ // console messages in case an error happens after
+ mockConsole.mockClear();
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+
+ mockSendReplayRequest.mockImplementationOnce(() => {
+ throw new Error('Something bad happened');
+ });
+ await advanceTimers(5000);
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(2);
+
+ // next tick should retry and fail
+ mockConsole.mockClear();
+
+ mockSendReplayRequest.mockImplementationOnce(() => {
+ throw new Error('Something bad happened');
+ });
+ await advanceTimers(10000);
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(3);
+
+ mockSendReplayRequest.mockImplementationOnce(() => {
+ throw new Error('Something bad happened');
+ });
+ await advanceTimers(30000);
+ expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4);
+
+ // No activity has occurred, session's last activity should remain the same
+ expect(replay.session?.lastActivity).toBeGreaterThanOrEqual(BASE_TIMESTAMP);
+ expect(replay.session?.segmentId).toBe(1);
+
+ // TODO: Recording should stop and next event should do nothing
+ });
+
+ it('has correct timestamps when there events earlier than initial timestamp', async function () {
+ replay.clearSession();
+ replay.loadSession({ expiry: 0 });
+ mockTransportSend.mockClear();
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ document.dispatchEvent(new Event('visibilitychange'));
+ await new Promise(process.nextTick);
+ expect(replay).not.toHaveSentReplay();
+
+ // Pretend 5 seconds have passed
+ const ELAPSED = 5000;
+ await advanceTimers(ELAPSED);
+
+ const TEST_EVENT = {
+ data: {},
+ timestamp: BASE_TIMESTAMP + ELAPSED,
+ type: 2,
+ };
+
+ replay.addEvent(TEST_EVENT);
+
+ // Add a fake event that started BEFORE
+ replay.addEvent({
+ data: {},
+ timestamp: (BASE_TIMESTAMP - 10000) / 1000,
+ type: 5,
+ });
+
+ window.dispatchEvent(new Event('blur'));
+ await new Promise(process.nextTick);
+ expect(replay).toHaveSentReplay({
+ replayEventPayload: expect.objectContaining({
+ replay_start_timestamp: (BASE_TIMESTAMP - 10000) / 1000,
+ urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough
+ tags: expect.objectContaining({
+ errorSampleRate: 0,
+ replayType: 'session',
+ sessionSampleRate: 1,
+ }),
+ }),
+ });
+ });
+
+ it('does not have stale `replay_start_timestamp` due to an old time origin', async function () {
+ const ELAPSED = 86400000 * 2; // 2 days
+ // Add a mock performance event that happens 2 days ago. This can happen in the browser
+ // when a tab has sat idle for a long period and user comes back to it.
+ //
+ // We pass a negative start time as it's a bit difficult to mock
+ // `@sentry/utils/browserPerformanceTimeOrigin`. This would not happen in
+ // real world.
+ replay.performanceEvents.push(
+ PerformanceEntryResource({
+ startTime: -ELAPSED,
+ }),
+ );
+
+ // This should be null because `addEvent` has not been called yet
+ // @ts-ignore private member
+ expect(replay.context.earliestEvent).toBe(null);
+ expect(mockTransportSend).toHaveBeenCalledTimes(0);
+
+ // A new checkout occurs (i.e. a new session was started)
+ const TEST_EVENT = {
+ data: {},
+ timestamp: BASE_TIMESTAMP,
+ type: 2,
+ };
+
+ replay.addEvent(TEST_EVENT);
+ // This event will trigger a flush
+ window.dispatchEvent(new Event('blur'));
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(mockTransportSend).toHaveBeenCalledTimes(1);
+ expect(replay).toHaveSentReplay({
+ replayEventPayload: expect.objectContaining({
+ // Make sure the old performance event is thrown out
+ replay_start_timestamp: BASE_TIMESTAMP / 1000,
+ }),
+ events: JSON.stringify([
+ TEST_EVENT,
+ {
+ type: 5,
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: BASE_TIMESTAMP / 1000,
+ type: 'default',
+ category: 'ui.blur',
+ },
+ },
+ },
+ ]),
+ });
+
+ // This gets reset after sending replay
+ // @ts-ignore private member
+ expect(replay.context.earliestEvent).toBe(null);
+ });
+
+ it('has single flush when checkout flush and debounce flush happen near simultaneously', async () => {
+ // click happens first
+ domHandler({
+ name: 'click',
+ });
+
+ // checkout
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
+ mockRecord._emitter(TEST_EVENT);
+
+ await advanceTimers(5000);
+ expect(replay.flush).toHaveBeenCalledTimes(1);
+
+ // Make sure there's nothing queued up after
+ await advanceTimers(5000);
+ expect(replay.flush).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/replay/test/unit/multiple-instances.test.ts b/packages/replay/test/unit/multiple-instances.test.ts
new file mode 100644
index 000000000000..9ae622605590
--- /dev/null
+++ b/packages/replay/test/unit/multiple-instances.test.ts
@@ -0,0 +1,8 @@
+import { Replay } from './../../src';
+
+it('throws on creating multiple instances', function () {
+ expect(() => {
+ new Replay();
+ new Replay();
+ }).toThrow();
+});
diff --git a/packages/replay/test/unit/session/Session.test.ts b/packages/replay/test/unit/session/Session.test.ts
new file mode 100644
index 000000000000..c10a14809856
--- /dev/null
+++ b/packages/replay/test/unit/session/Session.test.ts
@@ -0,0 +1,109 @@
+jest.mock('./../../../src/session/saveSession');
+
+import * as Sentry from '@sentry/browser';
+
+import { saveSession } from '../../../src/session/saveSession';
+import { Session } from '../../../src/session/Session';
+
+type CaptureEventMockType = jest.MockedFunction;
+
+jest.mock('@sentry/browser');
+
+jest.mock('@sentry/utils', () => {
+ return {
+ ...(jest.requireActual('@sentry/utils') as { string: unknown }),
+ uuid4: jest.fn(() => 'test_session_id'),
+ };
+});
+
+beforeEach(() => {
+ window.sessionStorage.clear();
+});
+
+afterEach(() => {
+ (Sentry.getCurrentHub().captureEvent as CaptureEventMockType).mockReset();
+});
+
+it('non-sticky Session does not save to local storage', function () {
+ const newSession = new Session(undefined, {
+ stickySession: false,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+ });
+
+ expect(saveSession).not.toHaveBeenCalled();
+ expect(newSession.id).toBe('test_session_id');
+ expect(newSession.segmentId).toBe(0);
+
+ newSession.segmentId++;
+ expect(saveSession).not.toHaveBeenCalled();
+ expect(newSession.segmentId).toBe(1);
+});
+
+it('sticky Session saves to local storage', function () {
+ const newSession = new Session(undefined, {
+ stickySession: true,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+ });
+
+ expect(saveSession).toHaveBeenCalledTimes(0);
+ expect(newSession.id).toBe('test_session_id');
+ expect(newSession.segmentId).toBe(0);
+
+ (saveSession as jest.Mock).mockClear();
+
+ newSession.segmentId++;
+ expect(saveSession).toHaveBeenCalledTimes(1);
+ expect(saveSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ segmentId: 1,
+ }),
+ );
+ expect(newSession.segmentId).toBe(1);
+});
+
+it('does not sample', function () {
+ const newSession = new Session(undefined, {
+ stickySession: true,
+ sessionSampleRate: 0.0,
+ errorSampleRate: 0.0,
+ });
+
+ expect(newSession.sampled).toBe(false);
+});
+
+it('samples using `sessionSampleRate`', function () {
+ const newSession = new Session(undefined, {
+ stickySession: true,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0.0,
+ });
+
+ expect(newSession.sampled).toBe('session');
+});
+
+it('samples using `errorSampleRate`', function () {
+ const newSession = new Session(undefined, {
+ stickySession: true,
+ sessionSampleRate: 0,
+ errorSampleRate: 1.0,
+ });
+
+ expect(newSession.sampled).toBe('error');
+});
+
+it('does not run sampling function if existing session was sampled', function () {
+ const newSession = new Session(
+ {
+ sampled: 'session',
+ },
+ {
+ stickySession: true,
+ sessionSampleRate: 0,
+ errorSampleRate: 0,
+ },
+ );
+
+ expect(newSession.sampled).toBe('session');
+});
diff --git a/packages/replay/test/unit/session/createSession.test.ts b/packages/replay/test/unit/session/createSession.test.ts
new file mode 100644
index 000000000000..ed281f5bc75a
--- /dev/null
+++ b/packages/replay/test/unit/session/createSession.test.ts
@@ -0,0 +1,66 @@
+import * as Sentry from '@sentry/core';
+
+import { createSession } from '../../../src/session/createSession';
+import { saveSession } from '../../../src/session/saveSession';
+
+jest.mock('./../../../src/session/saveSession');
+
+jest.mock('@sentry/utils', () => {
+ return {
+ ...(jest.requireActual('@sentry/utils') as { string: unknown }),
+ uuid4: jest.fn(() => 'test_session_id'),
+ };
+});
+
+type CaptureEventMockType = jest.MockedFunction;
+
+const captureEventMock: CaptureEventMockType = jest.fn();
+
+beforeAll(() => {
+ window.sessionStorage.clear();
+ jest.spyOn(Sentry, 'getCurrentHub');
+ (Sentry.getCurrentHub as jest.Mock).mockImplementation(() => ({
+ captureEvent: captureEventMock,
+ }));
+});
+
+afterEach(() => {
+ captureEventMock.mockReset();
+});
+
+it('creates a new session with no sticky sessions', function () {
+ const newSession = createSession({
+ stickySession: false,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+ });
+ expect(captureEventMock).not.toHaveBeenCalled();
+
+ expect(saveSession).not.toHaveBeenCalled();
+
+ expect(newSession.id).toBe('test_session_id');
+ expect(newSession.started).toBeGreaterThan(0);
+ expect(newSession.lastActivity).toEqual(newSession.started);
+});
+
+it('creates a new session with sticky sessions', function () {
+ const newSession = createSession({
+ stickySession: true,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+ });
+ expect(captureEventMock).not.toHaveBeenCalled();
+
+ expect(saveSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'test_session_id',
+ segmentId: 0,
+ started: expect.any(Number),
+ lastActivity: expect.any(Number),
+ }),
+ );
+
+ expect(newSession.id).toBe('test_session_id');
+ expect(newSession.started).toBeGreaterThan(0);
+ expect(newSession.lastActivity).toEqual(newSession.started);
+});
diff --git a/packages/replay/test/unit/session/deleteSession.test.ts b/packages/replay/test/unit/session/deleteSession.test.ts
new file mode 100644
index 000000000000..61aed26de53e
--- /dev/null
+++ b/packages/replay/test/unit/session/deleteSession.test.ts
@@ -0,0 +1,19 @@
+import { REPLAY_SESSION_KEY } from '../../../src/session/constants';
+import { deleteSession } from '../../../src/session/deleteSession';
+
+const storageEngine = window.sessionStorage;
+
+it('deletes a session', function () {
+ storageEngine.setItem(
+ REPLAY_SESSION_KEY,
+ '{"id":"fd09adfc4117477abc8de643e5a5798a","started":1648827162630,"lastActivity":1648827162658}',
+ );
+
+ deleteSession();
+
+ expect(storageEngine.getItem(REPLAY_SESSION_KEY)).toBe(null);
+});
+
+it('deletes an empty session', function () {
+ expect(() => deleteSession()).not.toThrow();
+});
diff --git a/packages/replay/test/unit/session/fetchSession.test.ts b/packages/replay/test/unit/session/fetchSession.test.ts
new file mode 100644
index 000000000000..54889ae114d4
--- /dev/null
+++ b/packages/replay/test/unit/session/fetchSession.test.ts
@@ -0,0 +1,59 @@
+import { REPLAY_SESSION_KEY } from '../../../src/session/constants';
+import { fetchSession } from '../../../src/session/fetchSession';
+
+const oldSessionStorage = window.sessionStorage;
+
+beforeAll(() => {
+ window.sessionStorage.clear();
+});
+
+afterEach(() => {
+ Object.defineProperty(window, 'sessionStorage', {
+ writable: true,
+ value: oldSessionStorage,
+ });
+ window.sessionStorage.clear();
+});
+
+const SAMPLE_RATES = {
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0.0,
+};
+
+it('fetches a valid and sampled session', function () {
+ window.sessionStorage.setItem(
+ REPLAY_SESSION_KEY,
+ '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": true,"started":1648827162630,"lastActivity":1648827162658}',
+ );
+
+ expect(fetchSession(SAMPLE_RATES)?.toJSON()).toEqual({
+ id: 'fd09adfc4117477abc8de643e5a5798a',
+ lastActivity: 1648827162658,
+ segmentId: 0,
+ sampled: true,
+ started: 1648827162630,
+ });
+});
+
+it('fetches a session that does not exist', function () {
+ expect(fetchSession(SAMPLE_RATES)).toBe(null);
+});
+
+it('fetches an invalid session', function () {
+ window.sessionStorage.setItem(REPLAY_SESSION_KEY, '{"id":"fd09adfc4117477abc8de643e5a5798a",');
+
+ expect(fetchSession(SAMPLE_RATES)).toBe(null);
+});
+
+it('safely attempts to fetch session when Session Storage is disabled', function () {
+ Object.defineProperty(window, 'sessionStorage', {
+ writable: true,
+ value: {
+ getItem: () => {
+ throw new Error('No Session Storage for you');
+ },
+ },
+ });
+
+ expect(fetchSession(SAMPLE_RATES)).toEqual(null);
+});
diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts
new file mode 100644
index 000000000000..7f39184c3719
--- /dev/null
+++ b/packages/replay/test/unit/session/getSession.test.ts
@@ -0,0 +1,198 @@
+import * as CreateSession from '../../../src/session/createSession';
+import * as FetchSession from '../../../src/session/fetchSession';
+import { getSession } from '../../../src/session/getSession';
+import { saveSession } from '../../../src/session/saveSession';
+import { Session } from '../../../src/session/Session';
+
+jest.mock('@sentry/utils', () => {
+ return {
+ ...(jest.requireActual('@sentry/utils') as { string: unknown }),
+ uuid4: jest.fn(() => 'test_session_id'),
+ };
+});
+
+const SAMPLE_RATES = {
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0,
+};
+
+function createMockSession(when: number = new Date().getTime()) {
+ return new Session(
+ {
+ id: 'test_session_id',
+ segmentId: 0,
+ lastActivity: when,
+ started: when,
+ sampled: 'session',
+ },
+ { stickySession: false, ...SAMPLE_RATES },
+ );
+}
+
+beforeAll(() => {
+ jest.spyOn(CreateSession, 'createSession');
+ jest.spyOn(FetchSession, 'fetchSession');
+ window.sessionStorage.clear();
+});
+
+afterEach(() => {
+ window.sessionStorage.clear();
+ (CreateSession.createSession as jest.MockedFunction).mockClear();
+ (FetchSession.fetchSession as jest.MockedFunction).mockClear();
+});
+
+it('creates a non-sticky session when one does not exist', function () {
+ const { session } = getSession({
+ expiry: 900000,
+ stickySession: false,
+ ...SAMPLE_RATES,
+ });
+
+ expect(FetchSession.fetchSession).not.toHaveBeenCalled();
+ expect(CreateSession.createSession).toHaveBeenCalled();
+
+ expect(session.toJSON()).toEqual({
+ id: 'test_session_id',
+ segmentId: 0,
+ lastActivity: expect.any(Number),
+ sampled: 'session',
+ started: expect.any(Number),
+ });
+
+ // Should not have anything in storage
+ expect(FetchSession.fetchSession(SAMPLE_RATES)).toBe(null);
+});
+
+it('creates a non-sticky session, regardless of session existing in sessionStorage', function () {
+ saveSession(createMockSession(new Date().getTime() - 10000));
+
+ const { session } = getSession({
+ expiry: 1000,
+ stickySession: false,
+ ...SAMPLE_RATES,
+ });
+
+ expect(FetchSession.fetchSession).not.toHaveBeenCalled();
+ expect(CreateSession.createSession).toHaveBeenCalled();
+
+ expect(session).toBeDefined();
+});
+
+it('creates a non-sticky session, when one is expired', function () {
+ const { session } = getSession({
+ expiry: 1000,
+ stickySession: false,
+ ...SAMPLE_RATES,
+ currentSession: new Session(
+ {
+ id: 'old_session_id',
+ lastActivity: new Date().getTime() - 1001,
+ started: new Date().getTime() - 1001,
+ segmentId: 0,
+ },
+ { stickySession: false, ...SAMPLE_RATES },
+ ),
+ });
+
+ expect(FetchSession.fetchSession).not.toHaveBeenCalled();
+ expect(CreateSession.createSession).toHaveBeenCalled();
+
+ expect(session).toBeDefined();
+ expect(session.id).not.toBe('old_session_id');
+});
+
+it('creates a sticky session when one does not exist', function () {
+ expect(FetchSession.fetchSession(SAMPLE_RATES)).toBe(null);
+
+ const { session } = getSession({
+ expiry: 900000,
+ stickySession: true,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0.0,
+ });
+
+ expect(FetchSession.fetchSession).toHaveBeenCalled();
+ expect(CreateSession.createSession).toHaveBeenCalled();
+
+ expect(session.toJSON()).toEqual({
+ id: 'test_session_id',
+ segmentId: 0,
+ lastActivity: expect.any(Number),
+ sampled: 'session',
+ started: expect.any(Number),
+ });
+
+ // Should not have anything in storage
+ expect(FetchSession.fetchSession(SAMPLE_RATES)?.toJSON()).toEqual({
+ id: 'test_session_id',
+ segmentId: 0,
+ lastActivity: expect.any(Number),
+ sampled: 'session',
+ started: expect.any(Number),
+ });
+});
+
+it('fetches an existing sticky session', function () {
+ const now = new Date().getTime();
+ saveSession(createMockSession(now));
+
+ const { session } = getSession({
+ expiry: 1000,
+ stickySession: true,
+ sessionSampleRate: 1.0,
+ errorSampleRate: 0.0,
+ });
+
+ expect(FetchSession.fetchSession).toHaveBeenCalled();
+ expect(CreateSession.createSession).not.toHaveBeenCalled();
+
+ expect(session.toJSON()).toEqual({
+ id: 'test_session_id',
+ segmentId: 0,
+ lastActivity: now,
+ sampled: 'session',
+ started: now,
+ });
+});
+
+it('fetches an expired sticky session', function () {
+ const now = new Date().getTime();
+ saveSession(createMockSession(new Date().getTime() - 2000));
+
+ const { session } = getSession({
+ expiry: 1000,
+ stickySession: true,
+ ...SAMPLE_RATES,
+ });
+
+ expect(FetchSession.fetchSession).toHaveBeenCalled();
+ expect(CreateSession.createSession).toHaveBeenCalled();
+
+ expect(session.id).toBe('test_session_id');
+ expect(session.lastActivity).toBeGreaterThanOrEqual(now);
+ expect(session.started).toBeGreaterThanOrEqual(now);
+ expect(session.segmentId).toBe(0);
+});
+
+it('fetches a non-expired non-sticky session', function () {
+ const { session } = getSession({
+ expiry: 1000,
+ stickySession: false,
+ ...SAMPLE_RATES,
+ currentSession: new Session(
+ {
+ id: 'test_session_id_2',
+ lastActivity: +new Date() - 500,
+ started: +new Date() - 500,
+ segmentId: 0,
+ },
+ { stickySession: false, ...SAMPLE_RATES },
+ ),
+ });
+
+ expect(FetchSession.fetchSession).not.toHaveBeenCalled();
+ expect(CreateSession.createSession).not.toHaveBeenCalled();
+
+ expect(session.id).toBe('test_session_id_2');
+ expect(session.segmentId).toBe(0);
+});
diff --git a/packages/replay/test/unit/session/saveSession.test.ts b/packages/replay/test/unit/session/saveSession.test.ts
new file mode 100644
index 000000000000..65f3ed270db8
--- /dev/null
+++ b/packages/replay/test/unit/session/saveSession.test.ts
@@ -0,0 +1,27 @@
+import { REPLAY_SESSION_KEY } from '../../../src/session/constants';
+import { saveSession } from '../../../src/session/saveSession';
+import { Session } from '../../../src/session/Session';
+
+beforeAll(() => {
+ window.sessionStorage.clear();
+});
+
+afterEach(() => {
+ window.sessionStorage.clear();
+});
+
+it('saves a valid session', function () {
+ const session = new Session(
+ {
+ id: 'fd09adfc4117477abc8de643e5a5798a',
+ segmentId: 0,
+ started: 1648827162630,
+ lastActivity: 1648827162658,
+ sampled: 'session',
+ },
+ { stickySession: true, sessionSampleRate: 1.0, errorSampleRate: 0 },
+ );
+ saveSession(session);
+
+ expect(window.sessionStorage.getItem(REPLAY_SESSION_KEY)).toEqual(JSON.stringify(session));
+});
diff --git a/packages/replay/test/unit/stop.test.ts b/packages/replay/test/unit/stop.test.ts
new file mode 100644
index 000000000000..ade2c159d96c
--- /dev/null
+++ b/packages/replay/test/unit/stop.test.ts
@@ -0,0 +1,152 @@
+import * as SentryUtils from '@sentry/utils';
+// mock functions need to be imported first
+import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '@test';
+
+import { Replay } from './../../src';
+import { SESSION_IDLE_DURATION } from './../../src/session/constants';
+import { useFakeTimers } from './../utils/use-fake-timers';
+
+useFakeTimers();
+
+describe('Replay - stop', () => {
+ let replay: Replay;
+ const prevLocation = window.location;
+
+ type MockAddInstrumentationHandler = jest.MockedFunction;
+ const { record: mockRecord } = mockRrweb();
+
+ let mockAddInstrumentationHandler: MockAddInstrumentationHandler;
+
+ beforeAll(async () => {
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ mockAddInstrumentationHandler = jest.spyOn(
+ SentryUtils,
+ 'addInstrumentationHandler',
+ ) as MockAddInstrumentationHandler;
+
+ ({ replay } = await mockSdk());
+ jest.runAllTimers();
+ });
+
+ beforeEach(() => {
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ replay.eventBuffer?.destroy();
+ jest.clearAllMocks();
+ });
+
+ afterEach(async () => {
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ jest.setSystemTime(new Date(BASE_TIMESTAMP));
+ sessionStorage.clear();
+ replay.clearSession();
+ replay.loadSession({ expiry: SESSION_IDLE_DURATION });
+ mockRecord.takeFullSnapshot.mockClear();
+ mockAddInstrumentationHandler.mockClear();
+ // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790)
+ delete window.location;
+ Object.defineProperty(window, 'location', {
+ value: prevLocation,
+ writable: true,
+ });
+ });
+
+ afterAll(() => {
+ replay && replay.stop();
+ });
+
+ it('does not upload replay if it was stopped and can resume replays afterwards', async () => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+ const ELAPSED = 5000;
+ // Not sure where the 20ms comes from tbh
+ const EXTRA_TICKS = 20;
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
+
+ // stop replays
+ replay.stop();
+
+ // Pretend 5 seconds have passed
+ jest.advanceTimersByTime(ELAPSED);
+
+ replay.addEvent(TEST_EVENT);
+ window.dispatchEvent(new Event('blur'));
+ await new Promise(process.nextTick);
+ expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay).not.toHaveSentReplay();
+ // Session's last activity should not be updated
+ expect(replay.session?.lastActivity).toEqual(BASE_TIMESTAMP);
+ // eventBuffer is destroyed
+ expect(replay.eventBuffer).toBe(null);
+
+ // re-enable replay
+ replay.start();
+
+ jest.advanceTimersByTime(ELAPSED);
+
+ const timestamp = +new Date(BASE_TIMESTAMP + ELAPSED + ELAPSED + EXTRA_TICKS) / 1000;
+
+ const hiddenBreadcrumb = {
+ type: 5,
+ timestamp,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp,
+ type: 'default',
+ category: 'ui.blur',
+ },
+ },
+ };
+
+ replay.addEvent(TEST_EVENT);
+ window.dispatchEvent(new Event('blur'));
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+ expect(replay).toHaveSentReplay({
+ events: JSON.stringify([
+ // This event happens when we call `replay.start`
+ {
+ data: { isCheckout: true },
+ timestamp: BASE_TIMESTAMP + ELAPSED + EXTRA_TICKS,
+ type: 2,
+ },
+ TEST_EVENT,
+ hiddenBreadcrumb,
+ ]),
+ });
+ // Session's last activity is last updated when we call `setup()` and *NOT*
+ // when tab is blurred
+ expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED + 20);
+ });
+
+ it('does not buffer events when stopped', async function () {
+ window.dispatchEvent(new Event('blur'));
+ expect(replay.eventBuffer?.length).toBe(1);
+
+ // stop replays
+ replay.stop();
+
+ expect(replay.eventBuffer?.length).toBe(undefined);
+
+ window.dispatchEvent(new Event('blur'));
+ await new Promise(process.nextTick);
+
+ expect(replay.eventBuffer?.length).toBe(undefined);
+ expect(replay).not.toHaveSentReplay();
+ });
+
+ it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () {
+ // NOTE: We clear addInstrumentationHandler mock after every test
+ replay.stop();
+ replay.start();
+ replay.stop();
+ replay.start();
+
+ expect(mockAddInstrumentationHandler).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts b/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts
new file mode 100644
index 000000000000..9a7387f7960d
--- /dev/null
+++ b/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts
@@ -0,0 +1,67 @@
+// eslint-disable-next-line import/no-unresolved
+import { PerformanceEntryLcp } from '@test/fixtures/performanceEntry/lcp';
+import { PerformanceEntryNavigation } from '@test/fixtures/performanceEntry/navigation';
+import { PerformanceEntryResource } from '@test/fixtures/performanceEntry/resource';
+
+import { dedupePerformanceEntries } from '../../../src/util/dedupePerformanceEntries';
+
+it('does nothing with a single entry', function () {
+ const entries = [PerformanceEntryNavigation()];
+ expect(dedupePerformanceEntries([], entries)).toEqual(entries);
+});
+
+it('dedupes 2 duplicate entries correctly', function () {
+ const entries = [PerformanceEntryNavigation(), PerformanceEntryNavigation()];
+ expect(dedupePerformanceEntries([], entries)).toEqual([entries[0]]);
+});
+
+it('dedupes multiple+mixed entries from new list', function () {
+ const a = PerformanceEntryNavigation({ startTime: 0 });
+ const b = PerformanceEntryNavigation({
+ startTime: 1,
+ name: 'https://foo.bar/',
+ });
+ const c = PerformanceEntryNavigation({ startTime: 2, type: 'reload' });
+ const d = PerformanceEntryResource({ startTime: 1.5 });
+ const entries = [a, a, b, d, b, c];
+ expect(dedupePerformanceEntries([], entries)).toEqual([a, b, d, c]);
+});
+
+it('dedupes from initial list and new list', function () {
+ const a = PerformanceEntryNavigation({ startTime: 0 });
+ const b = PerformanceEntryNavigation({
+ startTime: 1,
+ name: 'https://foo.bar/',
+ });
+ const c = PerformanceEntryNavigation({ startTime: 2, type: 'reload' });
+ const d = PerformanceEntryNavigation({ startTime: 1000 });
+ const entries = [a, a, b, b, c];
+ expect(dedupePerformanceEntries([a, d], entries)).toEqual([a, b, c, d]);
+});
+
+it('selects the latest lcp value given multiple lcps in new list', function () {
+ const a = PerformanceEntryResource({ startTime: 0 });
+ const b = PerformanceEntryLcp({ startTime: 100, name: 'b' });
+ const c = PerformanceEntryLcp({ startTime: 200, name: 'c' });
+ const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered
+ const entries = [a, b, c, d];
+ expect(dedupePerformanceEntries([], entries)).toEqual([a, c]);
+});
+
+it('selects the latest lcp value from new list, given multiple lcps in new list with an existing lcp', function () {
+ const a = PerformanceEntryResource({ startTime: 0 });
+ const b = PerformanceEntryLcp({ startTime: 100, name: 'b' });
+ const c = PerformanceEntryLcp({ startTime: 200, name: 'c' });
+ const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered
+ const entries = [b, c, d];
+ expect(dedupePerformanceEntries([a, d], entries)).toEqual([a, c]);
+});
+
+it('selects the existing lcp value given multiple lcps in new list with an existing lcp having the latest startTime', function () {
+ const a = PerformanceEntryResource({ startTime: 0 });
+ const b = PerformanceEntryLcp({ startTime: 100, name: 'b' });
+ const c = PerformanceEntryLcp({ startTime: 200, name: 'c' });
+ const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered
+ const entries = [b, d];
+ expect(dedupePerformanceEntries([a, c], entries)).toEqual([a, c]);
+});
diff --git a/packages/replay/test/unit/util/isExpired.test.ts b/packages/replay/test/unit/util/isExpired.test.ts
new file mode 100644
index 000000000000..a83677cf856e
--- /dev/null
+++ b/packages/replay/test/unit/util/isExpired.test.ts
@@ -0,0 +1,23 @@
+import { isExpired } from '../../../src/util/isExpired';
+
+it('is expired', function () {
+ expect(isExpired(0, 150, 200)).toBe(true); // expired at ts = 150
+});
+
+it('is not expired', function () {
+ expect(isExpired(100, 150, 200)).toBe(false); // expires at ts >= 250
+});
+
+it('is expired when target time reaches exactly the expiry time', function () {
+ expect(isExpired(100, 150, 250)).toBe(true); // expires at ts >= 250
+});
+
+it('never expires if expiry is 0', function () {
+ expect(isExpired(300, 0, 200)).toBe(false);
+ expect(isExpired(0, 0, 200)).toBe(false);
+});
+
+it('always expires if expiry is < 0', function () {
+ expect(isExpired(300, -1, 200)).toBe(true);
+ expect(isExpired(0, -1, 200)).toBe(true);
+});
diff --git a/packages/replay/test/unit/util/isSampled.test.ts b/packages/replay/test/unit/util/isSampled.test.ts
new file mode 100644
index 000000000000..8569f01c226c
--- /dev/null
+++ b/packages/replay/test/unit/util/isSampled.test.ts
@@ -0,0 +1,28 @@
+import { isSampled } from '../../../src/util/isSampled';
+
+// Note Math.random generates a value from 0 (inclusive) to <1 (1 exclusive).
+const cases = [
+ [1.0, 0.9999, true],
+ [1.0, 0.0, true],
+ [1.0, 0.5, true],
+ [0.0, 0.9999, false],
+ [0.0, 0.0, false],
+ [0.0, 0.5, false],
+ [0.5, 0.9999, false],
+ [0.5, 0.5, false],
+ [0.5, 0.0, true],
+];
+
+jest.spyOn(Math, 'random');
+const mockRandom = Math.random as jest.MockedFunction;
+
+describe('isSampled', () => {
+ test.each(cases)(
+ 'given sample rate of %p and RNG returns %p, result should be %p',
+ (sampleRate: number, mockRandomValue: number, expectedResult: boolean) => {
+ mockRandom.mockImplementationOnce(() => mockRandomValue);
+ const result = isSampled(sampleRate);
+ expect(result).toEqual(expectedResult);
+ },
+ );
+});
diff --git a/packages/replay/test/unit/util/isSessionExpired.test.ts b/packages/replay/test/unit/util/isSessionExpired.test.ts
new file mode 100644
index 000000000000..ff39ae7f9496
--- /dev/null
+++ b/packages/replay/test/unit/util/isSessionExpired.test.ts
@@ -0,0 +1,30 @@
+import { Session } from '../../../src/session/Session';
+import { isSessionExpired } from '../../../src/util/isSessionExpired';
+
+function createSession(extra?: Record) {
+ return new Session(
+ {
+ started: 0,
+ lastActivity: 0,
+ segmentId: 0,
+ ...extra,
+ },
+ { stickySession: false, sessionSampleRate: 1.0, errorSampleRate: 0 },
+ );
+}
+
+it('session last activity is older than expiry time', function () {
+ expect(isSessionExpired(createSession(), 100, 200)).toBe(true); // Session expired at ts = 100
+});
+
+it('session last activity is not older than expiry time', function () {
+ expect(isSessionExpired(createSession({ lastActivity: 100 }), 150, 200)).toBe(false); // Session expires at ts >= 250
+});
+
+it('session age is not older than max session life', function () {
+ expect(isSessionExpired(createSession(), 1_800_000, 50_000)).toBe(false);
+});
+
+it('session age is older than max session life', function () {
+ expect(isSessionExpired(createSession(), 1_800_000, 1_800_000)).toBe(true); // Session expires at ts >= 1_800_000
+});
diff --git a/packages/replay/test/unit/worker/Compressor.test.ts b/packages/replay/test/unit/worker/Compressor.test.ts
new file mode 100644
index 000000000000..77b95a07439b
--- /dev/null
+++ b/packages/replay/test/unit/worker/Compressor.test.ts
@@ -0,0 +1,53 @@
+import pako from 'pako';
+
+import { Compressor } from '../../../worker/src/Compressor';
+
+describe('Compressor', () => {
+ it('compresses multiple events', () => {
+ const compressor = new Compressor();
+
+ const events = [
+ {
+ id: 1,
+ foo: ['bar', 'baz'],
+ },
+ {
+ id: 2,
+ foo: [false],
+ },
+ ];
+
+ events.forEach(event => compressor.addEvent(event));
+
+ const compressed = compressor.finish();
+
+ const restored = pako.inflate(compressed, { to: 'string' });
+
+ expect(restored).toBe(JSON.stringify(events));
+ });
+
+ it('ignores undefined events', () => {
+ const compressor = new Compressor();
+
+ const events = [
+ {
+ id: 1,
+ foo: ['bar', 'baz'],
+ },
+ undefined,
+ {
+ id: 2,
+ foo: [false],
+ },
+ ] as Record[];
+
+ events.forEach(event => compressor.addEvent(event));
+
+ const compressed = compressor.finish();
+
+ const restored = pako.inflate(compressed, { to: 'string' });
+
+ const expected = [events[0], events[2]];
+ expect(restored).toBe(JSON.stringify(expected));
+ });
+});
diff --git a/packages/replay/test/utils/use-fake-timers.ts b/packages/replay/test/utils/use-fake-timers.ts
new file mode 100644
index 000000000000..a3293efb0b77
--- /dev/null
+++ b/packages/replay/test/utils/use-fake-timers.ts
@@ -0,0 +1,14 @@
+export function useFakeTimers() {
+ const _setInterval = setInterval;
+ const _clearInterval = clearInterval;
+ jest.useFakeTimers();
+
+ let interval: any;
+ beforeAll(function () {
+ interval = _setInterval(() => jest.advanceTimersByTime(20), 20);
+ });
+
+ afterAll(function () {
+ _clearInterval(interval);
+ });
+}
diff --git a/packages/replay/tsconfig.json b/packages/replay/tsconfig.json
new file mode 100644
index 000000000000..497751e1df04
--- /dev/null
+++ b/packages/replay/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "./config/tsconfig.core.json",
+ "compilerOptions": {
+ "paths": {
+ "@test": ["./test"],
+ "@test/*":
+ ["./test/*"]
+ },
+ "types": ["node", "jest"]
+ },
+ "include": [
+ "src/**/*.ts",
+ "test/**/*.ts",
+ "rollup.config.ts",
+ "jest.config.ts",
+ "jest.setup.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/replay/worker/src/Compressor.ts b/packages/replay/worker/src/Compressor.ts
new file mode 100644
index 000000000000..754209e5aab5
--- /dev/null
+++ b/packages/replay/worker/src/Compressor.ts
@@ -0,0 +1,60 @@
+import { constants, Deflate } from 'pako';
+
+export class Compressor {
+ /**
+ * pako deflator instance
+ */
+ public deflate: Deflate;
+
+ /**
+ * Number of added events
+ */
+ public added: number;
+
+ public constructor() {
+ this.init();
+ }
+
+ public init(): void {
+ this.added = 0;
+ this.deflate = new Deflate();
+
+ // Fake an array by adding a `[`
+ this.deflate.push('[', constants.Z_NO_FLUSH);
+
+ return;
+ }
+
+ public addEvent(data: Record): void {
+ if (!data) {
+ return;
+ }
+ // If the event is not the first event, we need to prefix it with a `,` so
+ // that we end up with a list of events
+ const prefix = this.added > 0 ? ',' : '';
+ // TODO: We may want Z_SYNC_FLUSH or Z_FULL_FLUSH (not sure the difference)
+ // Using NO_FLUSH here for now as we can create many attachments that our
+ // web UI will get API rate limited.
+ this.deflate.push(prefix + JSON.stringify(data), constants.Z_NO_FLUSH);
+ this.added++;
+
+ return;
+ }
+
+ public finish(): Uint8Array {
+ // We should always have a list, it can be empty
+ this.deflate.push(']', constants.Z_FINISH);
+
+ if (this.deflate.err) {
+ throw this.deflate.err;
+ }
+
+ // Copy result before we create a new deflator and return the compressed
+ // result
+ const result = this.deflate.result;
+
+ this.init();
+
+ return result;
+ }
+}
diff --git a/packages/replay/worker/src/handleMessage.ts b/packages/replay/worker/src/handleMessage.ts
new file mode 100644
index 000000000000..5e4dc4f757c1
--- /dev/null
+++ b/packages/replay/worker/src/handleMessage.ts
@@ -0,0 +1,48 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+import { Compressor } from './Compressor';
+
+const compressor = new Compressor();
+
+const handlers: Record void> = {
+ init: () => {
+ compressor.init();
+ return '';
+ },
+
+ addEvent: (data: Record) => {
+ compressor.addEvent(data);
+ return '';
+ },
+
+ finish: () => {
+ return compressor.finish();
+ },
+};
+
+export function handleMessage(e: MessageEvent): void {
+ const method = e.data.method as string;
+ const id = e.data.id as number;
+ const [data] = e.data.args ? JSON.parse(e.data.args) : [];
+
+ if (method in handlers && typeof handlers[method] === 'function') {
+ try {
+ const response = handlers[method](data);
+ // @ts-ignore this syntax is actually fine
+ postMessage({
+ id,
+ method,
+ success: true,
+ response,
+ });
+ } catch (err) {
+ // @ts-ignore this syntax is actually fine
+ postMessage({
+ id,
+ method,
+ success: false,
+ response: err,
+ });
+ console.error(err);
+ }
+ }
+}
diff --git a/packages/replay/worker/src/worker.ts b/packages/replay/worker/src/worker.ts
new file mode 100644
index 000000000000..751c9a431f19
--- /dev/null
+++ b/packages/replay/worker/src/worker.ts
@@ -0,0 +1,3 @@
+import { handleMessage } from './handleMessage';
+
+addEventListener('message', handleMessage);
diff --git a/packages/replay/workflows/build.yml b/packages/replay/workflows/build.yml
new file mode 100644
index 000000000000..4793b6d2959c
--- /dev/null
+++ b/packages/replay/workflows/build.yml
@@ -0,0 +1,35 @@
+name: build
+on:
+ push:
+ branches:
+ - main
+ - release/**
+ pull_request:
+
+jobs:
+ build:
+ name: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - uses: volta-cli/action@v4
+
+ - name: Install dependencies
+ run: |
+ yarn install --frozen-lockfile
+
+ - run: |
+ yarn test
+
+ - run: |
+ yarn build
+
+ - run: |
+ yarn build:npm
+
+ - uses: actions/upload-artifact@v3.1.1
+ with:
+ name: ${{ github.sha }}
+ path: |
+ ${{ github.workspace }}/*.tgz
diff --git a/packages/replay/workflows/release.yml b/packages/replay/workflows/release.yml
new file mode 100644
index 000000000000..f706eb39c9c2
--- /dev/null
+++ b/packages/replay/workflows/release.yml
@@ -0,0 +1,29 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Version to release
+ required: true
+ force:
+ description: Force a release even when there are release-blockers (optional)
+ required: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ name: 'Release a new version'
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ token: ${{ secrets.GH_RELEASE_PAT }}
+ fetch-depth: 0
+
+ - name: Prepare release
+ uses: getsentry/action-prepare-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }}
+ with:
+ version: ${{ github.event.inputs.version }}
+ force: ${{ github.event.inputs.force }}
diff --git a/packages/replay/workflows/size.yml b/packages/replay/workflows/size.yml
new file mode 100644
index 000000000000..79679f7d7be4
--- /dev/null
+++ b/packages/replay/workflows/size.yml
@@ -0,0 +1,28 @@
+name: "size"
+on:
+ pull_request:
+ branches:
+ - main
+jobs:
+ size:
+ runs-on: ubuntu-latest
+ env:
+ CI_JOB_NUMBER: 1
+ steps:
+ - uses: actions/checkout@v3
+
+ - id: versions
+ run: |
+ echo "::set-output name=node::$(jq -r '.volta.node' package.json)"
+
+ - uses: actions/setup-node@v3
+ with:
+ node-version: ${{ steps.versions.outputs.node }}
+ cache: 'yarn'
+
+ - name: build (and run size-limit)
+ uses: andresz1/size-limit-action@v1
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ build_script: build:prod
+
diff --git a/scripts/test.ts b/scripts/test.ts
index 0ed419da8ea7..ddc5cde2489d 100644
--- a/scripts/test.ts
+++ b/scripts/test.ts
@@ -17,6 +17,7 @@ const NODE_8_SKIP_TESTS_PACKAGES = [
'@sentry/angular',
'@sentry/remix',
'@sentry/svelte', // svelte testing library requires Node >= 10
+ '@sentry/replay',
];
// We have to downgrade some of our dependencies in order to run tests in Node 8 and 10.
@@ -28,7 +29,7 @@ const NODE_8_LEGACY_DEPENDENCIES = [
'ts-jest@25.x',
];
-const NODE_10_SKIP_TESTS_PACKAGES = [...DEFAULT_SKIP_TESTS_PACKAGES, '@sentry/remix'];
+const NODE_10_SKIP_TESTS_PACKAGES = [...DEFAULT_SKIP_TESTS_PACKAGES, '@sentry/remix', '@sentry/replay'];
const NODE_10_LEGACY_DEPENDENCIES = ['jsdom@16.x'];
const NODE_12_SKIP_TESTS_PACKAGES = [...DEFAULT_SKIP_TESTS_PACKAGES, '@sentry/remix'];
diff --git a/yarn.lock b/yarn.lock
index 816420c60184..5f5d86776883 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -329,6 +329,11 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.4.tgz#95c86de137bf0317f3a570e1b6e996b427299747"
integrity sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw==
+"@babel/compat-data@^7.20.0":
+ version "7.20.1"
+ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.1.tgz#f2e6ef7790d8c8dbf03d379502dcc246dcce0b30"
+ integrity sha512-EWZ4mE2diW3QALKvDMiXnbZpRvlj+nayZ112nK93SnhqOtpdsbVD4W+2tEoT3YNBAG9RBR0ISY758ZkOgsn6pQ==
+
"@babel/core@7.11.1":
version "7.11.1"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643"
@@ -393,6 +398,27 @@
json5 "^2.2.1"
semver "^6.3.0"
+"@babel/core@^7.17.5":
+ version "7.20.2"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.2.tgz#8dc9b1620a673f92d3624bd926dc49a52cf25b92"
+ integrity sha512-w7DbG8DtMrJcFOi4VrLm+8QM4az8Mo+PuLBKLp2zrYRCow8W/f9xiXm5sN53C8HksCyDQwCKha9JiDoIyPjT2g==
+ dependencies:
+ "@ampproject/remapping" "^2.1.0"
+ "@babel/code-frame" "^7.18.6"
+ "@babel/generator" "^7.20.2"
+ "@babel/helper-compilation-targets" "^7.20.0"
+ "@babel/helper-module-transforms" "^7.20.2"
+ "@babel/helpers" "^7.20.1"
+ "@babel/parser" "^7.20.2"
+ "@babel/template" "^7.18.10"
+ "@babel/traverse" "^7.20.1"
+ "@babel/types" "^7.20.2"
+ convert-source-map "^1.7.0"
+ debug "^4.1.0"
+ gensync "^1.0.0-beta.2"
+ json5 "^2.2.1"
+ semver "^6.3.0"
+
"@babel/generator@7.11.0":
version "7.11.0"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.0.tgz#4b90c78d8c12825024568cbe83ee6c9af193585c"
@@ -420,6 +446,15 @@
"@jridgewell/gen-mapping" "^0.3.2"
jsesc "^2.5.1"
+"@babel/generator@^7.20.1", "@babel/generator@^7.20.2":
+ version "7.20.4"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.4.tgz#4d9f8f0c30be75fd90a0562099a26e5839602ab8"
+ integrity sha512-luCf7yk/cm7yab6CAW1aiFnmEfBJplb/JojV56MYEK7ziWfGmFlTfmL9Ehwfy4gFhbjBfWO1wj7/TuSbVNEEtA==
+ dependencies:
+ "@babel/types" "^7.20.2"
+ "@jridgewell/gen-mapping" "^0.3.2"
+ jsesc "^2.5.1"
+
"@babel/helper-annotate-as-pure@^7.16.0", "@babel/helper-annotate-as-pure@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz#bb2339a7534a9c128e3102024c60760a3a7f3862"
@@ -470,6 +505,16 @@
browserslist "^4.21.3"
semver "^6.3.0"
+"@babel/helper-compilation-targets@^7.20.0":
+ version "7.20.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz#6bf5374d424e1b3922822f1d9bdaa43b1a139d0a"
+ integrity sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==
+ dependencies:
+ "@babel/compat-data" "^7.20.0"
+ "@babel/helper-validator-option" "^7.18.6"
+ browserslist "^4.21.3"
+ semver "^6.3.0"
+
"@babel/helper-create-class-features-plugin@^7.13.0", "@babel/helper-create-class-features-plugin@^7.16.0", "@babel/helper-create-class-features-plugin@^7.16.10", "@babel/helper-create-class-features-plugin@^7.16.7", "@babel/helper-create-class-features-plugin@^7.5.5":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.9.tgz#71835d7fb9f38bd9f1378e40a4c0902fdc2ea49d"
@@ -650,6 +695,20 @@
"@babel/traverse" "^7.19.6"
"@babel/types" "^7.19.4"
+"@babel/helper-module-transforms@^7.20.2":
+ version "7.20.2"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz#ac53da669501edd37e658602a21ba14c08748712"
+ integrity sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==
+ dependencies:
+ "@babel/helper-environment-visitor" "^7.18.9"
+ "@babel/helper-module-imports" "^7.18.6"
+ "@babel/helper-simple-access" "^7.20.2"
+ "@babel/helper-split-export-declaration" "^7.18.6"
+ "@babel/helper-validator-identifier" "^7.19.1"
+ "@babel/template" "^7.18.10"
+ "@babel/traverse" "^7.20.1"
+ "@babel/types" "^7.20.2"
+
"@babel/helper-optimise-call-expression@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz#a34e3560605abbd31a18546bd2aad3e6d9a174f2"
@@ -729,6 +788,13 @@
dependencies:
"@babel/types" "^7.19.4"
+"@babel/helper-simple-access@^7.20.2":
+ version "7.20.2"
+ resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9"
+ integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==
+ dependencies:
+ "@babel/types" "^7.20.2"
+
"@babel/helper-skip-transparent-expression-wrappers@^7.12.1", "@babel/helper-skip-transparent-expression-wrappers@^7.16.0":
version "7.16.0"
resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz#0ee3388070147c3ae051e487eca3ebb0e2e8bb09"
@@ -820,6 +886,15 @@
"@babel/traverse" "^7.19.4"
"@babel/types" "^7.19.4"
+"@babel/helpers@^7.20.1":
+ version "7.20.1"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.20.1.tgz#2ab7a0fcb0a03b5bf76629196ed63c2d7311f4c9"
+ integrity sha512-J77mUVaDTUJFZ5BpP6mMn6OIl3rEWymk2ZxDBQJUG3P+PbmyMcF3bYWvz0ma69Af1oobDqT/iAsvzhB58xhQUg==
+ dependencies:
+ "@babel/template" "^7.18.10"
+ "@babel/traverse" "^7.20.1"
+ "@babel/types" "^7.20.0"
+
"@babel/highlight@^7.10.4", "@babel/highlight@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.7.tgz#81a01d7d675046f0d96f82450d9d9578bdfd6b0b"
@@ -843,7 +918,7 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.9.tgz#9c94189a6062f0291418ca021077983058e171ef"
integrity sha512-vqUSBLP8dQHFPdPi9bc5GK9vRkYHJ49fsZdtoJ8EQ8ibpwk5rPKfvNIwChB0KVXcIjcepEBBd2VHC5r9Gy8ueg==
-"@babel/parser@^7.16.4":
+"@babel/parser@^7.16.4", "@babel/parser@^7.20.1", "@babel/parser@^7.20.2":
version "7.20.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2"
integrity sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg==
@@ -2202,6 +2277,22 @@
debug "^4.1.0"
globals "^11.1.0"
+"@babel/traverse@^7.20.1":
+ version "7.20.1"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8"
+ integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA==
+ dependencies:
+ "@babel/code-frame" "^7.18.6"
+ "@babel/generator" "^7.20.1"
+ "@babel/helper-environment-visitor" "^7.18.9"
+ "@babel/helper-function-name" "^7.19.0"
+ "@babel/helper-hoist-variables" "^7.18.6"
+ "@babel/helper-split-export-declaration" "^7.18.6"
+ "@babel/parser" "^7.20.1"
+ "@babel/types" "^7.20.0"
+ debug "^4.1.0"
+ globals "^11.1.0"
+
"@babel/types@7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
@@ -2228,6 +2319,15 @@
"@babel/helper-validator-identifier" "^7.19.1"
to-fast-properties "^2.0.0"
+"@babel/types@^7.20.0", "@babel/types@^7.20.2":
+ version "7.20.2"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.2.tgz#67ac09266606190f496322dbaff360fdaa5e7842"
+ integrity sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog==
+ dependencies:
+ "@babel/helper-string-parser" "^7.19.4"
+ "@babel/helper-validator-identifier" "^7.19.1"
+ to-fast-properties "^2.0.0"
+
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -4272,6 +4372,14 @@
"@rollup/pluginutils" "^4.1.1"
sucrase "^3.20.0"
+"@rollup/plugin-typescript@^8.3.1":
+ version "8.5.0"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz#7ea11599a15b0a30fa7ea69ce3b791d41b862515"
+ integrity sha512-wMv1/scv0m/rXx21wD2IsBbJFba8wGF3ErJIr6IKRfRj49S85Lszbxb4DCo8iILpluTjk2GAAu9CoZt4G3ppgQ==
+ dependencies:
+ "@rollup/pluginutils" "^3.1.0"
+ resolve "^1.17.0"
+
"@rollup/plugin-virtual@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.0.tgz#8c3f54b4ab4b267d9cd3dcbaedc58d4fd1deddca"
@@ -4681,6 +4789,11 @@
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
+"@types/css-font-loading-module@0.0.7":
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz#2f98ede46acc0975de85c0b7b0ebe06041d24601"
+ integrity sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==
+
"@types/duplexify@^3.6.0":
version "3.6.0"
resolved "https://registry.yarnpkg.com/@types/duplexify/-/duplexify-3.6.0.tgz#dfc82b64bd3a2168f5bd26444af165bf0237dcd8"
@@ -5088,6 +5201,25 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
+"@types/lodash.debounce@^4.0.7":
+ version "4.0.7"
+ resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.7.tgz#0285879defb7cdb156ae633cecd62d5680eded9f"
+ integrity sha512-X1T4wMZ+gT000M2/91SYj0d/7JfeNZ9PeeOldSNoE/lunLeQXKvkmIumI29IaKMotU/ln/McOIvgzZcQ/3TrSA==
+ dependencies:
+ "@types/lodash" "*"
+
+"@types/lodash.throttle@^4.1.7":
+ version "4.1.7"
+ resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58"
+ integrity sha512-znwGDpjCHQ4FpLLx19w4OXDqq8+OvREa05H89obtSyXyOFKL3dDjCslsmfBz0T2FU8dmf5Wx1QvogbINiGIu9g==
+ dependencies:
+ "@types/lodash" "*"
+
+"@types/lodash@*":
+ version "4.14.189"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.189.tgz#975ff8c38da5ae58b751127b19ad5e44b5b7f6d2"
+ integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA==
+
"@types/long@^4.0.0", "@types/long@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
@@ -5173,6 +5305,11 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
+"@types/pako@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@types/pako/-/pako-2.0.0.tgz#12ab4c19107528452e73ac99132c875ccd43bdfb"
+ integrity sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==
+
"@types/parse5@*":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.0.tgz#38590dc2c3cf5717154064e3ee9b6947ee21b299"
@@ -5881,6 +6018,11 @@
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.3.tgz#beaf980612532aa9a3004aff7e428943aeaa0711"
integrity sha512-Lv2vySXypg4nfa51LY1nU8yDAGo/5YwF+EY/rUZgIbfvwVARcd67ttCM8SMsTeJy51YhHYavEq+FS6R0hW9PFQ==
+"@xstate/fsm@^1.4.0":
+ version "1.6.5"
+ resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.5.tgz#f599e301997ad7e3c572a0b1ff0696898081bea5"
+ integrity sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==
+
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@@ -7614,6 +7756,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+base64-arraybuffer@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
+ integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
+
base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -12205,7 +12352,7 @@ eslint-plugin-react@^7.20.5:
resolve "^2.0.0-next.3"
string.prototype.matchall "^4.0.4"
-eslint-plugin-react@latest:
+eslint-plugin-react@^7.31.11:
version "7.31.11"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz#011521d2b16dcf95795df688a4770b4eaab364c8"
integrity sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==
@@ -12864,6 +13011,11 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
+fflate@^0.4.4:
+ version "0.4.8"
+ resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
+ integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
+
figgy-pudding@^3.4.1, figgy-pudding@^3.5.1:
version "3.5.2"
resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
@@ -16492,6 +16644,14 @@ jsdoctypeparser@^9.0.0:
resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-9.0.0.tgz#8c97e2fb69315eb274b0f01377eaa5c940bd7b26"
integrity sha512-jrTA2jJIL6/DAEILBEh2/w9QxCuwmvNXIry39Ay/HVfhE3o2yVV0U44blYkqdHA/OKloJEqvJy0xU+GSdE2SIw==
+jsdom-worker@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/jsdom-worker/-/jsdom-worker-0.2.1.tgz#bbea3dc012227b434bea8a22701871097bf50b94"
+ integrity sha512-LwGtjkIfbDObphy4lUJmmAmo5cM/WS8r1A61apecFeqkjUvOhhinxM8wQFS51ndR+6jXxzAX3bhxMOG3njckaw==
+ dependencies:
+ mitt "^1.1.3"
+ uuid-v4 "^0.1.0"
+
jsdom@^16.6.0:
version "16.7.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710"
@@ -18278,6 +18438,11 @@ mississippi@^3.0.0:
stream-each "^1.1.0"
through2 "^2.0.0"
+mitt@^1.1.3:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d"
+ integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==
+
mixin-deep@^1.2.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@@ -19843,6 +20008,11 @@ pad@^3.2.0:
dependencies:
wcwidth "^1.0.1"
+pako@^2.0.4:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
+ integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==
+
pako@~1.0.5:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
@@ -22433,6 +22603,23 @@ rollup@2.78.0, rollup@^2.67.1, rollup@^2.8.0:
optionalDependencies:
fsevents "~2.3.2"
+rrweb-snapshot@^1.1.14:
+ version "1.1.14"
+ resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.14.tgz#9d4d9be54a28a893373428ee4393ec7e5bd83fcc"
+ integrity sha512-eP5pirNjP5+GewQfcOQY4uBiDnpqxNRc65yKPW0eSoU1XamDfc4M8oqpXGMyUyvLyxFDB0q0+DChuxxiU2FXBQ==
+
+rrweb@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-1.1.3.tgz#4fbb3d473d71c79b6c30a54e585e5a01c8ac08bb"
+ integrity sha512-F2qp8LteJLyycsv+lCVJqtVpery63L3U+/ogqMA0da8R7Jx57o6gT+HpjrzdeeGMIBZR7kKNaKyJwDupTTu5KA==
+ dependencies:
+ "@types/css-font-loading-module" "0.0.7"
+ "@xstate/fsm" "^1.4.0"
+ base64-arraybuffer "^1.0.1"
+ fflate "^0.4.4"
+ mitt "^1.1.3"
+ rrweb-snapshot "^1.1.14"
+
rsvp@^3.0.14, rsvp@^3.0.17, rsvp@^3.0.18, rsvp@^3.0.21, rsvp@^3.0.6, rsvp@^3.1.0:
version "3.6.2"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a"
@@ -25417,6 +25604,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+uuid-v4@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/uuid-v4/-/uuid-v4-0.1.0.tgz#62d7b310406f6cecfea1528c69f1e8e0bcec5a3a"
+ integrity sha512-m11RYDtowtAIihBXMoGajOEKpAXrKbpKlpmxqyztMYQNGSY5nZAZ/oYch/w2HNS1RMA4WLGcZvuD8/wFMuCEzA==
+
uuid@3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"