diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index 1f98b357..ae01f8ac 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -115,7 +115,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | | `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | -Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capability), so no privacy transforms are applied. +Note: GTM, Segment, Crisp, Mixpanel, Bing UET, and SpeedCurve have no proxy capability, so no privacy transforms are applied. ## Script Support @@ -150,6 +150,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa | `googleTagManager` | googleTagManager | n/a | Bundle only | | `segment` | segment | n/a | Bundle only | | `crisp` | crisp | n/a | Bundle only | +| `speedcurve` | speedcurve | n/a | No proxy (ID-parameterized CDN URL) | ### Excluded from first-party mode (`proxy: false`) diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md new file mode 100644 index 00000000..68bd3f83 --- /dev/null +++ b/docs/content/scripts/speedcurve.md @@ -0,0 +1,108 @@ +--- +title: SpeedCurve LUX +description: Use SpeedCurve LUX Real User Monitoring in your Nuxt app to measure performance experienced by real users, with automatic SPA navigation tracking. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/speedcurve.ts + size: xs +--- + +[SpeedCurve LUX](https://speedcurve.com/features/lux/) is a Real User Monitoring (RUM) tool that measures the performance your users experience. It tracks Core Web Vitals, custom timing marks, and JavaScript errors. + +::script-stats +:: + +::script-docs +:: + +The composable comes with the following defaults: + +- **Trigger: Client** The LUX primer is injected into ``{lang="html"} immediately; `lux.js` loads when Nuxt hydrates. + +You can access the `LUX` object as a proxy directly, or await `$script` to get the loaded instance. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptSpeedCurve({ id: 'YOUR_ID' }) +proxy.LUX.label = 'my-page' +``` + +```ts [onLoaded] +const { onLoaded } = useScriptSpeedCurve({ id: 'YOUR_ID' }) +onLoaded(({ LUX }) => { + LUX.label = 'my-page' +}) +``` + +:: + +## SPA navigation + +Set `spaMode: true` to enable SpeedCurve's SPA tracking mode. The composable wires Vue Router automatically: + +- `router.beforeEach` calls `LUX.startSoftNavigation()`{lang="ts"} (closes the previous beacon, starts a new one) +- `nuxt.hook('page:finish')`{lang="ts"} calls `LUX.markLoadTime()`{lang="ts"} after the next paint (sets the END mark) +- Cancelled navigations seal the phantom beacon with `addData('luxNavFailed', '1')`{lang="ts"} for easy filtering + +```ts [app.vue] +useScriptSpeedCurve({ + id: 'YOUR_ID', + spaMode: true, + autoTrackSpaNavigations: true, // default when spaMode is true +}) +``` + +To disable auto-wiring and instrument manually: + +```ts +useScriptSpeedCurve({ + id: 'YOUR_ID', + spaMode: true, + autoTrackSpaNavigations: false, +}) +// Then call LUX.startSoftNavigation() and LUX.markLoadTime() yourself +``` + +## Custom page labels + +By default the composable uses `String(to.name ?? to.path)`{lang="ts"} as the page label for each navigation. Pass a function to `label` to override it: + +```ts +useScriptSpeedCurve({ + id: 'YOUR_ID', + spaMode: true, + label: to => to.meta.title as string ?? to.path, +}) +``` + +Set `label: false` to disable labeling entirely. Pass a plain string to set a static label (only meaningful without `spaMode`, since the router hook overwrites it on every navigation). + +## CSP + +Add these directives to your Content Security Policy: + +```text +script-src cdn.speedcurve.com; +img-src lux.speedcurve.com; +connect-src lux.speedcurve.com beacon.speedcurve.com; +``` + +Reference: https://support.speedcurve.com/docs/add-rum-to-your-csp + +::script-types +:: + +## Example + +Loading SpeedCurve LUX through `app.vue` with SPA tracking enabled. + +```vue [app.vue] + +``` diff --git a/package.json b/package.json index 4a764bd1..c4c43c6c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics && nuxt prepare test/fixtures/speedcurve", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/packages/script/package.json b/packages/script/package.json index 414a4fcb..6f6e7e19 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -68,6 +68,7 @@ "unimport", "#nuxt-scripts/types", "posthog-js", + "@speedcurve/lux", "@nuxt/devtools-kit", "sirv" ] @@ -75,6 +76,7 @@ "peerDependencies": { "@googlemaps/markerclusterer": "^2.6.2", "@paypal/paypal-js": "^8.1.2 || ^9.0.0", + "@speedcurve/lux": "^4.4.3", "@stripe/stripe-js": "^7.0.0 || ^8.0.0 || ^9.0.0", "@types/google.maps": "^3.58.1", "@types/vimeo__player": "^2.18.3", @@ -89,6 +91,9 @@ "@paypal/paypal-js": { "optional": true }, + "@speedcurve/lux": { + "optional": true + }, "@stripe/stripe-js": { "optional": true }, @@ -130,6 +135,7 @@ "devDependencies": { "@nuxt/kit": "catalog:", "@nuxt/module-builder": "catalog:", + "@speedcurve/lux": "catalog:", "rollup": "catalog:", "unbuild": "catalog:", "unimport": "catalog:" diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 92c69125..780d4d93 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -64,4 +64,8 @@ export const LOGOS = { usercentrics: ``, bingUet: ``, snapchatPixel: ``, + speedcurve: { + light: ``, + dark: ``, + }, } satisfies Record diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 27bc4f7e..e023701b 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -966,6 +966,18 @@ "code": "export interface SnapPixelApi {\n snaptr: SnapTrFns & {\n push: SnapTrFns\n loaded: boolean\n version: string\n queue: any[]\n }\n _snaptr: SnapPixelApi['snaptr']\n handleRequest?: SnapTrFns\n}" } ], + "speedcurve": [ + { + "name": "SpeedCurveOptions", + "kind": "const", + "code": "export const SpeedCurveOptions = object({\n /**\n * Your SpeedCurve customer ID.\n * @see https://support.speedcurve.com/docs/add-rum-to-your-site\n */\n id: pipe(string(), minLength(1)),\n /**\n * Enable SPA (single-page application) mode.\n * When true, lux.js tracks soft navigations instead of full page loads.\n * @see https://support.speedcurve.com/docs/single-page-applications\n */\n spaMode: optional(boolean()),\n /**\n * Automatically wire Vue Router hooks for SPA tracking when spaMode is true.\n * Set to false to instrument navigations manually.\n * @default true (when spaMode is true)\n */\n autoTrackSpaNavigations: optional(boolean()),\n /**\n * Page label shown in the SpeedCurve dashboard.\n * Accepts a static string, a function `(to) => string` for per-navigation labels,\n * or `false` to disable labeling entirely.\n * @default String(to.name ?? to.path)\n */\n label: optional(union([string(), function_(), literal(false)])),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(pipe(number(), minValue(0), maxValue(100))),\n /**\n * Send the beacon when the page is hidden (pagehide event).\n * @default true\n */\n sendBeaconOnPageHidden: optional(boolean()),\n /**\n * Track JavaScript errors.\n * @default true\n */\n trackErrors: optional(boolean()),\n /**\n * Maximum number of errors to track per page view.\n * @default 5\n */\n maxErrors: optional(number()),\n /**\n * Minimum time (ms) before a beacon can be sent.\n */\n minMeasureTime: optional(number()),\n /**\n * Maximum time (ms) after which the beacon is sent regardless of load state.\n * @default 60000\n */\n maxMeasureTime: optional(number()),\n /**\n * Start a new beacon when the page becomes visible after being hidden.\n */\n newBeaconOnPageShow: optional(boolean()),\n /**\n * Track pages loaded in background tabs.\n * @default false\n */\n trackHiddenPages: optional(boolean()),\n /**\n * Cookie domain for cross-subdomain session tracking.\n */\n cookieDomain: optional(string()),\n})" + }, + { + "name": "SpeedCurveApi", + "kind": "interface", + "code": "export interface SpeedCurveApi {\n LUX: LuxGlobal\n}" + } + ], "stripe": [ { "name": "StripeOptions", @@ -2360,6 +2372,93 @@ "description": "The user's age." } ], + "SpeedCurveOptions": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Your SpeedCurve customer ID." + }, + { + "name": "spaMode", + "type": "boolean", + "required": false, + "description": "Enable SPA (single-page application) mode. When true, lux.js tracks soft navigations instead of full page loads." + }, + { + "name": "autoTrackSpaNavigations", + "type": "boolean", + "required": false, + "description": "Automatically wire Vue Router hooks for SPA tracking when spaMode is true. Set to false to instrument navigations manually.", + "defaultValue": "true (when spaMode is true)" + }, + { + "name": "label", + "type": "string | Function | false", + "required": false, + "description": "Page label shown in the SpeedCurve dashboard. Accepts a static string, a function `(to) => string` for per-navigation labels, or `false` to disable labeling entirely.", + "defaultValue": "String(to.name ?? to.path)" + }, + { + "name": "samplerate", + "type": "number", + "required": false, + "description": "Sampling rate (0–100). Percentage of sessions that send beacons. Upstream spelling is lowercase — matches LUX UserConfig." + }, + { + "name": "sendBeaconOnPageHidden", + "type": "boolean", + "required": false, + "description": "Send the beacon when the page is hidden (pagehide event).", + "defaultValue": "true" + }, + { + "name": "trackErrors", + "type": "boolean", + "required": false, + "description": "Track JavaScript errors.", + "defaultValue": "true" + }, + { + "name": "maxErrors", + "type": "number", + "required": false, + "description": "Maximum number of errors to track per page view.", + "defaultValue": "5" + }, + { + "name": "minMeasureTime", + "type": "number", + "required": false, + "description": "Minimum time (ms) before a beacon can be sent." + }, + { + "name": "maxMeasureTime", + "type": "number", + "required": false, + "description": "Maximum time (ms) after which the beacon is sent regardless of load state.", + "defaultValue": "60000" + }, + { + "name": "newBeaconOnPageShow", + "type": "boolean", + "required": false, + "description": "Start a new beacon when the page becomes visible after being hidden." + }, + { + "name": "trackHiddenPages", + "type": "boolean", + "required": false, + "description": "Track pages loaded in background tabs.", + "defaultValue": "false" + }, + { + "name": "cookieDomain", + "type": "string", + "required": false, + "description": "Cookie domain for cross-subdomain session tracking." + } + ], "SnapTrPixelOptions": [ { "name": "id", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 8bb07b44..f6296162 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -42,6 +42,7 @@ import { RybbitAnalyticsOptions, SegmentOptions, SnapTrPixelOptions, + SpeedCurveOptions, StripeOptions, TikTokPixelOptions, UmamiAnalyticsOptions, @@ -173,6 +174,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // Usercentrics is the consent layer itself: must hit the vendor origin so // signature/policy lookups succeed. Bundle/proxy are intentionally absent. m('usercentrics', 'Usercentrics', 'utility', 'useScriptUsercentrics', {}, null), + m('speedcurve', 'SpeedCurve LUX', 'analytics', 'useScriptSpeedCurve', {}, null), ] export const REGISTRY_CATEGORIES = [ @@ -851,6 +853,14 @@ export async function registry(resolve?: (path: string) => Promise): Pro { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy', requiresSigning: true }, ], }), + def('speedcurve', { + schema: SpeedCurveOptions, + label: 'SpeedCurve LUX', + src: false, + category: 'analytics', + envDefaults: { id: '' }, + composableName: 'useScriptSpeedCurve', + }), ]) } diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index d6be8661..4843348b 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1,4 +1,4 @@ -import { any, array, boolean, custom, literal, minLength, number, object, optional, pipe, record, string, union } from 'valibot' +import { any, array, boolean, custom, function_, literal, maxValue, minLength, minValue, number, object, optional, pipe, record, string, union } from 'valibot' // Shared GCMv2 consent category value. const consentCategoryValue = union([literal('granted'), literal('denied')]) @@ -1007,6 +1007,75 @@ export const InitObjectPropertiesSchema = object({ age: optional(string()), }) +export const SpeedCurveOptions = object({ + /** + * Your SpeedCurve customer ID. + * @see https://support.speedcurve.com/docs/add-rum-to-your-site + */ + id: pipe(string(), minLength(1)), + /** + * Enable SPA (single-page application) mode. + * When true, lux.js tracks soft navigations instead of full page loads. + * @see https://support.speedcurve.com/docs/single-page-applications + */ + spaMode: optional(boolean()), + /** + * Automatically wire Vue Router hooks for SPA tracking when spaMode is true. + * Set to false to instrument navigations manually. + * @default true (when spaMode is true) + */ + autoTrackSpaNavigations: optional(boolean()), + /** + * Page label shown in the SpeedCurve dashboard. + * Accepts a static string, a function `(to) => string` for per-navigation labels, + * or `false` to disable labeling entirely. + * @default String(to.name ?? to.path) + */ + label: optional(union([string(), function_(), literal(false)])), + /** + * Sampling rate (0–100). Percentage of sessions that send beacons. + * Upstream spelling is lowercase — matches LUX UserConfig. + */ + samplerate: optional(pipe(number(), minValue(0), maxValue(100))), + /** + * Send the beacon when the page is hidden (pagehide event). + * @default true + */ + sendBeaconOnPageHidden: optional(boolean()), + /** + * Track JavaScript errors. + * @default true + */ + trackErrors: optional(boolean()), + /** + * Maximum number of errors to track per page view. + * @default 5 + */ + maxErrors: optional(number()), + /** + * Minimum time (ms) before a beacon can be sent. + */ + minMeasureTime: optional(number()), + /** + * Maximum time (ms) after which the beacon is sent regardless of load state. + * @default 60000 + */ + maxMeasureTime: optional(number()), + /** + * Start a new beacon when the page becomes visible after being hidden. + */ + newBeaconOnPageShow: optional(boolean()), + /** + * Track pages loaded in background tabs. + * @default false + */ + trackHiddenPages: optional(boolean()), + /** + * Cookie domain for cross-subdomain session tracking. + */ + cookieDomain: optional(string()), +}) + export const SnapTrPixelOptions = object({ /** * Your Snapchat Pixel ID. diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts new file mode 100644 index 00000000..18c82db0 --- /dev/null +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -0,0 +1,135 @@ +import type { LuxGlobal, UserConfig } from '@speedcurve/lux' +import type { RouteLocationNormalized } from 'vue-router' +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import luxSnippetSource from '@speedcurve/lux/dist/lux-snippet.js?raw' +import { useHead, useNuxtApp, useRouter } from 'nuxt/app' +import { useRegistryScript } from '../utils' +import { afterNextPaint } from '../utils/after-next-paint' +import { SpeedCurveOptions } from './schemas' + +export { SpeedCurveOptions } + +export interface SpeedCurveApi { + LUX: LuxGlobal +} + +declare global { + interface Window { + LUX?: LuxGlobal + LUX_ae?: ErrorEvent[] + LUX_al?: PerformanceEntry[] + } +} + +export type SpeedCurveInput = Omit, 'label'> & Omit & { + label?: string | ((to: RouteLocationNormalized) => string | false) | false +} + +// Derived from the schema: all schema keys except the composable-only ones. +const LUX_USER_CONFIG_KEYS = Object.keys(SpeedCurveOptions.entries).filter( + k => k !== 'id' && k !== 'autoTrackSpaNavigations' && k !== 'spaMode', +) as (keyof UserConfig)[] + +let luxWired = false + +export function useScriptSpeedCurve(_options?: SpeedCurveInput): UseScriptContext { + return useRegistryScript('speedcurve', options => ({ + scriptInput: { + src: `https://cdn.speedcurve.com/js/lux.js?id=${options.id}`, + crossorigin: 'anonymous', + }, + schema: import.meta.dev ? SpeedCurveOptions : undefined, + scriptOptions: { + trigger: 'client', + use: () => ({ LUX: window.LUX! } as T), + beforeInit: () => { + useHead({ + script: [{ + key: 'speedcurve-lux-primer', + tagPosition: 'head', + tagPriority: 'critical', + innerHTML: luxSnippetSource, + }], + }) + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + const input = options as unknown as SpeedCurveInput + applyConfig(input) + if (options.spaMode && options.autoTrackSpaNavigations !== false) + installAutoTracker(input) + }, + }), _options as unknown as RegistryScriptInput) as UseScriptContext +} + +export function applyConfig(options: SpeedCurveInput): void { + const lux = window.LUX as Record | undefined + if (!lux) + return + for (const k of LUX_USER_CONFIG_KEYS) { + const v = (options as Record)[k as string] + if (v === undefined) + continue + // label may be a function or false (handled by installAutoTracker); only pass strings to LUX directly + if (k === 'label' && typeof v !== 'string') + continue + lux[k as string] = v + } +} + +export function installAutoTracker(options?: SpeedCurveInput): void { + if (luxWired) + return + luxWired = true + + const router = useRouter() + const nuxt = useNuxtApp() + + // For SPA-only apps (no SSR), the first beforeEach fires for the initial + // client-side render — not a user navigation. Skip startSoftNavigation. + let pendingInitial = nuxt.payload?.serverRendered === false + + const label = options?.label === false + ? null + : typeof options?.label === 'function' + ? options.label + : (to: RouteLocationNormalized) => String(to.name ?? to.path) + + router.beforeEach((to) => { + const lux = window.LUX + if (!lux) + return + if (pendingInitial) { + pendingInitial = false + if (label) { + const nextLabel = label(to) + if (nextLabel !== false) + lux.label = nextLabel + } + return + } + lux.startSoftNavigation() + if (label) { + const nextLabel = label(to) + if (nextLabel !== false) + lux.label = nextLabel + } + }) + + // If a guard cancels navigation, seal the phantom beacon with a filterable tag. + router.afterEach((_to, _from, failure) => { + if (!failure) + return + const lux = window.LUX + if (!lux) + return + lux.markLoadTime() + lux.addData('luxNavFailed', '1') + }) + + nuxt.hook('page:finish', () => { + afterNextPaint(() => window.LUX?.markLoadTime()) + }) +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 8419fc27..3bbd1bfa 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -37,6 +37,7 @@ import type { RedditPixelInput } from './registry/reddit-pixel' import type { RybbitAnalyticsInput } from './registry/rybbit-analytics' import type { SegmentInput } from './registry/segment' import type { SnapTrPixelInput } from './registry/snapchat-pixel' +import type { SpeedCurveInput } from './registry/speedcurve' import type { StripeInput } from './registry/stripe' import type { TikTokPixelInput } from './registry/tiktok-pixel' import type { UmamiAnalyticsInput } from './registry/umami-analytics' @@ -275,6 +276,7 @@ export interface ScriptRegistry { rybbitAnalytics?: RybbitAnalyticsInput redditPixel?: RedditPixelInput segment?: SegmentInput + speedcurve?: SpeedCurveInput stripe?: StripeInput tiktokPixel?: TikTokPixelInput xEmbed?: XEmbedInput @@ -301,7 +303,7 @@ export type BuiltInRegistryScriptKey | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' - | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'youtubePlayer' | 'vercelAnalytics' + | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'speedcurve' | 'youtubePlayer' | 'vercelAnalytics' | 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm' /** diff --git a/packages/script/src/runtime/utils/after-next-paint.ts b/packages/script/src/runtime/utils/after-next-paint.ts new file mode 100644 index 00000000..e2633f87 --- /dev/null +++ b/packages/script/src/runtime/utils/after-next-paint.ts @@ -0,0 +1,5 @@ +// One rAF fires before the next paint; two fires after it has committed to screen. +// Same pattern used by web-vitals.js — no Nuxt/Vue equivalent exists. +export function afterNextPaint(callback: () => void): void { + requestAnimationFrame(() => requestAnimationFrame(callback)) +} diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index d0cb7445..5148ebe1 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -5,7 +5,7 @@ export type TrackedDataType | 'session-replay' | 'heatmaps' | 'clicks' | 'scrolls' | 'retargeting' | 'audiences' | 'form-submissions' | 'video-engagement' | 'transactions' | 'errors' - | 'tag-injection' | 'ab-testing' + | 'tag-injection' | 'ab-testing' | 'performance-timings' export interface ScriptMeta { /** Canonical script URL(s) to fetch for size measurement */ @@ -223,4 +223,10 @@ export const scriptMeta = { urls: ['https://secure.gravatar.com/js/gprofiles.js'], trackedData: [], }, + + // Performance monitoring + speedcurve: { + urls: ['https://cdn.speedcurve.com/js/lux.js'], + trackedData: ['page-views', 'performance-timings', 'errors'], + }, } satisfies Record diff --git a/playground/pages/third-parties/speed-curve/manual-spa/a.vue b/playground/pages/third-parties/speed-curve/manual-spa/a.vue new file mode 100644 index 00000000..b7a839ec --- /dev/null +++ b/playground/pages/third-parties/speed-curve/manual-spa/a.vue @@ -0,0 +1,42 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/manual-spa/b.vue b/playground/pages/third-parties/speed-curve/manual-spa/b.vue new file mode 100644 index 00000000..ae53996e --- /dev/null +++ b/playground/pages/third-parties/speed-curve/manual-spa/b.vue @@ -0,0 +1,42 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/manual-spa/c.vue b/playground/pages/third-parties/speed-curve/manual-spa/c.vue new file mode 100644 index 00000000..8a4b7a43 --- /dev/null +++ b/playground/pages/third-parties/speed-curve/manual-spa/c.vue @@ -0,0 +1,42 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/no-spa/a.vue b/playground/pages/third-parties/speed-curve/no-spa/a.vue new file mode 100644 index 00000000..72b5b24f --- /dev/null +++ b/playground/pages/third-parties/speed-curve/no-spa/a.vue @@ -0,0 +1,27 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/no-spa/b.vue b/playground/pages/third-parties/speed-curve/no-spa/b.vue new file mode 100644 index 00000000..29a5772e --- /dev/null +++ b/playground/pages/third-parties/speed-curve/no-spa/b.vue @@ -0,0 +1,25 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/no-spa/c.vue b/playground/pages/third-parties/speed-curve/no-spa/c.vue new file mode 100644 index 00000000..9e3ad1a0 --- /dev/null +++ b/playground/pages/third-parties/speed-curve/no-spa/c.vue @@ -0,0 +1,25 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/spa-auto/a.vue b/playground/pages/third-parties/speed-curve/spa-auto/a.vue new file mode 100644 index 00000000..b4036298 --- /dev/null +++ b/playground/pages/third-parties/speed-curve/spa-auto/a.vue @@ -0,0 +1,34 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/spa-auto/b.vue b/playground/pages/third-parties/speed-curve/spa-auto/b.vue new file mode 100644 index 00000000..d33f9ac2 --- /dev/null +++ b/playground/pages/third-parties/speed-curve/spa-auto/b.vue @@ -0,0 +1,33 @@ + + + diff --git a/playground/pages/third-parties/speed-curve/spa-auto/c.vue b/playground/pages/third-parties/speed-curve/spa-auto/c.vue new file mode 100644 index 00000000..f6091071 --- /dev/null +++ b/playground/pages/third-parties/speed-curve/spa-auto/c.vue @@ -0,0 +1,33 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d7bfed5..47a09319 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ catalogs: '@shikijs/themes': specifier: ^4.0.2 version: 4.0.2 + '@speedcurve/lux': + specifier: ^4.4.3 + version: 4.4.3 '@types/google.maps': specifier: ^3.64.1 version: 3.64.1 @@ -397,6 +400,9 @@ importers: '@nuxt/module-builder': specifier: 'catalog:' version: 1.0.2(@nuxt/cli@3.35.2(@nuxt/schema@4.4.5)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.34)(esbuild@0.28.0)(typescript@6.0.3)(vue-tsc@3.2.9(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) + '@speedcurve/lux': + specifier: 'catalog:' + version: 4.4.3 rollup: specifier: 'catalog:' version: 4.60.3 @@ -2600,6 +2606,9 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@speedcurve/lux@4.4.3': + resolution: {integrity: sha512-m8B5uEtgq5qljXfPxgB1WPeydfqeawJXcU5iPrEVU/cSDiGRtr5/2nRn/rLLLYVkdim9P/vRKnIcJFmkOoJzqg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -8976,6 +8985,8 @@ snapshots: '@speed-highlight/core@1.2.15': {} + '@speedcurve/lux@4.4.3': {} + '@standard-schema/spec@1.1.0': {} '@stripe/stripe-js@8.11.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5d12c4a5..88d74fa1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,8 @@ catalog: '@paypal/paypal-js': ^9.7.0 '@shikijs/langs': ^4.0.2 '@shikijs/themes': ^4.0.2 + # Pin to ^4.4.x: snippet protocol 2.0.0. Treat major bumps as breaking — verify primer compatibility before upgrading. + '@speedcurve/lux': ^4.4.3 '@types/google.maps': ^3.64.1 '@types/jest-image-snapshot': ^6.4.1 '@types/youtube': ^0.2.0 diff --git a/scripts/generate-registry-types.ts b/scripts/generate-registry-types.ts index 13dc5f98..14ca0bdb 100644 --- a/scripts/generate-registry-types.ts +++ b/scripts/generate-registry-types.ts @@ -87,6 +87,8 @@ function resolveAstType(node: any, source: string): string { } return 'unknown' } + if (callee === 'function' || callee === 'function_') + return 'Function' if (callee === 'custom') return 'Function' } diff --git a/test/e2e/_speedcurve-suite.ts b/test/e2e/_speedcurve-suite.ts new file mode 100644 index 00000000..0da325f3 --- /dev/null +++ b/test/e2e/_speedcurve-suite.ts @@ -0,0 +1,184 @@ +import type { Page } from 'playwright-core' +import { getBrowser, url, waitForHydration } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' + +interface LuxCall { method: string, args: unknown[] } + +async function scPage(path: string) { + const browser = await getBrowser() + const page = await browser.newPage() + await page.route('**/cdn.speedcurve.com/**', r => r.abort()) + const targetUrl = url(path) + await page.goto(targetUrl) + await waitForHydration(page, targetUrl) + return { page } +} + +async function getLuxCalls(page: Page): Promise { + return page.evaluate(() => (window as any)._luxCalls as LuxCall[]) +} + +async function waitForLuxCall(page: Page, method: string, timeoutMs = 5000) { + await page.waitForFunction( + (m: string) => ((window as any)._luxCalls as LuxCall[])?.some(c => c.method === m), + method, + { timeout: timeoutMs }, + ) +} + +export function defineSpeedCurveSuite() { + it('primer: injects LUX into with correct snippetVersion', async () => { + const { page } = await scPage('/tpc/speedcurve') + + const snippetVersion = await page.evaluate( + () => (window as any).LUX?.snippetVersion, + ) + expect(snippetVersion).toBeTruthy() + expect(String(snippetVersion)).toMatch(/^\d+\.\d+/) + }) + + it('SPA auto: calls startSoftNavigation when navigating to a new route', async () => { + const { page } = await scPage('/tpc/speedcurve') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + await waitForLuxCall(page, 'startSoftNavigation') + const calls = await getLuxCalls(page) + const softNavCalls = calls.filter(c => c.method === 'startSoftNavigation') + expect(softNavCalls).toHaveLength(1) + }) + + it('SPA auto: calls markLoadTime after page:finish + paint', { timeout: 20000 }, async () => { + const { page } = await scPage('/tpc/speedcurve') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + // markLoadTime fires after page:finish + two rAFs — give it time + await waitForLuxCall(page, 'markLoadTime', 8000) + const calls = await getLuxCalls(page) + const loadTimeCalls = calls.filter(c => c.method === 'markLoadTime') + expect(loadTimeCalls.length).toBeGreaterThanOrEqual(1) + }) + + it('SPA auto: timing order is startSoftNavigation before markLoadTime', async () => { + const { page } = await scPage('/tpc/speedcurve') + // Wait for the initial page:finish markLoadTime (fired via rAF) to land, + // then clear so only the next navigation's calls are in the array. + await waitForLuxCall(page, 'markLoadTime', 3000) + await page.evaluate(() => { + (window as any)._luxCalls = [] + }) + + await page.click('#nav-destination') + await page.waitForSelector('#page') + await waitForLuxCall(page, 'markLoadTime', 8000) + + const calls = await getLuxCalls(page) + const softNavIdx = calls.findIndex(c => c.method === 'startSoftNavigation') + const markLoadIdx = calls.findIndex(c => c.method === 'markLoadTime') + expect(softNavIdx).toBeGreaterThanOrEqual(0) + expect(markLoadIdx).toBeGreaterThan(softNavIdx) + }) + + it('SPA auto default label: uses Nuxt-generated route name as label', async () => { + const { page } = await scPage('/tpc/speedcurve') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + // Label is set synchronously in router.beforeEach; startSoftNavigation confirms the guard fired. + await waitForLuxCall(page, 'startSoftNavigation') + const label = await page.evaluate(() => (window as any).LUX?.label) + expect(typeof label).toBe('string') + expect(label).toMatch(/speedcurve.*destination/) + }) + + it('SPA auto: back navigation triggers second startSoftNavigation', { + timeout: 20000, + }, async () => { + const { page } = await scPage('/tpc/speedcurve') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + await waitForLuxCall(page, 'markLoadTime', 8000) + + await page.click('#nav-back') + await page.waitForSelector('#nav-destination') + + await waitForLuxCall(page, 'startSoftNavigation') + const calls = await getLuxCalls(page) + const softNavCalls = calls.filter(c => c.method === 'startSoftNavigation') + expect(softNavCalls.length).toBeGreaterThanOrEqual(2) + }) + + it('canceled navigation: seals phantom beacon with luxNavFailed tag', async () => { + const { page } = await scPage('/tpc/speedcurve') + + await page.click('#nav-blocked') + + await page.waitForFunction( + () => ((window as any)._luxCalls as LuxCall[])?.some(c => c.method === 'addData'), + { timeout: 5000 }, + ) + + const calls = await getLuxCalls(page) + const addDataCall = calls.find(c => c.method === 'addData') + expect(addDataCall?.args).toEqual(['luxNavFailed', '1']) + + const markLoadCall = calls.find(c => c.method === 'markLoadTime') + expect(markLoadCall).toBeDefined() + + expect(page.url()).toContain('/tpc/speedcurve') + expect(page.url()).not.toContain('/blocked') + }) + + it('SPA off: no startSoftNavigation call when spaMode is false', async () => { + const { page } = await scPage('/tpc/speedcurve-no-spa') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + await page.waitForTimeout(1000) // negative assertion: let any hooks fire if they mistakenly exist + + const calls = await getLuxCalls(page) + const softNavCalls = calls.filter(c => c.method === 'startSoftNavigation') + expect(softNavCalls).toHaveLength(0) + }) + + it('custom labelFor: applies the user-provided label function', async () => { + const { page } = await scPage('/tpc/speedcurve-custom-label') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + // Label is set in router.beforeEach; waiting for startSoftNavigation confirms the guard fired. + await waitForLuxCall(page, 'startSoftNavigation') + const label = await page.evaluate(() => (window as any).LUX?.label) + expect(label).toBe('custom:/tpc/speedcurve-custom-label/destination') + }) + + it('labelFor false: leaves window.LUX.label unchanged after navigation', async () => { + const { page } = await scPage('/tpc/speedcurve-label-off') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + // startSoftNavigation confirms the router guard ran (and did not set label). + await waitForLuxCall(page, 'startSoftNavigation') + const label = await page.evaluate(() => (window as any).LUX?.label) + expect(label).toBe('original-label') + }) + + it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', { timeout: 20000 }, async () => { + const { page } = await scPage('/tpc/speedcurve-manual') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + await page.waitForTimeout(1000) // negative assertion: let any hooks fire if they mistakenly exist + + const calls = await getLuxCalls(page) + const softNavCalls = calls.filter(c => c.method === 'startSoftNavigation') + expect(softNavCalls).toHaveLength(0) + }) +} diff --git a/test/e2e/speedcurve.test.ts b/test/e2e/speedcurve.test.ts new file mode 100644 index 00000000..c82afb28 --- /dev/null +++ b/test/e2e/speedcurve.test.ts @@ -0,0 +1,14 @@ +import { createResolver } from '@nuxt/kit' +import { setup } from '@nuxt/test-utils/e2e' +import { describe } from 'vitest' +import { defineSpeedCurveSuite } from './_speedcurve-suite' + +const { resolve } = createResolver(import.meta.url) + +describe('speedcurve', { timeout: 15000 }, async () => { + await setup({ + rootDir: resolve('../fixtures/speedcurve'), + browser: true, + }) + defineSpeedCurveSuite() +}) diff --git a/test/fixtures/speedcurve/app.vue b/test/fixtures/speedcurve/app.vue new file mode 100644 index 00000000..8f62b8bf --- /dev/null +++ b/test/fixtures/speedcurve/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/speedcurve/nuxt.config.ts b/test/fixtures/speedcurve/nuxt.config.ts new file mode 100644 index 00000000..16713baf --- /dev/null +++ b/test/fixtures/speedcurve/nuxt.config.ts @@ -0,0 +1,13 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: [ + '@nuxt/scripts', + ], + scripts: { + registry: { + speedcurve: {}, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/speedcurve/package.json b/test/fixtures/speedcurve/package.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/test/fixtures/speedcurve/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-custom-label/destination.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-custom-label/destination.vue new file mode 100644 index 00000000..f48e1aeb --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-custom-label/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-custom-label/index.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-custom-label/index.vue new file mode 100644 index 00000000..53e3845f --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-custom-label/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/destination.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/destination.vue new file mode 100644 index 00000000..0f8f8501 --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue new file mode 100644 index 00000000..87b0bf20 --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-manual/destination.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-manual/destination.vue new file mode 100644 index 00000000..0c69746c --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-manual/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-manual/index.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-manual/index.vue new file mode 100644 index 00000000..0ef0d946 --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-manual/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-no-spa/destination.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-no-spa/destination.vue new file mode 100644 index 00000000..e0c0d840 --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-no-spa/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-no-spa/index.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-no-spa/index.vue new file mode 100644 index 00000000..8f2d78d6 --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-no-spa/index.vue @@ -0,0 +1,28 @@ + + + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve/destination.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve/destination.vue new file mode 100644 index 00000000..3b0dde91 --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve/destination.vue @@ -0,0 +1,6 @@ + diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve/index.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve/index.vue new file mode 100644 index 00000000..6930161a --- /dev/null +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve/index.vue @@ -0,0 +1,38 @@ + + + diff --git a/test/fixtures/speedcurve/tsconfig.json b/test/fixtures/speedcurve/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/speedcurve/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/unit/speedcurve-after-next-paint.test.ts b/test/unit/speedcurve-after-next-paint.test.ts new file mode 100644 index 00000000..2308f53c --- /dev/null +++ b/test/unit/speedcurve-after-next-paint.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterNextPaint } from '../../packages/script/src/runtime/utils/after-next-paint' + +describe('afterNextPaint', () => { + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('calls the callback after exactly two requestAnimationFrame invocations', () => { + const cb = vi.fn() + afterNextPaint(cb) + + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(2) + expect(cb).toHaveBeenCalledOnce() + }) + + it('does NOT call the callback synchronously (before rAF fires)', () => { + // Replace with a non-firing mock to verify sync non-call + vi.mocked(window.requestAnimationFrame).mockImplementation(() => 0) + + const cb = vi.fn() + afterNextPaint(cb) + + expect(cb).not.toHaveBeenCalled() + }) + + it('does NOT call the callback after only one rAF', () => { + let outerCb: FrameRequestCallback | undefined + vi.mocked(window.requestAnimationFrame) + .mockImplementationOnce((cb) => { + outerCb = cb + return 0 + }) // capture outer rAF + .mockImplementation(() => 0) // inner rAF: don't fire + + const cb = vi.fn() + afterNextPaint(cb) + + // Fire outer rAF — registers inner rAF but does NOT call cb yet + outerCb!(0) + expect(cb).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/speedcurve-auto-tracker.test.ts b/test/unit/speedcurve-auto-tracker.test.ts new file mode 100644 index 00000000..c7fdef10 --- /dev/null +++ b/test/unit/speedcurve-auto-tracker.test.ts @@ -0,0 +1,260 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockBeforeEach = vi.fn() +const mockAfterEach = vi.fn() +const mockHook = vi.fn() + +vi.mock('nuxt/app', () => ({ + useHead: vi.fn(), + useRouter: () => ({ beforeEach: mockBeforeEach, afterEach: mockAfterEach }), + useNuxtApp: () => ({ hook: mockHook, payload: { serverRendered: true } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), + createError: (e: any) => new Error(e.message), + injectHead: vi.fn(), + onNuxtReady: vi.fn(), +})) + +vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), +})) + +vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ + default: '/* lux snippet */', +})) + +describe('installAutoTracker', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + Object.defineProperty(window, 'LUX', { value: undefined, writable: true, configurable: true }) + }) + + it('registers router hooks and page:finish hook', async () => { + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + expect(mockBeforeEach).toHaveBeenCalledOnce() + expect(mockAfterEach).toHaveBeenCalledOnce() + expect(mockHook).toHaveBeenCalledWith('page:finish', expect.any(Function)) + }) + + it('is idempotent — calling twice only registers hooks once', async () => { + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + installAutoTracker({ id: '456' }) + + expect(mockBeforeEach).toHaveBeenCalledOnce() + }) + + it('calls startSoftNavigation on user navigations (SSR app: serverRendered=true)', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + // serverRendered=true means pendingInitial=false: first beforeEach IS a user nav + installAutoTracker({ id: '123' }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + + // First call — SSR app, all navigations call startSoftNavigation + handler({ name: 'home', path: '/' }) + expect(lux.startSoftNavigation).toHaveBeenCalledOnce() + + handler({ name: 'about', path: '/about' }) + expect(lux.startSoftNavigation).toHaveBeenCalledTimes(2) + }) + + it('skips startSoftNavigation on initial nav for SPA-only app (serverRendered=false) but still sets label', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + // Simulate SPA-only behavior by calling installAutoTracker with the same mocked hooks + // but wiring the pendingInitial logic manually via the beforeEach handler. + // Since module mocks are hoisted, we test the SPA path by resetting module cache + // and re-importing with a serverRendered=false mock. + vi.resetModules() + vi.doMock('nuxt/app', () => ({ + useHead: vi.fn(), + useRouter: () => ({ beforeEach: mockBeforeEach, afterEach: mockAfterEach }), + useNuxtApp: () => ({ hook: mockHook, payload: { serverRendered: false } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), + createError: (e: any) => new Error(e.message), + injectHead: vi.fn(), + onNuxtReady: vi.fn(), + })) + vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ + default: '/* lux snippet */', + })) + vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), + })) + + vi.clearAllMocks() + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + const handler = mockBeforeEach.mock.calls.at(-1)![0] as (to: any) => void + + // First call — SPA-only initial render: skip startSoftNavigation but still label the page + handler({ name: 'home', path: '/' }) + expect(lux.startSoftNavigation).not.toHaveBeenCalled() + expect(lux.label).toBe('home') // label IS set even when startSoftNavigation is skipped + + // Second call — user navigation, should call startSoftNavigation + handler({ name: 'about', path: '/about' }) + expect(lux.startSoftNavigation).toHaveBeenCalledOnce() + + // Restore module state for subsequent tests + vi.resetModules() + vi.doMock('nuxt/app', () => ({ + useHead: vi.fn(), + useRouter: () => ({ beforeEach: mockBeforeEach, afterEach: mockAfterEach }), + useNuxtApp: () => ({ hook: mockHook, payload: { serverRendered: true } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), + createError: (e: any) => new Error(e.message), + injectHead: vi.fn(), + onNuxtReady: vi.fn(), + })) + vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ + default: '/* lux snippet */', + })) + vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), + })) + }) + + it('applies default label from route name', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: 'about', path: '/about' }) + + expect(lux.label).toBe('about') + }) + + it('uses default route-name labeling when label is a static string (string is applied by applyConfig, not router hook)', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123', label: 'static-page' }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: 'about', path: '/about' }) + + // The router hook uses the default (route name), not the static string + expect(lux.label).toBe('about') + }) + + it('uses label function when provided', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123', label: to => `custom:${to.path}` }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: 'about', path: '/about' }) + + expect(lux.label).toBe('custom:/about') + }) + + it('does not set label when label is false', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: 'original' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123', label: false }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: 'about', path: '/about' }) + + expect(lux.label).toBe('original') + }) + + it('does not set label when label function returns false', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: 'keep-me' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123', label: () => false }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: 'about', path: '/about' }) + + expect(lux.label).toBe('keep-me') + }) + + it('seals phantom beacon with luxNavFailed tag on failed navigation', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + const afterEachHandler = mockAfterEach.mock.calls[0][0] as (to: any, from: any, failure: any) => void + + // Successful nav — must NOT mark load time + afterEachHandler({}, {}, undefined) + expect(lux.markLoadTime).not.toHaveBeenCalled() + + // Failed nav — must seal beacon + afterEachHandler({}, {}, new Error('guard cancelled')) + expect(lux.markLoadTime).toHaveBeenCalledOnce() + expect(lux.addData).toHaveBeenCalledWith('luxNavFailed', '1') + }) + + it('page:finish callback calls markLoadTime after double rAF', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + // Make rAF synchronous so we can test without real paint cycles + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + const pageFinishHandler = mockHook.mock.calls.find(([event]) => event === 'page:finish')?.[1] as () => void + expect(pageFinishHandler).toBeDefined() + + expect(lux.markLoadTime).not.toHaveBeenCalled() + pageFinishHandler() + expect(lux.markLoadTime).toHaveBeenCalledOnce() + + vi.restoreAllMocks() + }) + + it('default label falls back to to.path when to.name is undefined', async () => { + const lux = { startSoftNavigation: vi.fn(), markLoadTime: vi.fn(), addData: vi.fn(), label: '' } + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: undefined, path: '/some/path' }) + + expect(lux.label).toBe('/some/path') + }) + + it('beforeEach returns early without throwing when window.LUX is undefined at nav time', async () => { + Object.defineProperty(window, 'LUX', { value: undefined, writable: true, configurable: true }) + + const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') + installAutoTracker({ id: '123' }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + expect(() => handler({ name: 'about', path: '/about' })).not.toThrow() + }) +}) diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts new file mode 100644 index 00000000..3e66c980 --- /dev/null +++ b/test/unit/speedcurve-config.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('nuxt/app', () => ({ + useHead: vi.fn(), + useRouter: () => ({ beforeEach: vi.fn(), afterEach: vi.fn() }), + useNuxtApp: () => ({ hook: vi.fn(), payload: { serverRendered: true } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), + createError: (e: any) => new Error(e.message), + injectHead: vi.fn(), + onNuxtReady: vi.fn(), +})) + +vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), +})) + +vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ + default: '/* lux snippet */', +})) + +describe('applyConfig', () => { + beforeEach(() => { + Object.defineProperty(window, 'LUX', { value: {}, writable: true, configurable: true }) + }) + + it('sets known LUX config fields from options', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: '123', samplerate: 50, maxMeasureTime: 30000 }) + + expect(lux.samplerate).toBe(50) + expect(lux.maxMeasureTime).toBe(30000) + }) + + it('does not set undefined options on window.LUX', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: '123' }) + + expect(lux.samplerate).toBeUndefined() + expect(lux.cookieDomain).toBeUndefined() + expect(lux.label).toBeUndefined() + }) + + it('does not throw when window.LUX is undefined', async () => { + Object.defineProperty(window, 'LUX', { value: undefined, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + expect(() => applyConfig({ id: '123', samplerate: 50 })).not.toThrow() + }) + + it('does not forward autoTrackSpaNavigations to window.LUX', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: '123', autoTrackSpaNavigations: true }) + + expect(lux.autoTrackSpaNavigations).toBeUndefined() + }) + + it('does not forward id to window.LUX (id belongs in the CDN URL only)', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: 'MY_CUSTOMER_ID' }) + + expect(lux.id).toBeUndefined() + }) + + it('sets label when a static string is provided', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: '123', label: 'my-page' }) + + expect(lux.label).toBe('my-page') + }) + + it('does not forward label to window.LUX when label is a function', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: '123', label: to => to.path }) + + expect(lux.label).toBeUndefined() + }) + + it('does not forward label to window.LUX when label is false', async () => { + const lux: Record = {} + Object.defineProperty(window, 'LUX', { value: lux, writable: true, configurable: true }) + + const { applyConfig } = await import('../../packages/script/src/runtime/registry/speedcurve') + applyConfig({ id: '123', label: false }) + + expect(lux.label).toBeUndefined() + }) +}) diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts new file mode 100644 index 00000000..df09ac2d --- /dev/null +++ b/test/unit/speedcurve-primer.test.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it, vi } from 'vitest' + +const useHeadCalls: any[] = [] + +vi.mock('nuxt/app', () => ({ + useHead: (input: any) => useHeadCalls.push(input), + useRouter: () => ({ beforeEach: vi.fn(), afterEach: vi.fn() }), + useNuxtApp: () => ({ hook: vi.fn(), payload: { serverRendered: true } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), + createError: (e: any) => new Error(e.message), + injectHead: vi.fn(), + onNuxtReady: vi.fn(), +})) + +vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), +})) + +vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ + default: '(function(){window.LUX=window.LUX||{};window.LUX.snippetVersion="2.0.0";})()', +})) + +describe('useScriptSpeedCurve primer injection', () => { + it('calls useHead with a critical inline head script', async () => { + const { useScriptSpeedCurve } = await import('../../packages/script/src/runtime/registry/speedcurve') + + useScriptSpeedCurve({ id: 'TEST_ID_123' }) + + // The wrapped beforeInit in useRegistryScript calls useHead during beforeInit + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + const lastCall = vi.mocked(useScript).mock.calls.at(-1) + const opts = lastCall?.[1] as any + opts?.beforeInit?.() + + const headCall = useHeadCalls.find(c => c.script?.length > 0) + expect(headCall).toBeDefined() + const entry = headCall.script[0] + expect(entry.key).toBe('speedcurve-lux-primer') + expect(entry.tagPriority).toBe('critical') + expect(entry.tagPosition).toBe('head') + expect(entry.innerHTML).toContain('LUX') + }) + + it('includes customer id in lux.js src', async () => { + const { useScriptSpeedCurve } = await import('../../packages/script/src/runtime/registry/speedcurve') + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + + useScriptSpeedCurve({ id: 'MY_CUSTOMER_ID' }) + + const lastCall = vi.mocked(useScript).mock.calls.at(-1) + expect(lastCall?.[0]).toMatchObject({ src: expect.stringContaining('id=MY_CUSTOMER_ID') }) + }) + + it('sets crossorigin: anonymous on the script input', async () => { + const { useScriptSpeedCurve } = await import('../../packages/script/src/runtime/registry/speedcurve') + const { useScript } = await import('../../packages/script/src/runtime/composables/useScript') + + useScriptSpeedCurve({ id: 'SOME_ID' }) + + const lastCall = vi.mocked(useScript).mock.calls.at(-1) + expect(lastCall?.[0]).toMatchObject({ crossorigin: 'anonymous' }) + }) +})