diff --git a/docs/debug-parser.html b/docs/debug-parser.html index c5fdf9a..ce79720 100644 --- a/docs/debug-parser.html +++ b/docs/debug-parser.html @@ -102,9 +102,11 @@

SpeedCurve RUM Debug Parser

Parsed debug output

- - - + + + + +
diff --git a/docs/debug-parser.js b/docs/debug-parser.js index 8dc9e1d..e81b772 100644 --- a/docs/debug-parser.js +++ b/docs/debug-parser.js @@ -1 +1 @@ -!function(){"use strict";"function"==typeof SuppressedError&&SuppressedError;var e={as:"activationStart",rs:"redirectStart",re:"redirectEnd",fs:"fetchStart",ds:"domainLookupStart",de:"domainLookupEnd",cs:"connectStart",sc:"secureConnectionStart",ce:"connectEnd",qs:"requestStart",bs:"responseStart",be:"responseEnd",oi:"domInteractive",os:"domContentLoadedEventStart",oe:"domContentLoadedEventEnd",oc:"domComplete",ls:"loadEventStart",le:"loadEventEnd",sr:"startRender",fc:"firstContentfulPaint",lc:"largestContentfulPaint"};function t(t,r){if(r)return function(e,t){var n=e.match(new RegExp("".concat(t,"(\\d+)")));return n?parseFloat(n[1]):null}(n(t,"NT"),r);var a=n(t,"NT").match(/[a-z]+[0-9]+/g);return a?Object.fromEntries(a.map((function(t){var n=t.match(/[a-z]+/)[0];return[e[n],parseFloat(t.match(/\d+/)[0])]}))):{}}function n(e,t){return e.searchParams.get(t)||""}function r(e){return e.map((function(e){return JSON.stringify(e)})).join(", ")}var a=document.querySelector("#input"),c=document.querySelector("#event-counter"),o=document.querySelector("#output"),s=document.querySelector("#parse"),i=document.querySelectorAll(".event-filter");if(!a||!o||!s)throw new Error("Cannot start debug parser.");function u(e){e.innerHTML="";var n=[];try{n=JSON.parse(a.value)}catch(t){e.appendChild(d("Could not parse input: ".concat(t),"red"))}c.innerText="(".concat(n.length," events)");for(var o=Number(new Date(n[0][0])),s=0,u=n;s0&&(a+=" Minimum measure time was ".concat(n[1])),a;case 21:return"Sample rate is ".concat(n[0],"%. This session is being sampled.");case 22:return"Sample rate is ".concat(n[0],"%. This session is not being sampled.");case 23:return a="Main beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 24:return a="Supplementary user timing beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 25:return a="Supplementary user interaction beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 26:return a="Supplementary custom data beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 80:return"POST beacon initialised.";case 81:return"POST beacon send() called.";case 82:return"POST beacon maximum measure timeout reached.";case 83:return t.includes("beaconUrl")?"POST beacon sent: ".concat(n[0]):"POST beacon sent.";case 89:return t.includes("beaconUrl")?"POST beacon send failed: ".concat(n[0]):"POST beacon send failed.";case 84:return"POST beacon cancelled (already sent).";case 85:return"POST beacon cancelled.";case 86:return"POST beacon is no longer recording metrics. Metrics received after this point may be ignored.";case 87:return"POST beacon metric rejected: ".concat(n[0]);case 90:return"POST beacon cancelled due to CSP violation.";case 91:return"POST beacon metric collector: ".concat(n[0]," (has data: ").concat(n[1],")");case 41:return"";case 42:return"layout-shift"===n[0].entryType?"Received layout shift at ".concat(n[0].startTime.toFixed()," ms with value of ").concat(n[0].value.toFixed(3)):"longtask"===n[0].entryType?"Received long task with duration of ".concat(n[0].duration," ms"):"event"===n[0].entryType?0===n[0].interactionId?"Ignored INP entry with no interaction ID":"Received INP entry with duration of ".concat(n[0].duration," ms (ID: ").concat(n[0].interactionId,")"):"first-input"===n[0].entryType?"Received FID entry with duration of ".concat(n[0].duration," ms"):"largest-contentful-paint"===n[0].entryType?"Received LCP entry at ".concat(n[0].startTime.toFixed()," ms"):"element"===n[0].entryType?"Received element timing entry for ".concat(n[0].identifier," at ").concat(n[0].startTime.toFixed()," ms"):(a="Received ".concat(n[0].entryType," entry"),n[0].startTime&&(a+=" at ".concat(n[0].startTime.toFixed()," ms")),a);case 43:return"largest-contentful-paint"===n[0].entryType?"Picked LCP from entry at ".concat(n[0].startTime.toFixed()," ms"):"";case 51:return"Error while initialising PerformanceObserver: ".concat(n[0]);case 52:return"Error reading input event. Cannot calculate FID for this page.";case 53:return"Cannot read the innerHTML property of an element. Cannot calculate inline style or script sizes for this page.";case 54:return"Error reading input event. Cannot calculate user interaction times for this page.";case 55:return"Error reading session cookie. This page will not be linked to a user session.";case 56:return"Error setting session cookie. This page will not be linked to a user session.";case 57:return"Error while evaluating '".concat(n[0],"' for the page label: ").concat(n[1]);case 71:return"The Navigation Timing API is not supported. Performance metrics for this page will be limited.";case 72:return"Start render time could not be determined."}return a}(a,f),u=a[2];if(i){if(function(e){return[23,26,25,24].includes(e)}(a[1])){(b=d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i))).classList.add("tooltip-container");var l=new URL(u[0]),g=t(l);(h=document.createElement("div")).className="tooltip",h.innerHTML='\n
\n Page label: '.concat(l.searchParams.get("l"),"
\n Hostname: ").concat(l.searchParams.get("HN"),"
\n Path: ").concat(l.searchParams.get("PN"),"
\n lux.js version: ").concat(l.searchParams.get("v"),"
\n
\n LCP: ").concat(g.largestContentfulPaint,"
\n CLS: ").concat(l.searchParams.get("DCLS"),"
\n INP: ").concat(l.searchParams.get("INP"),"
\n FID: ").concat(l.searchParams.get("FID"),"
\n
\n "),b.appendChild(h),e.appendChild(b)}else if(1===a[1]){(b=d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i," Hover to view configuration."))).classList.add("tooltip-container");var v=u[1];try{v=JSON.parse(v)}catch(e){}(h=document.createElement("div")).className="tooltip",h.innerHTML='\n
\n
'.concat(JSON.stringify(v,null,4),"
\n
\n "),b.appendChild(h),e.appendChild(b)}else if(83===a[1]){var b;(b=d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i," Hover to view data."))).classList.add("tooltip-container");var h,S=u[1];try{S=JSON.parse(S)}catch(e){}(h=document.createElement("div")).className="tooltip",h.innerHTML='\n
\n
'.concat(JSON.stringify(S,null,4),"
\n
\n "),b.appendChild(h),e.appendChild(b)}else e.appendChild(d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i)));if(9===a[1]&&(p=!0),3===a[1]&&(m=a[0],p=!1),7===a[1])a[0]-m<1e3&&e.appendChild(d("".concat((new Intl.NumberFormat).format(s)," ms: âš ī¸ Data was gathered for less than 1 second. Consider increasing the value of LUX.minMeasureTime.")));if(p&&42===a[1])42!==n[c+1][1]&&e.appendChild(d("".concat((new Intl.NumberFormat).format(s)," ms: âš ī¸ Performance entries were received after the beacon was sent.")))}}));var g=new Date(o);e.prepend(d("0 ms: Navigation started at ".concat(g.toLocaleDateString()," ").concat(g.toLocaleTimeString())))}function d(e,t){var n=document.createElement("li");return n.textContent=e,n.className=t||"",n}s.addEventListener("click",(function(){return u(o)})),i.forEach((function(e){e.addEventListener("change",(function(){return u(o)}))})),a.value&&u(o)}(); +!function(){"use strict";"function"==typeof SuppressedError&&SuppressedError;var e={_:"navigationStart",as:"activationStart",rs:"redirectStart",re:"redirectEnd",fs:"fetchStart",ds:"domainLookupStart",de:"domainLookupEnd",cs:"connectStart",sc:"secureConnectionStart",ce:"connectEnd",qs:"requestStart",bs:"responseStart",be:"responseEnd",oi:"domInteractive",os:"domContentLoadedEventStart",oe:"domContentLoadedEventEnd",oc:"domComplete",ls:"loadEventStart",le:"loadEventEnd",sr:"startRender",fc:"firstContentfulPaint",lc:"largestContentfulPaint"};function t(e){return e.map(function(e){return JSON.stringify(e)}).join(", ")}var n=document.querySelector("#input"),r=document.querySelector("#event-counter"),a=document.querySelector("#output"),c=document.querySelector("#parse"),o=document.querySelectorAll(".event-filter");if(!n||!a||!c)throw new Error("Cannot start debug parser.");function s(a){a.innerHTML="";var c=[];try{c=JSON.parse(n.value)}catch(e){a.appendChild(i("Could not parse input: ".concat(e),"red"))}var s=c.filter(function(e){return 23===e[1]});r.innerText="(".concat(c.length," events; ").concat(s.length," page views)");for(var u=Number(new Date(c[0][0])),d=0,l=c;d0&&(a+=" Minimum measure time was ".concat(r[1])),a;case 21:return"Sample rate is ".concat(r[0],"%. This session is being sampled.");case 22:return"Sample rate is ".concat(r[0],"%. This session is not being sampled.");case 23:return a="đŸ“Ģ Main beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 24:return a="📧 Supplementary user timing beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 25:return a="📧 Supplementary user interaction beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 26:return a="📧 Supplementary custom data beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 80:return"POST beacon initialised.";case 81:return"POST beacon send() called.";case 82:return"POST beacon maximum measure timeout reached.";case 83:return n.includes("beaconUrl")?"đŸ“Ģ POST beacon sent: ".concat(r[0]):"đŸ“Ģ POST beacon sent.";case 89:return n.includes("beaconUrl")?"âš ī¸ POST beacon send failed: ".concat(r[0]):"âš ī¸ POST beacon send failed.";case 85:return"POST beacon cancelled.";case 86:return"POST beacon is no longer recording metrics. Metrics received after this point may be ignored.";case 87:return"POST beacon metric rejected: ".concat(r[0]);case 90:return"POST beacon cancelled due to CSP violation.";case 91:return n.includes("metrics")?"POST beacon metric collector: ".concat(r[0]," (has data: ").concat(r[1],")"):"";case 41:return"";case 42:return n.includes("metrics")?"layout-shift"===r[0].entryType?"Received layout shift at ".concat(r[0].startTime.toFixed()," ms with value of ").concat(r[0].value.toFixed(3)):"longtask"===r[0].entryType?"Received long task with duration of ".concat(r[0].duration," ms"):"event"===r[0].entryType?0===r[0].interactionId?"Ignored INP entry with no interaction ID":"Received INP entry with duration of ".concat(r[0].duration," ms (ID: ").concat(r[0].interactionId,")"):"first-input"===r[0].entryType?"Received FID entry with duration of ".concat(r[0].duration," ms"):"largest-contentful-paint"===r[0].entryType?"Received LCP entry at ".concat(r[0].startTime.toFixed()," ms"):"element"===r[0].entryType?"Received element timing entry for ".concat(r[0].identifier," at ").concat(r[0].startTime.toFixed()," ms"):(a="Received ".concat(r[0].entryType," entry"),r[0].startTime&&(a+=" at ".concat(r[0].startTime.toFixed()," ms")),a):"";case 43:return n.includes("metrics")&&"largest-contentful-paint"===r[0].entryType?"Picked LCP from entry at ".concat(r[0].startTime.toFixed()," ms"):"";case 51:return"Error while initialising PerformanceObserver: ".concat(r[0]);case 52:return"Error reading input event. Cannot calculate FID for this page.";case 53:return"Cannot read the innerHTML property of an element. Cannot calculate inline style or script sizes for this page.";case 54:return"Error reading input event. Cannot calculate user interaction times for this page.";case 55:return"Error reading session cookie. This page will not be linked to a user session.";case 56:return"Error setting session cookie. This page will not be linked to a user session.";case 57:return"Error while evaluating '".concat(r[0],"' for the page label: ").concat(r[1]);case 71:return"The Navigation Timing API is not supported. Performance metrics for this page will be limited.";case 72:return"Start render time could not be determined."}return a}(n,g),v=n[2];if(m){if(function(e){return[23,26,25,24].includes(e)}(n[1])){(P=i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m))).classList.add("tooltip-container");var b=new URL(v[0]),h=(d=(o=b,s="NT",o.searchParams.get(s)||"").match(/([a-z]+)?[0-9]+/g))?Object.fromEntries(d.map(function(t){var n=t.match(/[a-z]+/);return[n?e[n[0]]:"navigationStart",parseFloat(t.match(/\d+/)[0])]})):{};(T=document.createElement("div")).className="tooltip",T.innerHTML='\n
\n Page label: '.concat(b.searchParams.get("l"),"
\n Hostname: ").concat(b.searchParams.get("HN"),"
\n Path: ").concat(b.searchParams.get("PN"),"
\n lux.js version: ").concat(b.searchParams.get("v"),"
\n
\n LCP: ").concat(h.largestContentfulPaint,"
\n CLS: ").concat(b.searchParams.get("DCLS"),"
\n INP: ").concat(b.searchParams.get("INP"),"
\n FID: ").concat(b.searchParams.get("FID"),"
\n
\n "),P.appendChild(T),a.appendChild(P)}else if(1===n[1]){(P=i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m," Hover to view configuration."))).classList.add("tooltip-container");var S=v[1];try{S=JSON.parse(S)}catch(e){}(T=document.createElement("div")).className="tooltip",T.innerHTML='\n
\n
'.concat(JSON.stringify(S,null,4),"
\n
\n "),P.appendChild(T),a.appendChild(P)}else if(83===n[1]){var P;(P=i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m," Hover to view data."))).classList.add("tooltip-container");var T,C=v[1];try{C=JSON.parse(C)}catch(e){}(T=document.createElement("div")).className="tooltip",T.innerHTML='\n
\n
'.concat(JSON.stringify(C,null,4),"
\n
\n "),P.appendChild(T),a.appendChild(P)}else a.appendChild(i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m)));if(9===n[1]&&(f=!0),3===n[1]&&(p=n[0],f=!1),7===n[1])n[0]-p<1e3&&a.appendChild(i("".concat((new Intl.NumberFormat).format(l)," ms: âš ī¸ Data was gathered for less than 1 second. Consider increasing the value of LUX.minMeasureTime.")));if(f&&42===n[1])42!==c[r+1][1]&&a.appendChild(i("".concat((new Intl.NumberFormat).format(l)," ms: âš ī¸ Performance entries were received after the beacon was sent.")))}});var v=new Date(u);a.prepend(i("0 ms: đŸŸĸ Navigation started at ".concat(v.toLocaleDateString()," ").concat(v.toLocaleTimeString())))}function i(e,t){var n=document.createElement("li");return n.textContent=e,n.className=t||"",n}c.addEventListener("click",function(){return s(a)}),o.forEach(function(e){e.addEventListener("change",function(){return s(a)})}),n.value&&s(a)}(); diff --git a/docs/debug-parser/events.ts b/docs/debug-parser/events.ts index ae889c8..d3b811e 100644 --- a/docs/debug-parser/events.ts +++ b/docs/debug-parser/events.ts @@ -14,7 +14,23 @@ export function isBeaconEvent(event: LogEvent) { ].includes(event); } +function isVerboseEvent(event: LogEvent) { + return [ + LogEvent.DataCollectionStart, + LogEvent.PostBeaconCancelled, + LogEvent.PostBeaconCSPViolation, + LogEvent.PostBeaconInitialised, + LogEvent.PostBeaconSendCalled, + LogEvent.PostBeaconStopRecording, + LogEvent.PostBeaconTimeoutReached, + ].includes(event); +} + export function getMessageForEvent(event: LogEventRecord, filters: string[]): string { + if (isVerboseEvent(event[1]) && !filters.includes("verbose")) { + return ""; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const args = event[2] as any[]; @@ -26,20 +42,23 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st } switch (event[1]) { - case 0: + case 0 as LogEvent: return "The lux.js script was not loaded on this page."; case LogEvent.EvaluationStart: - return `lux.js v${args[0]} is initialising.`; + return `lux.js v${args[0]} init begin.`; case LogEvent.EvaluationEnd: - return "lux.js has finished initialising."; + return "lux.js init complete."; case LogEvent.InitCalled: - return "LUX.init()"; + return "đŸŸĸ LUX.init() - new page view started"; + + case LogEvent.StartSoftNavigationCalled: + return "đŸŸĸ LUX.startSoftNavigation() - new page view started"; case LogEvent.MarkLoadTimeCalled: - return `LUX.markLoadTime(${argsAsString(args)})`; + return `đŸŽ¯ LUX.markLoadTime(${argsAsString(args)})`; case LogEvent.MarkCalled: if (filters.includes("userTiming")) { @@ -71,6 +90,9 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st case LogEvent.SendCancelledPageHidden: return "This beacon was not sent because the page visibility was hidden."; + case LogEvent.SendCancelledSpaMode: + return "â„šī¸ LUX.send() was ignored because SPA Mode is enabled."; + case LogEvent.ForceSampleCalled: return "LUX.forceSample()"; @@ -78,7 +100,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return "Preparing to send main beacon. Metrics received after this point may be ignored."; case LogEvent.UnloadHandlerTriggered: - return "Unload handler was triggered."; + return "â¤´ī¸ Unload handler was triggered."; case LogEvent.OnloadHandlerTriggered: message = `Onload handler was triggered after ${args[0]} ms.`; @@ -96,7 +118,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return `Sample rate is ${args[0]}%. This session is not being sampled.`; case LogEvent.MainBeaconSent: - message = "Main beacon sent"; + message = "đŸ“Ģ Main beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -105,7 +127,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.UserTimingBeaconSent: - message = "Supplementary user timing beacon sent"; + message = "📧 Supplementary user timing beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -114,7 +136,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.InteractionBeaconSent: - message = "Supplementary user interaction beacon sent"; + message = "📧 Supplementary user interaction beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -123,7 +145,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.CustomDataBeaconSent: - message = "Supplementary custom data beacon sent"; + message = "📧 Supplementary custom data beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -142,20 +164,17 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st case LogEvent.PostBeaconSent: if (filters.includes("beaconUrl")) { - return `POST beacon sent: ${args[0]}`; + return `đŸ“Ģ POST beacon sent: ${args[0]}`; } - return "POST beacon sent."; + return "đŸ“Ģ POST beacon sent."; case LogEvent.PostBeaconSendFailed: if (filters.includes("beaconUrl")) { - return `POST beacon send failed: ${args[0]}`; + return `âš ī¸ POST beacon send failed: ${args[0]}`; } - return "POST beacon send failed."; - - case LogEvent.PostBeaconAlreadySent: - return "POST beacon cancelled (already sent)."; + return "âš ī¸ POST beacon send failed."; case LogEvent.PostBeaconCancelled: return "POST beacon cancelled."; @@ -170,12 +189,20 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return "POST beacon cancelled due to CSP violation."; case LogEvent.PostBeaconCollector: - return `POST beacon metric collector: ${args[0]} (has data: ${args[1]})`; + if (filters.includes("metrics")) { + return `POST beacon metric collector: ${args[0]} (has data: ${args[1]})`; + } + + return ""; case LogEvent.NavigationStart: return ""; case LogEvent.PerformanceEntryReceived: + if (!filters.includes("metrics")) { + return ""; + } + if (args[0].entryType === "layout-shift") { return `Received layout shift at ${args[0].startTime.toFixed()} ms with value of ${args[0].value.toFixed( 3, @@ -207,7 +234,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.PerformanceEntryProcessed: - if (args[0].entryType === "largest-contentful-paint") { + if (filters.includes("metrics") && args[0].entryType === "largest-contentful-paint") { return `Picked LCP from entry at ${args[0].startTime.toFixed()} ms`; } @@ -272,6 +299,12 @@ function getEventName(event: LogEvent) { return "MarkLoadTimeCalled"; case LogEvent.SendCancelledPageHidden: return "SendCancelledPageHidden"; + case LogEvent.StartSoftNavigationCalled: + return "StartSoftNavigationCalled"; + case LogEvent.SendCancelledSpaMode: + return "SendCancelledSpaMode"; + case LogEvent.BfCacheRestore: + return "BfCacheRestore"; case LogEvent.SessionIsSampled: return "SessionIsSampled"; case LogEvent.SessionIsNotSampled: @@ -318,8 +351,6 @@ function getEventName(event: LogEvent) { return "PostBeaconTimeoutReached"; case LogEvent.PostBeaconSent: return "PostBeaconSent"; - case LogEvent.PostBeaconAlreadySent: - return "PostBeaconAlreadySent"; case LogEvent.PostBeaconCancelled: return "PostBeaconCancelled"; case LogEvent.PostBeaconStopRecording: @@ -333,4 +364,6 @@ function getEventName(event: LogEvent) { case LogEvent.PostBeaconCollector: return "PostBeaconCollector"; } + + return "Unknown Event"; } diff --git a/docs/debug-parser/index.ts b/docs/debug-parser/index.ts index d26bb15..eef5f40 100644 --- a/docs/debug-parser/index.ts +++ b/docs/debug-parser/index.ts @@ -34,7 +34,9 @@ function renderOutput(output: Element) { output.appendChild(li(`Could not parse input: ${err}`, "red")); } - eventCounter.innerText = `(${inputEvents.length} events)`; + const sentBeacons = inputEvents.filter((event) => event[1] === LogEvent.MainBeaconSent); + + eventCounter.innerText = `(${inputEvents.length} events; ${sentBeacons.length} page views)`; let navigationStart = Number(new Date(inputEvents[0][0])); @@ -183,7 +185,7 @@ function renderOutput(output: Element) { output.prepend( li( - `0 ms: Navigation started at ${startTime.toLocaleDateString()} ${startTime.toLocaleTimeString()}`, + `0 ms: đŸŸĸ Navigation started at ${startTime.toLocaleDateString()} ${startTime.toLocaleTimeString()}`, ), ); } diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/spa-mode.md b/docs/spa-mode.md new file mode 100644 index 0000000..84394d7 --- /dev/null +++ b/docs/spa-mode.md @@ -0,0 +1,7 @@ +# SPA Mode in lux.js + +## Migrating from `LUX.auto = false` + +- Remove `LUX.auto = false`. +- Remove all `LUX.send()` calls. In SPA mode, the beacon is sent automatically. In some very rare cases you may want to send the beacon manually, which can be done by calling `LUX.send(true)` - **this is not recommended**. +- Replace all `LUX.init()` calls with `LUX.startSoftNavigation()`. diff --git a/docs/usage-changes.md b/docs/usage-changes.md deleted file mode 100644 index eb95d4d..0000000 --- a/docs/usage-changes.md +++ /dev/null @@ -1,158 +0,0 @@ -# LUX "v2" usage changes - -## General API changes - -### New method: `LUX.configure` - -It's common for LUX configuration to be specified all at once. This method aims to make the API for configuring LUX more consistent. For example, this mixture of setting properties and calling methods... - -```js -LUX.auto = false; -LUX.label = "Home"; -LUX.addData("tvMode", 1); -LUX.addData("env", "prod"); -``` - -... Could instead be written as one configuration object: - -```js -LUX.configure({ - auto: false, - label: "Home", - data: { - tvMode: 1, - env: "prod", - }, -}); -``` - -### New parameter: `LUX.send(configuration)` - -It's common for things like page labels and customer data to be specified at the same place that `LUX.send` is called. An optional object parameter to `LUX.send` would be the equivalent to calling `LUX.configure(options)` immediately followed by `LUX.send()`. - -```js -LUX.send({ - label: "Home", - data: { - tvMode: 1, - env: "prod", - }, -}); -``` - -### New property: `LUX.measureUntil = ` - -Controls when the beacon is sent in "auto" mode. Possible values are: - -* `pagehidden` - wait until the page's `visibilityState` is `hidden`, with `pagehide` and `unload` fallbacks - -Future versions of LUX might also support `networkidle`, which would employ heuristics to send the beacon when all network requests have completed; `cpuidle`, which would wait until there are no more long tasks; and `idle` which is a combination of both. - -### New property: `LUX.maxTimeAfterOnload = ` - -Controls the maximum time to wait after the onload event before the beacon is sent. Default value is `10000` (10 seconds). Can be set to `0` to wait indefinitely (not recommended). - -### New method: `LUX.markLoadTime()` - -Marks the "onload" time for SPA page views. Enables an accurate onload time to be recorded without sending the beacon too early. - -## "Normal" (non-SPA) usage - -### Current usage in "auto" mode - -No changes. By default LUX will continue to measure until onload. - -### Send the beacon automatically after measuring for as long as possible - -This is a new "mode" for LUX where we continue collecting data. Metrics like LCP, CLS, and long tasks will be affected by this. - -```js -LUX.measureUntil = "pagehidden"; -``` - -## SPA-specific changes - -### Current usage - -No changes. `LUX.init` and `LUX.send` will continue to work as they do now. - -```js -LUX.auto = false; - -// Manually send the beacon as soon as the page has loaded -MyApp.onPageLoaded(() => { - LUX.send(); -}); - -// Manually reset LUX at the point where the user initiates a navigation -MyApp.onUserNavigation(() => { - LUX.init(); - loadNextPage(); -}); -``` - -### Proposal #1 - -### Mark the "onload" time without sending the beacon, then send the beacon as late as possible - -This is similar to the current usage, however the onload time measurement is split from the beacon sending. This allows for data to be collected for as long as possible. Metrics like LCP and long tasks will be affected by this. - -```js -LUX.auto = false; - -// Automatically send the beacon when the page is hidden -LUX.measureUntil = "pagehidden"; - -// Manually mark when the page is loaded -onPageLoaded(() => { - LUX.markLoadTime(); -}); - -// Manually send the beacon and reset LUX at the point where the user initiates a navigation -onUserNavigation(() => { - LUX.send(); - LUX.init(); - loadNextPage(); -}); -``` - -### Mark the "onload" time without sending the beacon, send the beacon as late as possible, and automatically send the beacon - -This is similar to the current usage, however the onload time measurement is split from the beacon sending. This allows for data to be collected for as long as possible. Metrics like LCP and long tasks will be affected by this. - -```js -// Measure until the page is hidden, up to a maximum of 10 seconds after onload -LUX.measureUntil = "pagehidden"; -LUX.maxTimeAfterOnload = 10000; - -LUX.auto = false; - -// Measure until the network is idle for 5 seconds -LUX.measureUntil = "networkidle"; -LUX.idleTime = 5000; - -// Measure until long tasks are idle for 5 seconds -// What would we do for browsers that don't support long tasks? -LUX.measureUntil = "cpuidle"; -LUX.idleTime = 5000; - -// Measure until everything (CPU & network) is idle for 5 seconds -LUX.measureUntil = "idle"; -LUX.idleTime = 5000; - - - -LUX.auto = false; - -// Manually mark when the page is loaded -onPageLoaded(() => { - LUX.markLoadTime(); -}); - -// Manually send the beacon and reset LUX at the point where the user initiates a navigation -onUserNavigation(() => { - LUX.send(); - LUX.init(); - loadNextPage(); -}); -``` diff --git a/package-lock.json b/package-lock.json index 287dbac..8730605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-typescript": "^7.21.5", "@playwright/test": "^1.32.3", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^12.1.4", @@ -2516,6 +2517,27 @@ "node": ">=18" } }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-replace": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz", diff --git a/package.json b/package.json index f635575..f67d837 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "@speedcurve/lux", "version": "4.2.1", + "config": { + "snippetVersion": "2.0.0" + }, "main": "dist/lux.js", "scripts": { "build": "npm run rollup", @@ -24,6 +27,7 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-typescript": "^7.21.5", "@playwright/test": "^1.32.3", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^12.1.4", diff --git a/rollup.config.mjs b/rollup.config.mjs index a755de5..077d7e1 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,63 +1,115 @@ +import json from "@rollup/plugin-json"; import replace from "@rollup/plugin-replace"; import terser from "@rollup/plugin-terser"; import typescript from "@rollup/plugin-typescript"; +import pkg from "./package.json" with { type: "json" }; -const outputConfig = (file, polyfills, minified) => ({ +const commonPlugins = (target = "es5") => [ + json(), + replace({ + __ENABLE_POLYFILLS: JSON.stringify(target === "es5"), + }), + typescript({ + include: ["src/**"], + compilerOptions: { + target, + }, + }), +]; + +const scriptOutput = (file, minified) => ({ file, format: "iife", - plugins: [ - replace({ - __ENABLE_POLYFILLS: JSON.stringify(polyfills), - }), - minified ? terser() : undefined, - ], + plugins: [minified ? terser() : undefined], sourcemap: true, }); +const snippetOutput = (file, target = "es5") => { + const versionString = `${pkg.config.snippetVersion}${target === "es5" ? "" : `-${target}`}`; + const preamble = `/* SpeedCurve RUM Snippet v${versionString} */`; + + return { + file: file, + name: "LUX", + format: "iife", + strict: false, + plugins: [ + terser({ + format: { + preamble: preamble, + }, + }), + + /** + * This is a bit of a hack to ensure that the named export is always in the global scope. + * Rollup formats the export as `var [name] = function() { [default export] }()`, which + * is fine when the snippet is placed in the global scope. However our customers may either + * purposefully or inadvertently place the snippet in a scoped block that results in the + * `LUX` variable not being declared in the global scope. To ensure `LUX` is always in the + * global scope, we use the replace plugin to remove the `var` from the minified snippet. + */ + replace({ + delimiters: ["", ""], + values: { + "var LUX=": "LUX=", + [`${preamble}\n`]: preamble, + __SNIPPET_VERSION: JSON.stringify(versionString), + }, + }), + ], + }; +}; + export default [ + // lux.js script (compat) + { + input: "src/lux.ts", + output: [scriptOutput("dist/lux.js", false), scriptOutput("dist/lux.min.js", true)], + plugins: commonPlugins(), + }, + + // lux.js script (ES2015) { input: "src/lux.ts", output: [ - outputConfig("dist/lux.js", true, false), - outputConfig("dist/lux.min.js", true, true), - outputConfig("dist/lux-no-polyfills.js", false, false), - outputConfig("dist/lux-no-polyfills.min.js", false, true), + scriptOutput("dist/lux.es2015.js", false), + scriptOutput("dist/lux.es2015.min.js", true), ], - plugins: [ - typescript({ - include: "src/**", - }), + plugins: commonPlugins("es2015"), + }, + + // lux.js script (ES2020) + { + input: "src/lux.ts", + output: [ + scriptOutput("dist/lux.es2020.js", false), + scriptOutput("dist/lux.es2020.min.js", true), ], + plugins: commonPlugins("es2020"), }, + // Inline snippet (compat) { input: "src/snippet.ts", - output: { - file: "dist/lux-snippet.js", - name: "LUX", - format: "iife", - strict: false, - plugins: [ - terser(), + output: [snippetOutput("dist/lux-snippet.js")], + plugins: commonPlugins(), + }, - /** - * This is a bit of a hack to ensure that the named export is always in the global scope. - * Rollup formats the export as `var [name] = function() { [default export] }()`, which - * is fine when the snippet is placed in the global scope. However our customers may either - * purposefully or inadvertently place the snippet in a scoped block that results in the - * `LUX` variable not being declared in the global scope. To ensure `LUX` is always in the - * global scope, we use the replace plugin to remove the `var` from the minified snippet. - */ - replace({ "var LUX=": "LUX=" }), - ], - }, - plugins: [ - typescript({ - include: ["src/**"], - }), - ], + // Inline snippet (ES2015) + { + input: "src/snippet.ts", + output: [snippetOutput("dist/lux-snippet.es2015.js", "es2015")], + plugins: commonPlugins("es2015"), + }, + + // Inline snippet (ES2020) + { + input: "src/snippet.ts", + output: [snippetOutput("dist/lux-snippet.es2020.js", "es2020")], + plugins: commonPlugins("es2020"), }, + // Debug parser { input: "docs/debug-parser/index.ts", output: { @@ -66,6 +118,7 @@ export default [ plugins: [terser()], }, plugins: [ + json(), typescript({ include: ["docs/**", "src/**", "tests/helpers/lux.ts"], declaration: false, diff --git a/src/beacon.ts b/src/beacon.ts index 771a407..0cbe9ba 100644 --- a/src/beacon.ts +++ b/src/beacon.ts @@ -1,5 +1,6 @@ import { ConfigObject, UserConfig } from "./config"; import { wasPrerendered } from "./document"; +import * as Events from "./events"; import Flags, { addFlag } from "./flags"; import { addListener } from "./listeners"; import Logger, { LogEvent } from "./logger"; @@ -160,6 +161,10 @@ export class Beacon { } send() { + if (this.isSent) { + return; + } + this.logger.logEvent(LogEvent.PostBeaconSendCalled); for (const cb of this.onBeforeSendCbs) { @@ -187,11 +192,6 @@ export class Beacon { return; } - if (this.isSent) { - this.logger.logEvent(LogEvent.PostBeaconAlreadySent); - return; - } - // Only clear the max measure timeout if there's data to send. clearTimeout(this.maxMeasureTimeout); @@ -204,6 +204,7 @@ export class Beacon { collectionDuration: now() - collectionStart, pageId: this.pageId, scriptVersion: VERSION, + snippetVersion: this.config.snippetVersion, sessionId: this.sessionId, startTime: this.startTime, }, @@ -214,6 +215,7 @@ export class Beacon { if (sendBeacon(beaconUrl, JSON.stringify(payload))) { this.isSent = true; this.logger.logEvent(LogEvent.PostBeaconSent, [beaconUrl, payload]); + Events.emit("beacon", payload); } } catch (e) { // Intentionally empty; handled below @@ -239,6 +241,9 @@ export type BeaconMetaData = { /** The lux.js version that sent the beacon */ scriptVersion: string; + /** The lux.js snippet version that sent the beacon */ + snippetVersion?: string; + /** How long in milliseconds did this beacon capture page data for */ measureDuration: number; diff --git a/src/config.ts b/src/config.ts index 05837a3..9f48461 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,11 @@ +import { LuxGlobal } from "./global"; import { ServerTimingConfig } from "./server-timing"; import { UrlPatternMapping } from "./url-matcher"; +/** + * ConfigObject holds the parsed and normalised lux.js configuration. It is initialised once based + * on the `LUX` global. + */ export interface ConfigObject { allowEmptyPostBeacon: boolean; auto: boolean; @@ -26,6 +31,8 @@ export interface ConfigObject { samplerate: number; sendBeaconOnPageHidden: boolean; serverTiming?: ServerTimingConfig; + snippetVersion?: LuxGlobal["snippetVersion"]; + spaMode: boolean; trackErrors: boolean; trackHiddenPages: boolean; } @@ -35,7 +42,8 @@ export type UserConfig = Partial; const luxOrigin = "https://lux.speedcurve.com"; export function fromObject(obj: UserConfig): ConfigObject { - const autoMode = getProperty(obj, "auto", true); + const spaMode = getProperty(obj, "spaMode", false); + const autoMode = spaMode ? false : getProperty(obj, "auto", true); return { allowEmptyPostBeacon: getProperty(obj, "allowEmptyPostBeacon", false), @@ -55,13 +63,14 @@ export function fromObject(obj: UserConfig): ConfigObject { maxBeaconUTEntries: getProperty(obj, "maxBeaconUTEntries", 20), maxErrors: getProperty(obj, "maxErrors", 5), maxMeasureTime: getProperty(obj, "maxMeasureTime", 60_000), - measureUntil: getProperty(obj, "measureUntil", "onload"), + measureUntil: getProperty(obj, "measureUntil", spaMode ? "pagehidden" : "onload"), minMeasureTime: getProperty(obj, "minMeasureTime", 0), newBeaconOnPageShow: getProperty(obj, "newBeaconOnPageShow", false), pagegroups: getProperty(obj, "pagegroups"), samplerate: getProperty(obj, "samplerate", 100), - sendBeaconOnPageHidden: getProperty(obj, "sendBeaconOnPageHidden", autoMode), + sendBeaconOnPageHidden: getProperty(obj, "sendBeaconOnPageHidden", spaMode || autoMode), serverTiming: getProperty(obj, "serverTiming"), + spaMode, trackErrors: getProperty(obj, "trackErrors", true), trackHiddenPages: getProperty(obj, "trackHiddenPages", false), }; diff --git a/src/global.ts b/src/global.d.ts similarity index 61% rename from src/global.ts rename to src/global.d.ts index bfeec76..d3f233b 100644 --- a/src/global.ts +++ b/src/global.d.ts @@ -3,29 +3,43 @@ import type { Event } from "./events"; import type { LogEventRecord } from "./logger"; export type Command = [CommandFunction, ...CommandArg[]]; -type CommandFunction = "addData" | "init" | "mark" | "markLoadTime" | "measure" | "on" | "send"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type CommandArg = any; +type CommandFunction = + | "addData" + | "init" + | "mark" + | "markLoadTime" + | "measure" + | "on" + | "send" + | "startSoftNavigation"; + +type CommandArg = unknown; type PerfMarkFn = typeof performance.mark; type PerfMeasureFn = typeof performance.measure; +/** + * LuxGlobal is the global `LUX` object. It is defined and modified by the snippet, lux.js and by + * the implementor. + */ export interface LuxGlobal extends UserConfig { /** Command queue used to store actions that were initiated before the full script loads */ ac?: Command[]; - addData: (name: string, value: unknown) => void; + addData: (name: string, value?: unknown) => void; cmd: (cmd: Command) => void; /** @deprecated */ doUpdate?: () => void; forceSample?: () => void; - getDebug?: () => LogEventRecord[]; + getDebug: () => LogEventRecord[]; getSessionId?: () => void; - init: () => void; + init: (time?: number) => void; mark: (...args: Parameters) => ReturnType | void; - markLoadTime?: (time?: number) => void; + markLoadTime: (time?: number) => void; measure: (...args: Parameters) => ReturnType | void; on: (event: Event, callback: (data?: unknown) => void) => void; /** Timestamp representing when the LUX snippet was evaluated */ ns?: number; - send: () => void; + send: (force?: boolean) => void; + snippetVersion?: string; + startSoftNavigation: (time?: number) => void; version?: string; } diff --git a/src/logger.ts b/src/logger.ts index a9bc7a4..22bd8d1 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -15,6 +15,10 @@ export const enum LogEvent { OnloadHandlerTriggered = 11, MarkLoadTimeCalled = 12, SendCancelledPageHidden = 13, + StartSoftNavigationCalled = 14, + SendCancelledSpaMode = 16, + BfCacheRestore = 17, + InitCallIgnored = 18, // Data collection events SessionIsSampled = 21, @@ -48,7 +52,6 @@ export const enum LogEvent { PostBeaconSendCalled = 81, PostBeaconTimeoutReached = 82, PostBeaconSent = 83, - PostBeaconAlreadySent = 84, PostBeaconCancelled = 85, PostBeaconStopRecording = 86, PostBeaconMetricRejected = 87, @@ -58,12 +61,12 @@ export const enum LogEvent { PostBeaconCollector = 91, } -export type LogEventRecord = [number, number, ...unknown[]]; +export type LogEventRecord = [number, LogEvent, ...unknown[]]; export default class Logger { events: LogEventRecord[] = []; - logEvent(event: number, args: unknown[] = []) { + logEvent(event: LogEvent, args: unknown[] = []) { this.events.push([now(), event, args]); } diff --git a/src/lux.ts b/src/lux.ts index 045bbbd..9e18321 100644 --- a/src/lux.ts +++ b/src/lux.ts @@ -14,7 +14,7 @@ import { onVisible, isVisible, wasPrerendered, wasRedirected } from "./document" import { getNodeSelector } from "./dom"; import * as Events from "./events"; import Flags, { addFlag } from "./flags"; -import { Command, LuxGlobal } from "./global"; +import type { Command, LuxGlobal } from "./global"; import { getTrackingParams } from "./integrations/tracking"; import { InteractionInfo } from "./interaction"; import { addListener, removeListener } from "./listeners"; @@ -850,15 +850,6 @@ LUX = (function () { * beginning of a page transition, but is also called internally when the BF cache is restored. */ function _init(startTime?: number, clearFlags = true): void { - // Some customers (incorrectly) call LUX.init on the very first page load of a SPA. This would - // cause some first-page-only data (like paint metrics) to be lost. To prevent this, we silently - // bail from this function when we detect an unnecessary LUX.init call. - const endMark = _getMark(END_MARK); - - if (!endMark) { - return; - } - // Mark the "navigationStart" for this SPA page. A start time can be passed through, for example // to set a page's start time as an event timestamp. if (startTime) { @@ -867,14 +858,6 @@ LUX = (function () { _mark(START_MARK); } - logger.logEvent(LogEvent.InitCalled); - - // This is an edge case where LUX.auto = true but LUX.init() has been called. In this case, the - // POST beacon will not be sent automatically, so we need to send it here. - if (globalConfig.auto && !beacon.isSent) { - beacon.send(); - } - // Clear all interactions from the previous "page". _clearIx(); @@ -1038,11 +1021,7 @@ LUX = (function () { 0 + "fs" + 0 + - "ls" + - end + - "le" + - end + - ""; + (end > 0 ? "ls" + end + "le" + end : ""); } else if (performance.timing) { // Return the real Nav Timing metrics because this is the "main" page view (not a SPA) const navEntry = getNavigationEntry(); @@ -1069,8 +1048,14 @@ LUX = (function () { return ""; }; + // loadEventStart always comes from navigation timing let loadEventStartStr = prefixNTValue("loadEventStart", "ls", true); - let loadEventEndStr = prefixNTValue("loadEventEnd", "le", true); + + // If LUX.markLoadTime() was called in SPA Mode, we allow the custom mark to override loadEventEnd + let loadEventEndStr = + globalConfig.spaMode && endMark + ? "le" + processTimeMetric(endMark.startTime) + : prefixNTValue("loadEventEnd", "le", true); if (getPageRestoreTime() && startMark && endMark) { // For bfcache restores, we set the load time to the time it took for the page to be restored. @@ -1393,8 +1378,10 @@ LUX = (function () { return [curleft, curtop]; } - // Mark the load time of the current page. Intended to be used in SPAs where it is not desirable to - // send the beacon as soon as the page has finished loading. + /** + * Mark the load time of the current page. Intended to be used in SPAs where it is not desirable + * to send the beacon as soon as the page has finished loading. + */ function _markLoadTime(time?: number) { logger.logEvent(LogEvent.MarkLoadTimeCalled, [time]); @@ -1435,6 +1422,10 @@ LUX = (function () { queryParams.push("fl=" + gFlags); } + if (globalLux.snippetVersion) { + queryParams.push("sv=" + globalLux.snippetVersion); + } + const customDataValues = CustomData.valuesToString(customData); if (customDataValues) { @@ -1469,10 +1460,20 @@ LUX = (function () { const startMark = _getMark(START_MARK); const endMark = _getMark(END_MARK); - if (!startMark || (endMark && endMark.startTime < startMark.startTime)) { - // Record the synthetic loadEventStart time for this page, unless it was already recorded - // with LUX.markLoadTime() - _markLoadTime(); + if (!startMark) { + // For hard navigations set the synthetic load time when the beacon is being sent, unless + // one has already been set. + if (!endMark) { + _markLoadTime(); + } + } else { + // For soft navigations, only set the synthetic load time if SPA mode is not enabled, and... + if (!globalConfig.spaMode) { + // ...there is no existing end mark, or the end mark is from a previous SPA page. + if (!endMark || endMark.startTime < startMark.startTime) { + _markLoadTime(); + } + } } // Store any tracking parameters as custom data @@ -2004,6 +2005,7 @@ LUX = (function () { // See https://bugs.chromium.org/p/chromium/issues/detail?id=1133363 setTimeout(() => { if (gbLuxSent) { + logger.logEvent(LogEvent.BfCacheRestore); // If the beacon was already sent for this page, we start a new page view and mark the // load time as the time it took to restore the page. _init(getPageRestoreTime(), false); @@ -2036,35 +2038,91 @@ LUX = (function () { const globalLux = globalConfig as LuxGlobal; // Functions + globalLux.addData = _addData; + globalLux.cmd = _runCommand; + globalLux.getSessionId = _getUniqueId; globalLux.mark = _mark; - globalLux.measure = _measure; - globalLux.init = _init; globalLux.markLoadTime = _markLoadTime; + globalLux.measure = _measure; globalLux.on = Events.subscribe; - globalLux.send = () => { - logger.logEvent(LogEvent.SendCalled); + globalLux.snippetVersion = LUX.snippetVersion; + globalLux.version = VERSION; + + globalLux.init = (time?: number) => { + logger.logEvent(LogEvent.InitCalled); + + // Some customers (incorrectly) call LUX.init on the very first page load of a SPA. This would + // cause some first-page-only data (like paint metrics) to be lost. To prevent this, we silently + // bail from this function when we detect an unnecessary LUX.init call. + // + // Some notes about how this is compatible with SPA mode: + // - For "new" implementations where SPA mode has always been enabled, we expect + // LUX.startSoftNavigation() to be called instead of LUX.init(), so this code path should + // never be reached. + // + // - For "old" implementations, we expect LUX.send() is still being called. So we can rely on + // there being an end mark from the previous LUX.send() call. + // + const endMark = _getMark(END_MARK); + + if (!endMark) { + logger.logEvent(LogEvent.InitCallIgnored); + return; + } + + // In SPA mode, ensure the previous page's beacon has been sent + if (globalConfig.spaMode) { + beacon.send(); + _sendLux(); + } + + _init(time); + }; + + globalLux.startSoftNavigation = (time?: number): void => { + logger.logEvent(LogEvent.StartSoftNavigationCalled); beacon.send(); _sendLux(); + _init(time); }; - globalLux.addData = _addData; - globalLux.getSessionId = _getUniqueId; // so customers can do their own sampling + + globalLux.send = (force?: boolean) => { + if (globalConfig.spaMode && !force) { + // In SPA mode, sending the beacon manually is not necessary, and is ignored unless the `force` + // parameter has been specified. + logger.logEvent(LogEvent.SendCancelledSpaMode); + + // If markLoadTime() has not already been called, we assume this send() call corresponds to a + // "loaded" state and mark it as the load time. This mark is important as it is used to + // decide whether an init() call can be ignored or not. + const startMark = _getMark(START_MARK); + const endMark = _getMark(END_MARK); + + if (!endMark || (startMark && endMark.startTime < startMark.startTime)) { + _markLoadTime(); + } + } else { + logger.logEvent(LogEvent.SendCalled); + beacon.send(); + _sendLux(); + } + }; + globalLux.getDebug = () => { console.log( "SpeedCurve RUM debugging documentation: https://support.speedcurve.com/docs/rum-js-api#luxgetdebug", ); return logger.getEvents(); }; + globalLux.forceSample = () => { logger.logEvent(LogEvent.ForceSampleCalled); setUniqueId(createSyncId(true)); }; + globalLux.doUpdate = () => { // Deprecated, intentionally empty. }; - globalLux.cmd = _runCommand; - - // Public properties - globalLux.version = VERSION; /** * Run a command from the command queue diff --git a/src/snippet.ts b/src/snippet.ts index ad97ffb..d7b21fd 100644 --- a/src/snippet.ts +++ b/src/snippet.ts @@ -1,4 +1,5 @@ import type { Command, LuxGlobal } from "./global"; +import { LogEvent } from "./logger"; import { performance } from "./performance"; import scriptStartTime from "./start-marker"; import { msSinceNavigationStart } from "./timing"; @@ -15,13 +16,15 @@ LUX = window.LUX || ({} as LuxGlobal); LUX.ac = []; LUX.addData = (name, value) => LUX.cmd(["addData", name, value]); LUX.cmd = (cmd: Command) => LUX.ac!.push(cmd); -LUX.getDebug = () => [[scriptStartTime, 0, []]]; -LUX.init = () => LUX.cmd(["init"]); +LUX.getDebug = () => [[scriptStartTime, 0 as LogEvent, []]]; +LUX.init = (time?: number) => LUX.cmd(["init", time || msSinceNavigationStart()]); LUX.mark = _mark; LUX.markLoadTime = () => LUX.cmd(["markLoadTime", msSinceNavigationStart()]); +LUX.startSoftNavigation = () => LUX.cmd(["startSoftNavigation", msSinceNavigationStart()]); LUX.measure = _measure; LUX.on = (event, callback) => LUX.cmd(["on", event, callback]); -LUX.send = () => LUX.cmd(["send"]); +LUX.send = (force?: boolean) => LUX.cmd(["send", force]); +LUX.snippetVersion = __SNIPPET_VERSION; LUX.ns = scriptStartTime; export default LUX; @@ -77,6 +80,5 @@ function _measure(...args: Parameters): ReturnType; @@ -59,8 +60,10 @@ export function getNavTiming( return Object.fromEntries( matches.map((str) => { - const key = str.match(/[a-z]+/)![0]; - const name = navigationTimingKeys[key as keyof typeof navigationTimingKeys]; + const keyMatch = str.match(/[a-z]+/); + const name = keyMatch + ? navigationTimingKeys[keyMatch[0] as keyof typeof navigationTimingKeys] + : "navigationStart"; const val = parseFloat(str.match(/\d+/)![0]); return [name, val]; diff --git a/tests/helpers/shared-tests.ts b/tests/helpers/shared-tests.ts index e7c87ea..58d8f6b 100644 --- a/tests/helpers/shared-tests.ts +++ b/tests/helpers/shared-tests.ts @@ -1,4 +1,6 @@ import { Page, expect } from "@playwright/test"; +import { BeaconPayload } from "../../src/beacon"; +import { SNIPPET_VERSION, VERSION } from "../../src/version"; import { getNavTiming, getPageStat, getSearchParam } from "./lux"; type SharedTestArgs = { @@ -103,3 +105,19 @@ export function testNavigationTiming({ browserName, beacon }: SharedTestArgs) { expect(NT.largestContentfulPaint).toBeGreaterThan(0); } } + +export function testPostBeacon(beacon: BeaconPayload, hasSnippet = true) { + expect(beacon.customerId).toEqual("10001"); + expect(beacon.flags).toBeGreaterThan(0); + expect(beacon.pageId).toBeTruthy(); + expect(beacon.sessionId).toBeTruthy(); + expect(beacon.measureDuration).toBeGreaterThan(0); + expect(beacon.scriptVersion).toEqual(VERSION); + + if (hasSnippet) { + // The es2020 variant is set in tests/server.mjs + expect(beacon.snippetVersion).toEqual(`${SNIPPET_VERSION}-es2020`); + } else { + expect(beacon.snippetVersion).toBeUndefined(); + } +} diff --git a/tests/helpers/types.d.ts b/tests/helpers/types.d.ts new file mode 100644 index 0000000..8a71033 --- /dev/null +++ b/tests/helpers/types.d.ts @@ -0,0 +1 @@ +export type Writable = { -readonly [K in keyof T]: T[K] }; diff --git a/tests/integration/bf-cache.spec.ts b/tests/integration/bf-cache.spec.ts index d1171f7..82bb612 100644 --- a/tests/integration/bf-cache.spec.ts +++ b/tests/integration/bf-cache.spec.ts @@ -110,7 +110,7 @@ test.describe("BF cache integration", () => { const firstET = parseUserTiming(getSearchParam(firstBeacon, "ET")); const bfcET = parseUserTiming(getSearchParam(bfcBeacon, "ET")); - expect(firstET["eve-image"].startTime).toBeGreaterThanOrEqual(getNavTiming(firstBeacon, "le")!); + expect(firstET["eve-image"].startTime).toBeGreaterThanOrEqual(getNavTiming(firstBeacon, "ls")!); expect(firstET["eve-image-delayed"]).toBeUndefined(); expect(bfcET["eve-image"].startTime).toEqual(0); diff --git a/tests/integration/default-metrics.spec.ts b/tests/integration/default-metrics.spec.ts index 695f871..4d39a2b 100644 --- a/tests/integration/default-metrics.spec.ts +++ b/tests/integration/default-metrics.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { versionAsFloat } from "../../src/version"; +import { SNIPPET_VERSION, versionAsFloat } from "../../src/version"; import { getLuxJsStat, getSearchParam } from "../helpers/lux"; import * as Shared from "../helpers/shared-tests"; import RequestInterceptor from "../request-interceptor"; @@ -14,8 +14,10 @@ test.describe("Default metrics in auto mode", () => { // LUX beacon is automatically sent expect(luxRequests.count()).toEqual(1); - // LUX version is included in the beacon + // Script and snippet versions are included in the beacon expect(getSearchParam(beacon, "v")).toEqual(versionAsFloat().toString()); + // The es2020 variant is set in tests/server.mjs + expect(getSearchParam(beacon, "sv")).toEqual(`${SNIPPET_VERSION}-es2020`); // customer ID is detected correctly expect(getSearchParam(beacon, "id")).toEqual("10001"); diff --git a/tests/integration/events.spec.ts b/tests/integration/events.spec.ts index 0121c10..cd675f5 100644 --- a/tests/integration/events.spec.ts +++ b/tests/integration/events.spec.ts @@ -1,7 +1,16 @@ import { test, expect } from "@playwright/test"; +import { BeaconPayload } from "../../src/beacon"; import { getSearchParam } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; +declare global { + interface Window { + page_id: string; + beacon_url: string; + payload: BeaconPayload; + } +} + test.describe("LUX events", () => { test("new_page_id", async ({ page }) => { const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -28,14 +37,24 @@ test.describe("LUX events", () => { }); test("beacon", async ({ page }) => { + const onBeacon = ` + (beacon) => { + if (typeof beacon === "string") { + window.beacon_url = beacon; + } else { + window.payload = beacon; + } + } + `; + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); - await page.goto( - "/default.html?injectScript=LUX.auto=false;LUX.on('beacon', (url) => window.beacon_url = url);", - { waitUntil: "networkidle" }, - ); + await page.goto(`/default.html?injectScript=LUX.auto=false;LUX.on('beacon', ${onBeacon});`, { + waitUntil: "networkidle", + }); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); const beacon = luxRequests.getUrl(0)!; + const payload = await page.evaluate(() => window.payload); let beaconUrl = await page.evaluate(() => window.beacon_url); // We don't encode the Delivery Type parameter before sending the beacon, but Chromium seems to @@ -44,5 +63,6 @@ test.describe("LUX events", () => { beaconUrl = beaconUrl.replace("dt(empty string)_", "dt(empty%20string)_"); expect(beaconUrl).toEqual(beacon.href); + expect(payload.customerId).toEqual("10001"); }); }); diff --git a/tests/integration/page-label.spec.ts b/tests/integration/page-label.spec.ts index 4831397..352f92e 100644 --- a/tests/integration/page-label.spec.ts +++ b/tests/integration/page-label.spec.ts @@ -2,6 +2,14 @@ import { test, expect } from "@playwright/test"; import Flags from "../../src/flags"; import { hasFlag } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; +import type RequestMatcher from "../request-matcher"; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: Record; + } +} test.describe("LUX page labels in auto mode", () => { test("no custom label set", async ({ page }) => { @@ -140,7 +148,7 @@ test.describe("LUX page labels in a SPA", () => { }); test.describe("LUX JS page label", () => { - let luxRequests; + let luxRequests: RequestMatcher; test.beforeEach(async ({ page }) => { luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -182,7 +190,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("Another JS Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("Another JS Label"); }); test("the variable can be changed on the fly", async ({ page }) => { @@ -194,7 +202,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("First JS Label"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("First JS Label"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => { @@ -205,7 +213,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("Different Variable Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("Different Variable Label"); // Restore jspagelabel to previous state await page.evaluate(() => (LUX.jspagelabel = "window.config.page[0].name")); @@ -220,7 +228,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("custom label"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("custom label"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => { @@ -231,7 +239,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("JS Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("JS Label"); }); test("LUX.pagegroups takes priority over JS page label", async ({ page }) => { @@ -243,7 +251,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("Pagegroup"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("Pagegroup"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => { @@ -254,7 +262,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("JS Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("JS Label"); }); test("falls back to JS variable when pagegroup doesn't match", async ({ page }) => { @@ -267,7 +275,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("JS Label"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("JS Label"); }); test("falls back to document title when JS variable doesn't eval", async ({ page }) => { @@ -279,7 +287,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("LUX default test page"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("LUX default test page"); }); test("falls back to document title when JS variable evaluates to a falsey value", async ({ @@ -293,7 +301,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("LUX default test page"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("LUX default test page"); }); test("internal LUX variables can't be accessed", async ({ page }) => { @@ -305,6 +313,6 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("LUX default test page"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("LUX default test page"); }); }); diff --git a/tests/integration/post-beacon/beacon-request.spec.ts b/tests/integration/post-beacon/beacon-request.spec.ts index bbfcca5..7cbf476 100644 --- a/tests/integration/post-beacon/beacon-request.spec.ts +++ b/tests/integration/post-beacon/beacon-request.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import { BeaconPayload } from "../../../src/beacon"; -import { VERSION } from "../../../src/version"; import { getElapsedMs } from "../../helpers/lux"; +import * as Shared from "../../helpers/shared-tests"; import RequestInterceptor from "../../request-interceptor"; /** @@ -25,13 +25,31 @@ test.describe("POST beacon request", () => { await luxRequests.waitForMatchingRequest(() => page.goto("/")); const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload; - expect(b.customerId).toEqual("10001"); - expect(b.flags).toBeGreaterThan(0); - expect(b.pageId).toBeTruthy(); - expect(b.sessionId).toBeTruthy(); - expect(b.measureDuration).toBeGreaterThan(0); - expect(b.scriptVersion).toEqual(VERSION); expect(b.startTime).toEqual(0); + Shared.testPostBeacon(b); + }); + + test("beacon metadata works when the lux.js script is loaded before the snippet", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/"); + await page.goto("/images.html?noInlineSnippet", { waitUntil: "networkidle" }); + await page.addScriptTag({ url: "/js/snippet.js" }); + await luxRequests.waitForMatchingRequest(() => page.goto("/")); + + const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload; + expect(b.startTime).toEqual(0); + Shared.testPostBeacon(b); + }); + + test("beacon metadata works when there is no snippet", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/"); + await page.goto("/images.html?noInlineSnippet", { waitUntil: "networkidle" }); + await luxRequests.waitForMatchingRequest(() => page.goto("/")); + + const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload; + expect(b.startTime).toEqual(0); + Shared.testPostBeacon(b, false); }); test("beacon metadata is sent for SPAs", async ({ page }) => { @@ -49,21 +67,8 @@ test.describe("POST beacon request", () => { expect(luxRequests.count()).toEqual(2); const b = luxRequests.get(1)!.postDataJSON() as BeaconPayload; - expect(b.customerId).toEqual("10001"); - expect(b.flags).toBeGreaterThan(0); - expect(b.pageId).toBeTruthy(); - expect(b.sessionId).toBeTruthy(); - expect(b.measureDuration).toBeGreaterThan(0); - expect(b.scriptVersion).toEqual(VERSION); expect(b.startTime).toBeGreaterThanOrEqual(timeBeforeInit); - }); - - test("the beacon is sent when LUX.init() is called", async ({ page }) => { - const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/"); - await page.goto("/images.html", { waitUntil: "networkidle" }); - await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.init())); - - expect(luxRequests.count()).toEqual(1); + Shared.testPostBeacon(b); }); test("the beacon is not sent when LUX.auto is false", async ({ page }) => { diff --git a/tests/integration/snippet.spec.ts b/tests/integration/snippet.spec.ts index 77b6b9f..997ef25 100644 --- a/tests/integration/snippet.spec.ts +++ b/tests/integration/snippet.spec.ts @@ -9,6 +9,15 @@ import { } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; +declare global { + interface Window { + beforeMeasureTime: number; + loadTime: number; + markTime: number; + startSoftNavTime: number; + } +} + test.describe("LUX inline snippet", () => { test("LUX.markLoadTime works before the script is loaded", async ({ page }) => { const beaconRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -20,13 +29,13 @@ test.describe("LUX inline snippet", () => { const beacon = beaconRequests.getUrl(0)!; const loadEventStart = getNavTiming(beacon, "le") || 0; - const loadTime = await page.evaluate(() => window.loadTime as number); + const loadTime = await page.evaluate(() => Math.floor(window.loadTime)); const loadTimeMark = await page.evaluate( (mark) => performance.getEntriesByName(mark)[0].startTime, END_MARK, ); - expect(loadTime).toBeLessThan(loadEventStart); + expect(loadTime).toBeLessThanOrEqual(loadEventStart); /** * Calling the snippet's version of markLoadTime() should cause the load time to be marked as @@ -37,8 +46,31 @@ test.describe("LUX inline snippet", () => { * so the values should be almost exactly the same. These assertions give a bit of leeway to * reduce flakiness on slower test machines. */ - expect(loadTimeMark).toBeGreaterThanOrEqual(Math.floor(loadTime)); - expect(loadTimeMark).toBeLessThan(loadTime + 5); + expect(loadTimeMark).toBeBetween(loadTime, loadTime + 5); + }); + + test("LUX.startSoftNavigation works before the script is loaded", async ({ page }) => { + const beaconRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + + await page.goto( + "/default.html?injectScript=window.startSoftNavTime=performance.now();LUX.startSoftNavigation();", + ); + + await beaconRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); + expect(beaconRequests.count()).toEqual(2); + + /** + * Similar to how we test markLoadTime() above, we keep track of the time right before + * calling startSoftNavigation() so we can compare it to the time when the soft navigation + * is recorded in the beacon. + */ + const hardNavBeacon = beaconRequests.getUrl(0)!; + const hardNavStart = getNavTiming(hardNavBeacon).navigationStart; + const softNavBeacon = beaconRequests.getUrl(1)!; + const softNavStart = getNavTiming(softNavBeacon).navigationStart; + const startSoftNavTime = await page.evaluate(() => Math.floor(window.startSoftNavTime)); + + expect(softNavStart - hardNavStart).toEqual(startSoftNavTime); }); test("LUX.mark works before the script is loaded", async ({ page }) => { @@ -55,7 +87,7 @@ test.describe("LUX inline snippet", () => { await beaconRequests.waitForMatchingRequest(); const beacon = beaconRequests.getUrl(0)!; - const markTime = (await page.evaluate(() => window.markTime)) as number; + const markTime = await page.evaluate(() => Math.floor(window.markTime)); const UT = parseUserTiming(getSearchParam(beacon, "UT")); expect(UT["mark-1"].startTime).toBeGreaterThanOrEqual(Math.floor(markTime)); @@ -83,7 +115,7 @@ test.describe("LUX inline snippet", () => { await beaconRequests.waitForMatchingRequest(); const beacon = beaconRequests.getUrl(0)!; - const beforeMeasureTime = await page.evaluate(() => window.beforeMeasureTime as number); + const beforeMeasureTime = await page.evaluate(() => Math.floor(window.beforeMeasureTime)); const connectEnd = await getNavigationTimingMs(page, "connectEnd"); const UT = parseUserTiming(getSearchParam(beacon, "UT")); diff --git a/tests/integration/spa-metrics.spec.ts b/tests/integration/spa-metrics.spec.ts index 13b07ea..a5f2616 100644 --- a/tests/integration/spa-metrics.spec.ts +++ b/tests/integration/spa-metrics.spec.ts @@ -109,7 +109,7 @@ test.describe("LUX SPA", () => { expect(luxLoadEventEnd).toEqual(pageLoadEventEnd); }); - test("load time value for subsequent pages is the time between LUX.init() and LUX.send()", async ({ + test("load time value for soft navs is the time between LUX.init() and LUX.send()", async ({ page, }) => { const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -130,8 +130,7 @@ test.describe("LUX SPA", () => { const loadEventEnd = getNavTiming(beacon, "le"); // We waited 50ms between LUX.init() and LUX.send(), so the load time should - // be at least 50ms. 60ms is an arbitrary upper limit to make sure we're not - // over-reporting load time. + // be at least 50ms, but less than the timestamp after LUX.send() was called expect(loadEventStart).toBeGreaterThanOrEqual(50); expect(loadEventStart).toBeLessThanOrEqual(timeAfterSend - timeBeforeInit); expect(loadEventStart).toEqual(loadEventEnd); @@ -142,21 +141,37 @@ test.describe("LUX SPA", () => { expect(hasFlag(beaconFlags, Flags.InitCalled)).toBe(true); }); - test("load time can be marked before the beacon is sent", async ({ page }) => { + test("LUX.markLoadTime() does not override loadEventEnd on a hard navigation", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.auto=false;"); + await page.evaluate(() => LUX.markLoadTime(12345)); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); + + const beacon = luxRequests.getUrl(0)!; + const pageLoadEventStart = await getNavigationTimingMs(page, "loadEventStart"); + const pageLoadEventEnd = await getNavigationTimingMs(page, "loadEventEnd"); + const loadEventStart = getNavTiming(beacon, "ls"); + const loadEventEnd = getNavTiming(beacon, "le"); + + expect(loadEventStart).toEqual(pageLoadEventStart); + expect(loadEventEnd).toEqual(pageLoadEventEnd); + }); + + test("LUX.markLoadTime() can set the load time on a soft navigation", async ({ page }) => { const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); await page.goto("/default.html?injectScript=LUX.auto=false;"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); await page.evaluate(() => LUX.init()); - await page.waitForTimeout(10); - await page.evaluate(() => LUX.markLoadTime()); - await page.waitForTimeout(50); + await page.evaluate(() => LUX.markLoadTime(12345)); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); const beacon = luxRequests.getUrl(1)!; - const loadEventStart = getNavTiming(beacon, "le"); + const loadEventStart = getNavTiming(beacon, "ls"); - expect(loadEventStart).toBeGreaterThanOrEqual(10); - expect(loadEventStart).toBeLessThan(50); + // In a soft nav the load time will be relative to the LUX.init call. + expect(loadEventStart).toBeBetween(12000, 12345); }); }); diff --git a/tests/integration/spa-mode.spec.ts b/tests/integration/spa-mode.spec.ts new file mode 100644 index 0000000..1082fe3 --- /dev/null +++ b/tests/integration/spa-mode.spec.ts @@ -0,0 +1,195 @@ +import { test, expect } from "@playwright/test"; +import { BeaconPayload } from "../../src/beacon"; +import Flags, { hasFlag } from "../../src/flags"; +import { entryTypeSupported } from "../helpers/browsers"; +import { + getElapsedMs, + getNavigationTimingMs, + getNavTiming, + getSearchParam, + parseUserTiming, +} from "../helpers/lux"; +import * as Shared from "../helpers/shared-tests"; +import RequestInterceptor from "../request-interceptor"; + +test.describe("LUX SPA Mode", () => { + test("beacons are only sent before page transitions and before pagehide", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;"); + await page.evaluate(() => LUX.send()); + + // Calling LUX.send() doesn't trigger a beacon + expect(luxRequests.count()).toEqual(0); + + // Calling LUX.init() to start a page transition should send the previous beacon + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.init())); + expect(luxRequests.count()).toEqual(1); + + // Calling LUX.startSoftNavigation() to start a page transition should send the previous beacon + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.startSoftNavigation())); + expect(luxRequests.count()).toEqual(2); + }); + + test("load time for soft navs is not recorded in SPA mode unless LUX.markLoadTime() is called", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;"); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + // Create a soft nav but don't call LUX.markLoadTime() + await page.evaluate(() => LUX.init()); + await page.waitForTimeout(50); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + let beacon = luxRequests.getUrl(1)!; + let beaconFlags = parseInt(getSearchParam(beacon, "fl")); + let loadEventStart = getNavTiming(beacon, "ls"); + let loadEventEnd = getNavTiming(beacon, "le"); + + expect(loadEventStart).toBeNull(); + expect(loadEventEnd).toBeNull(); + expect(hasFlag(beaconFlags, Flags.InitCalled)).toBe(true); + + // Create a soft nav and do call LUX.markLoadTime() + const timeBeforeInit = await page.evaluate(() => { + const beforeInit = Math.floor(performance.now()); + LUX.init(); + return beforeInit; + }); + await page.waitForTimeout(50); + await luxRequests.waitForMatchingRequest(() => + page.evaluate(() => { + LUX.markLoadTime(); + LUX.send(true); + }), + ); + const timeAfterSend = await getElapsedMs(page); + + beacon = luxRequests.getUrl(2)!; + beaconFlags = parseInt(getSearchParam(beacon, "fl")); + loadEventStart = getNavTiming(beacon, "ls"); + loadEventEnd = getNavTiming(beacon, "le"); + + // We called LUX.markLoadTime() after 50ms + expect(loadEventStart).toBeGreaterThanOrEqual(50); + expect(loadEventStart).toBeLessThanOrEqual(timeAfterSend - timeBeforeInit); + expect(loadEventStart).toEqual(loadEventEnd); + expect(hasFlag(beaconFlags, Flags.InitCalled)).toBe(true); + }); + + test("LUX.markLoadTime() can override loadEventEnd on a hard navigation", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;"); + await page.evaluate(() => LUX.markLoadTime(12345)); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + const beacon = luxRequests.getUrl(0)!; + const pageLoadEventStart = await getNavigationTimingMs(page, "loadEventStart"); + const loadEventStart = getNavTiming(beacon, "ls"); + const loadEventEnd = getNavTiming(beacon, "le"); + + expect(loadEventStart).toEqual(pageLoadEventStart); + expect(loadEventEnd).toEqual(12345); + }); + + test("LUX.init cannot be accidentally called on the initial navigation in SPA mode", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;", { waitUntil: "networkidle" }); + await page.evaluate(() => LUX.init()); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + const beacon = luxRequests.getUrl(0)!; + const NT = getNavTiming(beacon); + const lcpSupported = await entryTypeSupported(page, "largest-contentful-paint"); + + expect(NT.startRender).toBeGreaterThan(0); + expect(NT.firstContentfulPaint).toBeGreaterThan(0); + + if (lcpSupported) { + expect(NT.largestContentfulPaint).toBeGreaterThanOrEqual(0); + } else { + expect(NT.largestContentfulPaint).toBeUndefined(); + } + }); + + test("legacy implementations work as expected", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + + // Simulate loading lux.js with SPA mode injected by the server, and the implementor setting + // LUX.auto = false. + await page.goto("/default.html?injectScript=LUX.spaMode=true;LUX.auto=false;", { + waitUntil: "networkidle", + }); + + await page.evaluate(() => { + LUX.label = "page-01"; + performance.mark("load-marker", { startTime: 20 }); + + // Make an invalid LUX.init call during the hard navigation to test that this is still + // handled correctly. + LUX.init(); + + // This will be used as the load time but the beacon will not be sent here + LUX.send(); + + LUX.init(); + LUX.label = "page-02"; + + setTimeout(() => { + performance.mark("load-marker"); + LUX.send(); + LUX.init(); + LUX.label = "page-03"; + + setTimeout(() => { + performance.mark("load-marker"); + }, 20); + }, 20); + }); + + // Wait for the timeouts + await page.waitForTimeout(80); + + // Abandon the page before LUX.send is called for the third page load. + await page.goto("/"); + await luxRequests.waitForMatchingRequest(); + + expect(luxRequests.count()).toEqual(3); + + const beacon1 = luxRequests.getUrl(0)!; + const beacon2 = luxRequests.getUrl(1)!; + const beacon3 = luxRequests.getUrl(2)!; + const pageLoadEventEnd = await getNavigationTimingMs(page, "loadEventEnd"); + + expect(getSearchParam(beacon1, "l")).toEqual("page-01"); + expect(getSearchParam(beacon2, "l")).toEqual("page-02"); + expect(getSearchParam(beacon3, "l")).toEqual("page-03"); + + const UT1 = parseUserTiming(getSearchParam(beacon1, "UT")); + const UT2 = parseUserTiming(getSearchParam(beacon2, "UT")); + const UT3 = parseUserTiming(getSearchParam(beacon3, "UT")); + + expect(UT1["load-marker"].startTime).toEqual(20); + expect(UT2["load-marker"].startTime).toBeBetween(20, 30); + expect(UT3["load-marker"].startTime).toBeBetween(20, 30); + + expect(getNavTiming(beacon1, "le")).toBeGreaterThanOrEqual(pageLoadEventEnd); + }); + + test("MPAs still work as expected", async ({ page, browserName }) => { + const requestInterceptor = new RequestInterceptor(page); + const getBeacons = requestInterceptor.createRequestMatcher("/beacon/"); + const postBeacons = requestInterceptor.createRequestMatcher("/store/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;", { waitUntil: "networkidle" }); + await page.goto("/"); + const getBeacon = getBeacons.getUrl(0)!; + const postBeacon = postBeacons.get(0)!.postDataJSON() as BeaconPayload; + + Shared.testPageStats({ page, browserName, beacon: getBeacon }); + Shared.testNavigationTiming({ page, browserName, beacon: getBeacon }); + Shared.testPostBeacon(postBeacon); + }); +}); diff --git a/tests/playwright.d.ts b/tests/playwright.d.ts index 3c248e0..cf14e2c 100644 --- a/tests/playwright.d.ts +++ b/tests/playwright.d.ts @@ -1,6 +1,10 @@ +import type { LuxGlobal } from "../src/global"; + export {}; declare global { + declare const LUX: LuxGlobal; + namespace PlaywrightTest { interface Matchers { toBeBetween(a: number, b: number): R; diff --git a/tests/request-matcher.ts b/tests/request-matcher.ts index 85bc850..9a81532 100644 --- a/tests/request-matcher.ts +++ b/tests/request-matcher.ts @@ -35,7 +35,9 @@ export default class RequestMatcher { requestCount?: number, ): Promise; async waitForMatchingRequest(requestCount?: number): Promise; - async waitForMatchingRequest(...args): Promise { + async waitForMatchingRequest( + ...args: [(() => Promise) | number | undefined, number?] + ): Promise { let afterCb: (() => Promise) | undefined; let requestCount: number | undefined; diff --git a/tests/server.mjs b/tests/server.mjs index 287195e..42759bc 100644 --- a/tests/server.mjs +++ b/tests/server.mjs @@ -2,10 +2,10 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import path from "node:path"; -import url from "node:url"; +import { fileURLToPath } from "node:url"; import BeaconStore from "./helpers/beacon-store.js"; -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testPagesDir = path.join(__dirname, "test-pages"); const distDir = path.join(__dirname, "..", "dist"); @@ -15,27 +15,27 @@ BeaconStore.open().then(async (store) => { const server = createServer(async (req, res) => { const reqTime = new Date(); - const inlineSnippet = await readFile(path.join(distDir, "lux-snippet.js")); - const parsedUrl = url.parse(req.url, true); - const pathname = parsedUrl.pathname; + const inlineSnippet = await readFile(path.join(distDir, "lux-snippet.es2020.js")); + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; const headers = (contentType) => { const h = { - "cache-control": `public, max-age=${parsedUrl.query.maxAge || 0}`, + "cache-control": `public, max-age=${url.searchParams.get("maxAge") || 0}`, "content-type": contentType, - "server-timing": parsedUrl.query.serverTiming || "", + "server-timing": url.searchParams.get("serverTiming") || "", "timing-allow-origin": "*", }; - if (!parsedUrl.query.keepAlive) { + if (!url.searchParams.get("keepAlive")) { h.connection = "close"; } - if (parsedUrl.query.csp) { - const cspHeader = parsedUrl.query.cspReportOnly + if (url.searchParams.has("csp")) { + const cspHeader = url.searchParams.get("cspReportOnly") ? "content-security-policy-report-only" : "content-security-policy"; - h[cspHeader] = parsedUrl.query.csp; + h[cspHeader] = url.searchParams.get("csp"); } return h; @@ -43,9 +43,7 @@ BeaconStore.open().then(async (store) => { const sendResponse = async (status, headers, body) => { console.log( - [reqTime.toISOString(), status, req.method, `${pathname}${parsedUrl.search || ""}`].join( - " ", - ), + [reqTime.toISOString(), status, req.method, `${pathname}${url.search || ""}`].join(" "), ); res.writeHead(status, headers); @@ -65,16 +63,24 @@ BeaconStore.open().then(async (store) => { break; } - if (parsedUrl.query.redirectTo) { + if (url.searchParams.has("redirectTo")) { // Send the redirect after a short delay so that the redirectStart time is measurable - setTimeout(() => { - sendResponse(302, { location: decodeURIComponent(parsedUrl.query.redirectTo) }, ""); - }, parsedUrl.query.redirectDelay || 0); + const redirectLocation = decodeURIComponent(url.searchParams.get("redirectTo")); + + setTimeout( + () => { + sendResponse(302, { location: redirectLocation }, ""); + }, + url.searchParams.get("redirectDelay") || 0, + ); } else if (pathname === "/") { sendResponse(200, headers("text/plain"), "OK"); } else if (pathname === "/js/lux.min.js.map") { const contents = await readFile(path.join(distDir, "lux.min.js.map")); sendResponse(200, headers("application/json"), contents); + } else if (pathname === "/js/snippet.js") { + const contents = await readFile(path.join(distDir, "lux-snippet.es2020.js")); + sendResponse(200, headers("application/json"), contents); } else if (pathname === "/js/lux.js") { const contents = await readFile(path.join(distDir, "lux.min.js")); let preamble = [ @@ -89,16 +95,16 @@ BeaconStore.open().then(async (store) => { sendResponse(200, headers(contentType), `${preamble};${contents}`); } else if (pathname === "/beacon/" || pathname === "/error/") { if (req.headers.referer) { - const referrerUrl = url.parse(req.headers.referer, true); + const referrerUrl = new URL(req.headers.referer); - if ("useBeaconStore" in referrerUrl.query) { - store.id = referrerUrl.query.useBeaconStore; + if (referrerUrl.searchParams.has("useBeaconStore")) { + store.id = referrerUrl.searchParams.get("useBeaconStore"); store.put( reqTime.getTime(), req.headers["user-agent"], new URL(req.url, `http://${req.headers.host}`).href, - parsedUrl.query.l, - decodeURIComponent(parsedUrl.query.PN), + url.searchParams.get("l"), + decodeURIComponent(url.searchParams.get("PN")), ); } } @@ -121,25 +127,25 @@ BeaconStore.open().then(async (store) => { }; `; - if (parsedUrl.query.injectBeforeSnippet) { - injectScript += parsedUrl.query.injectBeforeSnippet; + if (url.searchParams.has("injectBeforeSnippet")) { + injectScript += url.searchParams.get("injectBeforeSnippet"); } - if (!parsedUrl.query.noInlineSnippet) { + if (!url.searchParams.has("noInlineSnippet")) { injectScript += inlineSnippet; } - if (parsedUrl.query.injectScript) { - injectScript += parsedUrl.query.injectScript; + if (url.searchParams.has("injectScript")) { + injectScript += url.searchParams.get("injectScript"); } contents = contents.toString().replace("/*INJECT_SCRIPT*/", injectScript); } - if (parsedUrl.query.delay) { + if (url.searchParams.has("delay")) { setTimeout( () => sendResponse(200, headers(contentType), contents), - parseInt(parsedUrl.query.delay), + parseInt(url.searchParams.get("delay")), ); } else { sendResponse(200, headers(contentType), contents); diff --git a/tests/test-pages/preact.js b/tests/test-pages/preact.js new file mode 100644 index 0000000..af3a959 --- /dev/null +++ b/tests/test-pages/preact.js @@ -0,0 +1,3 @@ +/* esm.sh - htm@3.1.1/preact/standalone */ +var N,f,en,D,tn,B,_n,F={},rn=[],yn=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function C(e,n){for(var t in n)e[t]=n[t];return e}function on(e){var n=e.parentNode;n&&n.removeChild(e)}function ln(e,n,t){var _,l,r,u={};for(r in n)r=="key"?_=n[r]:r=="ref"?l=n[r]:u[r]=n[r];if(arguments.length>2&&(u.children=arguments.length>3?N.call(arguments,2):t),typeof e=="function"&&e.defaultProps!=null)for(r in e.defaultProps)u[r]===void 0&&(u[r]=e.defaultProps[r]);return U(e,u,_,l,null)}function U(e,n,t,_,l){var r={type:e,props:n,key:t,ref:_,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:l??++en};return f.vnode!=null&&f.vnode(r),r}function W(e){return e.children}function A(e,n){this.props=e,this.context=n}function E(e,n){if(n==null)return e.__?E(e.__,e.__.__k.indexOf(e)+1):null;for(var t;n0?U(i.type,i.props,i.key,null,i.__v):i)!=null){if(i.__=t,i.__b=t.__b+1,(c=y[o])===null||c&&i.key==c.key&&i.type===c.type)y[o]=void 0;else for(m=0;m=t.__.length&&t.__.push({}),t.__[e]}function bn(e){return S=1,Cn(vn,e)}function Cn(e,n,t){var _=P(x++,2);return _.t=e,_.__c||(_.__=[t?t(n):vn(void 0,n),function(l){var r=_.t(_.__[0],l);_.__[0]!==r&&(_.__=[r,_.__[1]],_.__c.setState({}))}],_.__c=g),_.__}function Pn(e,n){var t=P(x++,3);!f.__s&&q(t.__H,n)&&(t.__=e,t.__H=n,g.__H.__h.push(t))}function xn(e,n){var t=P(x++,4);!f.__s&&q(t.__H,n)&&(t.__=e,t.__H=n,g.__h.push(t))}function Dn(e){return S=5,dn(function(){return{current:e}},[])}function wn(e,n,t){S=6,xn(function(){typeof e=="function"?e(n()):e&&(e.current=n())},t==null?t:t.concat(e))}function dn(e,n){var t=P(x++,7);return q(t.__H,n)&&(t.__=e(),t.__H=n,t.__h=e),t.__}function Tn(e,n){return S=8,dn(function(){return e},n)}function Un(e){var n=g.context[e.__c],t=P(x++,9);return t.c=e,n?(t.__==null&&(t.__=!0,n.sub(g)),n.props.value):e.__}function An(e,n){f.useDebugValue&&f.useDebugValue(n?n(e):e)}function Mn(e){var n=P(x++,10),t=bn();return n.__=e,g.componentDidCatch||(g.componentDidCatch=function(_){n.__&&n.__(_),t[1](_)}),[t[0],function(){t[1](void 0)}]}function Hn(){I.forEach(function(e){if(e.__P)try{e.__H.__h.forEach(M),e.__H.__h.forEach(O),e.__H.__h=[]}catch(n){e.__H.__h=[],f.__e(n,e.__v)}}),I=[]}f.__b=function(e){g=null,J&&J(e)},f.__r=function(e){K&&K(e),x=0;var n=(g=e.__c).__H;n&&(n.__h.forEach(M),n.__h.forEach(O),n.__h=[])},f.diffed=function(e){Q&&Q(e);var n=e.__c;n&&n.__H&&n.__H.__h.length&&(I.push(n)!==1&&z===f.requestAnimationFrame||((z=f.requestAnimationFrame)||function(t){var _,l=function(){clearTimeout(r),Z&&cancelAnimationFrame(_),setTimeout(t)},r=setTimeout(l,100);Z&&(_=requestAnimationFrame(l))})(Hn)),g=void 0},f.__c=function(e,n){n.some(function(t){try{t.__h.forEach(M),t.__h=t.__h.filter(function(_){return!_.__||O(_)})}catch(_){n.some(function(l){l.__h&&(l.__h=[])}),n=[],f.__e(_,t.__v)}}),X&&X(e,n)},f.unmount=function(e){Y&&Y(e);var n=e.__c;if(n&&n.__H)try{n.__H.__.forEach(M)}catch(t){f.__e(t,n.__v)}};var Z=typeof requestAnimationFrame=="function";function M(e){var n=g;typeof e.__c=="function"&&e.__c(),g=n}function O(e){var n=g;e.__c=e.__(),g=n}function q(e,n){return!e||e.length!==n.length||n.some(function(t,_){return t!==e[_]})}function vn(e,n){return typeof n=="function"?n(e):n}var mn=function(e,n,t,_){var l;n[0]=0;for(var r=1;r=5&&((u||!c&&r===5)&&(s.push(r,0,u,l),r=6),c&&(s.push(r,c,0,l),r=6)),u=""},o=0;o"?(r=1,u=""):u=_+u[0]:a?_===a?a="":u+=_:_==='"'||_==="'"?a=_:_===">"?(h(),r=1):r&&(_==="="?(r=5,l=u,u=""):_==="/"&&(r<5||t[o][m+1]===">")?(h(),r===3&&(s=s[0]),r=s,(s=s[0]).push(2,0,r),r=0):_===" "||_===" "||_===` +`||_==="\r"?(h(),r=2):u+=_),r===3&&u==="!--"&&(r=4,s=s[0])}return h(),s}(e)),n),arguments,[])).length>1?n:n[0]}.bind(ln);export{A as Component,Sn as createContext,ln as h,Fn as html,En as render,Tn as useCallback,Un as useContext,An as useDebugValue,Pn as useEffect,Mn as useErrorBoundary,wn as useImperativeHandle,xn as useLayoutEffect,dn as useMemo,Cn as useReducer,Dn as useRef,bn as useState}; diff --git a/tests/test-pages/spa.html b/tests/test-pages/spa.html new file mode 100644 index 0000000..9b4427a --- /dev/null +++ b/tests/test-pages/spa.html @@ -0,0 +1,167 @@ + + + + + LUX SPA test page + + + + + +

LUX SPA test page

+ +
+ + + + + diff --git a/tests/unit/CLS.test.ts b/tests/unit/CLS.test.ts index ea1f403..43ffe4d 100644 --- a/tests/unit/CLS.test.ts +++ b/tests/unit/CLS.test.ts @@ -2,6 +2,12 @@ import { describe, expect, test } from "@jest/globals"; import * as Config from "../../src/config"; import * as CLS from "../../src/metric/CLS"; +declare global { + interface Window { + LayoutShift: () => void; + } +} + const config = Config.fromObject({}); // Mock LayoutShift support so the CLS.getData() returns a value. diff --git a/tests/unit/INP.test.ts b/tests/unit/INP.test.ts index e302a0d..637a5a2 100644 --- a/tests/unit/INP.test.ts +++ b/tests/unit/INP.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from "@jest/globals"; import * as Config from "../../src/config"; import * as INP from "../../src/metric/INP"; import "../../src/window.d.ts"; +import { Writable } from "../helpers/types"; const config = Config.fromObject({}); @@ -137,7 +138,7 @@ describe("INP", () => { }); }); -function makeEntry(props: Partial): PerformanceEventTiming { +function makeEntry(props: Partial): Writable { return { interactionId: 0, duration: 0, diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 6df629e..e75ad38 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -8,15 +8,19 @@ describe("Config.fromObject()", () => { expect(config.auto).toEqual(true); expect(config.beaconUrl).toEqual("https://lux.speedcurve.com/lux/"); expect(config.customerid).toBeUndefined(); + expect(config.errorBeaconUrl).toEqual("https://lux.speedcurve.com/error/"); expect(config.jspagelabel).toBeUndefined(); expect(config.label).toBeUndefined(); expect(config.maxErrors).toEqual(5); expect(config.maxMeasureTime).toEqual(60_000); expect(config.measureUntil).toEqual("onload"); expect(config.minMeasureTime).toEqual(0); + expect(config.newBeaconOnPageShow).toEqual(false); expect(config.samplerate).toEqual(100); expect(config.sendBeaconOnPageHidden).toEqual(true); + expect(config.spaMode).toEqual(false); expect(config.trackErrors).toEqual(true); + expect(config.trackHiddenPages).toEqual(false); }); test("it uses values from the config object when they are provided", () => { @@ -53,4 +57,26 @@ describe("Config.fromObject()", () => { expect(config.sendBeaconOnPageHidden).toEqual(true); }); + + test("it sets sensible defaults in SPA mode", () => { + const config = Config.fromObject({ + spaMode: true, + }); + + expect(config.auto).toEqual(false); + expect(config.measureUntil).toEqual("pagehidden"); + expect(config.sendBeaconOnPageHidden).toEqual(true); + expect(config.spaMode).toEqual(true); + }); + + test("SPA mode defaults can be overridden, except for auto", () => { + const config = Config.fromObject({ + auto: true, + spaMode: true, + sendBeaconOnPageHidden: false, + }); + + expect(config.auto).toEqual(false); + expect(config.sendBeaconOnPageHidden).toEqual(false); + }); }); diff --git a/tsconfig.json b/tsconfig.json index de84c4e..38927ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2022", + "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "skipLibCheck": true, "noEmit": true, @@ -16,10 +16,11 @@ "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, + "resolveJsonModule": true, "strictBindCallApply": true, "strictFunctionTypes": true, "strictNullChecks": true, "strictPropertyInitialization": true }, - "include": ["src/**/*", "docs/**/*"], + "include": ["src/**/*", "tests/**/*", "docs/**/*"], }