From c3e64d586e0463e06eef51a75eda399dd5fd317c Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:02:28 +0100 Subject: [PATCH 01/32] feat(speedcurve): add @speedcurve/lux to catalog --- packages/script/package.json | 2 ++ pnpm-lock.yaml | 11 +++++++++++ pnpm-workspace.yaml | 2 ++ 3 files changed, 15 insertions(+) diff --git a/packages/script/package.json b/packages/script/package.json index 414a4fcb..eb57661c 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" ] @@ -129,6 +130,7 @@ }, "devDependencies": { "@nuxt/kit": "catalog:", + "@speedcurve/lux": "catalog:", "@nuxt/module-builder": "catalog:", "rollup": "catalog:", "unbuild": "catalog:", 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..ac290adb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,8 @@ catalog: pkg-types: ^2.3.1 playwright-core: ^1.60.0 posthog-js: ^1.373.4 + # 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 rollup: ^4.60.3 shiki: ^4.0.2 sirv: ^3.0.2 From 8ef86472badc6e866bc8e74d9c3648a0647b1c33 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:05:35 +0100 Subject: [PATCH 02/32] feat(speedcurve): add afterNextPaint utility --- packages/script/src/runtime/utils/after-next-paint.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/script/src/runtime/utils/after-next-paint.ts 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)) +} From 7ab8999196e19e17825e04d2cf65a1d18e6d156c Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:07:25 +0100 Subject: [PATCH 03/32] feat(speedcurve): add SpeedCurveOptions schema --- .../script/src/runtime/registry/schemas.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index d6be8661..e1a2b486 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1007,6 +1007,77 @@ 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()), + /** + * Automatically send a LUX beacon when the page loads. + * @default true + */ + auto: optional(boolean()), + /** + * Page label shown in the SpeedCurve dashboard. + */ + label: optional(string()), + /** + * Sampling rate (0–100). Percentage of sessions that send beacons. + * Upstream spelling is lowercase — matches LUX UserConfig. + */ + samplerate: optional(number()), + /** + * 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. From 1269c3123a7d36da87ea3c602214e738cb198d44 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:08:35 +0100 Subject: [PATCH 04/32] feat(speedcurve): add useScriptSpeedCurve composable --- .../script/src/runtime/registry/speedcurve.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/script/src/runtime/registry/speedcurve.ts diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts new file mode 100644 index 00000000..f4f4c966 --- /dev/null +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -0,0 +1,131 @@ +import type { LuxGlobal, UserConfig } from '@speedcurve/lux' +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import type { RouteLocationNormalized } from 'vue-router' +import { useHead, useNuxtApp, useRouter } from 'nuxt/app' +import luxSnippetSource from '@speedcurve/lux/dist/lux-snippet.js?raw' +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[] + __speedcurveLuxWired?: boolean + _luxCalls?: Array<{ method: string, args: unknown[] }> + } +} + +export type SpeedCurveInput = RegistryScriptInput & { + /** + * Derive a page label for each SPA navigation. + * Defaults to `String(to.name ?? to.path)`. + * Set to `false` to disable labeling. + */ + labelFor?: ((to: RouteLocationNormalized) => string) | false +} + +// LUX UserConfig keys that map 1:1 to our schema (autoTrackSpaNavigations is composable-only). +const LUX_USER_CONFIG_KEYS: (keyof UserConfig)[] = [ + 'spaMode', + 'auto', + 'label', + 'samplerate', + 'sendBeaconOnPageHidden', + 'trackErrors', + 'maxErrors', + 'minMeasureTime', + 'maxMeasureTime', + 'newBeaconOnPageShow', + 'trackHiddenPages', + 'cookieDomain', +] + +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 + : () => { + applyConfig(options) + if (options.spaMode && options.autoTrackSpaNavigations !== false) + installAutoTracker(_options) + }, + }), _options) 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) + lux[k as string] = v + } +} + +export function installAutoTracker(options?: SpeedCurveInput): void { + if (window.__speedcurveLuxWired) return + window.__speedcurveLuxWired = 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 labelFor = options?.labelFor === false + ? null + : (options?.labelFor ?? ((to: RouteLocationNormalized) => String(to.name ?? to.path))) + + router.beforeEach((to) => { + const lux = window.LUX + if (!lux) return + if (pendingInitial) { + pendingInitial = false + if (labelFor) lux.label = labelFor(to) + return + } + lux.startSoftNavigation() + if (labelFor) lux.label = labelFor(to) + }) + + // 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()) + }) +} From 8d6d52d996c0bbba1c7b8a26f919c405310151c4 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:08:57 +0100 Subject: [PATCH 05/32] feat(speedcurve): register SpeedCurve LUX in registry --- packages/script/src/registry.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 8bb07b44..7f399eba 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', 'utility', 'useScriptSpeedCurve', {}, null), ] export const REGISTRY_CATEGORIES = [ @@ -851,6 +853,13 @@ 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: 'utility', + envDefaults: { id: '' }, + }), ]) } From 43f8ad5ec7da50acbb8118ae4624af1cf2cbe9f8 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:09:45 +0100 Subject: [PATCH 06/32] feat(speedcurve): add SpeedCurve logo --- packages/script/src/registry-logos.ts | 1 + packages/script/src/runtime/types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 92c69125..f0dbcecc 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -64,4 +64,5 @@ export const LOGOS = { usercentrics: ``, bingUet: ``, snapchatPixel: ``, + speedcurve: ``, } satisfies Record diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 8419fc27..142f16d4 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -301,7 +301,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' /** From 0a8a765c9120d7d416235bea03f790baa44e9b47 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:11:02 +0100 Subject: [PATCH 07/32] chore(speedcurve): regenerate registry-types.json --- packages/script/src/registry-types.json | 105 ++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 27bc4f7e..0d6f119f 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 * Automatically send a LUX beacon when the page loads.\n * @default true\n */\n auto: optional(boolean()),\n /**\n * Page label shown in the SpeedCurve dashboard.\n */\n label: optional(string()),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(number()),\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,99 @@ "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": "auto", + "type": "boolean", + "required": false, + "description": "Automatically send a LUX beacon when the page loads.", + "defaultValue": "true" + }, + { + "name": "label", + "type": "string", + "required": false, + "description": "Page label shown in the SpeedCurve dashboard." + }, + { + "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", From 75b3af45c4c98043758592a82157c3e02bf54998 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:21:53 +0100 Subject: [PATCH 08/32] fix(speedcurve): add ScriptRegistry type + peerDependency --- packages/script/package.json | 4 ++++ packages/script/src/runtime/types.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/script/package.json b/packages/script/package.json index eb57661c..f0fa1752 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -76,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", @@ -90,6 +91,9 @@ "@paypal/paypal-js": { "optional": true }, + "@speedcurve/lux": { + "optional": true + }, "@stripe/stripe-js": { "optional": true }, diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 142f16d4..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 From a20cb78070662033b04b77f98ea492af5ca8a598 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:30:12 +0100 Subject: [PATCH 09/32] test(speedcurve): add primer injection unit tests --- test/unit/speedcurve-primer.test.ts | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/unit/speedcurve-primer.test.ts diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts new file mode 100644 index 00000000..a2e9075f --- /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' }) + }) +}) From 602383a9813f89c14688032637d252c801b51eb9 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:30:40 +0100 Subject: [PATCH 10/32] test(speedcurve): add config apply unit tests --- test/unit/speedcurve-config.test.ts | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/unit/speedcurve-config.test.ts diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts new file mode 100644 index 00000000..d597897c --- /dev/null +++ b/test/unit/speedcurve-config.test.ts @@ -0,0 +1,78 @@ +/** + * @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() + }) +}) From 481820d393969816384d29a0cd33dee697c2e040 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:32:37 +0100 Subject: [PATCH 11/32] test(speedcurve): add auto-tracker unit tests --- test/unit/speedcurve-auto-tracker.test.ts | 231 ++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test/unit/speedcurve-auto-tracker.test.ts diff --git a/test/unit/speedcurve-auto-tracker.test.ts b/test/unit/speedcurve-auto-tracker.test.ts new file mode 100644 index 00000000..337fbbcf --- /dev/null +++ b/test/unit/speedcurve-auto-tracker.test.ts @@ -0,0 +1,231 @@ +/** + * @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() + delete (window as any).__speedcurveLuxWired + 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' })), + })) + + delete (window as any).__speedcurveLuxWired + 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 labelFor 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', labelFor: 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 labelFor 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', labelFor: false }) + + const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void + handler({ name: 'about', path: '/about' }) + + expect(lux.label).toBe('original') + }) + + 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() + }) +}) From d2e2afb2d9d5ec12bf7f896e7b156e4e70a35f0d Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:32:57 +0100 Subject: [PATCH 12/32] test(speedcurve): add afterNextPaint unit tests --- test/unit/speedcurve-after-next-paint.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/unit/speedcurve-after-next-paint.test.ts 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..ab66e083 --- /dev/null +++ b/test/unit/speedcurve-after-next-paint.test.ts @@ -0,0 +1,47 @@ +/** + * @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() + }) +}) From 7689d3bf0b6a721b936f6cdae7e6332ab3042244 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:41:43 +0100 Subject: [PATCH 13/32] test(speedcurve): add e2e fixture pages --- test/fixtures/basic/nuxt.config.ts | 1 + .../speedcurve-custom-label/destination.vue | 3 ++ .../tpc/speedcurve-custom-label/index.vue | 30 +++++++++++++++ .../tpc/speedcurve-label-off/destination.vue | 3 ++ .../pages/tpc/speedcurve-label-off/index.vue | 30 +++++++++++++++ .../tpc/speedcurve-manual/destination.vue | 3 ++ .../pages/tpc/speedcurve-manual/index.vue | 29 ++++++++++++++ .../tpc/speedcurve-no-spa/destination.vue | 3 ++ .../pages/tpc/speedcurve-no-spa/index.vue | 28 ++++++++++++++ .../pages/tpc/speedcurve/destination.vue | 6 +++ .../basic/pages/tpc/speedcurve/index.vue | 38 +++++++++++++++++++ 11 files changed, 174 insertions(+) create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-custom-label/destination.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-custom-label/index.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-label-off/destination.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-label-off/index.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-manual/destination.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-manual/index.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-no-spa/destination.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve-no-spa/index.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve/destination.vue create mode 100644 test/fixtures/basic/pages/tpc/speedcurve/index.vue diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 8c2419ba..a65da7e0 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -10,6 +10,7 @@ export default defineNuxtConfig({ instagramEmbed: {}, blueskyEmbed: {}, gravatar: {}, + speedcurve: {}, }, }, devtools: { diff --git a/test/fixtures/basic/pages/tpc/speedcurve-custom-label/destination.vue b/test/fixtures/basic/pages/tpc/speedcurve-custom-label/destination.vue new file mode 100644 index 00000000..f48e1aeb --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-custom-label/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-custom-label/index.vue b/test/fixtures/basic/pages/tpc/speedcurve-custom-label/index.vue new file mode 100644 index 00000000..b4e3a647 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-custom-label/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-label-off/destination.vue b/test/fixtures/basic/pages/tpc/speedcurve-label-off/destination.vue new file mode 100644 index 00000000..0f8f8501 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-label-off/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-label-off/index.vue b/test/fixtures/basic/pages/tpc/speedcurve-label-off/index.vue new file mode 100644 index 00000000..b45f2a37 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-label-off/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-manual/destination.vue b/test/fixtures/basic/pages/tpc/speedcurve-manual/destination.vue new file mode 100644 index 00000000..0c69746c --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-manual/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-manual/index.vue b/test/fixtures/basic/pages/tpc/speedcurve-manual/index.vue new file mode 100644 index 00000000..74ef7d5d --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-manual/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-no-spa/destination.vue b/test/fixtures/basic/pages/tpc/speedcurve-no-spa/destination.vue new file mode 100644 index 00000000..e0c0d840 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-no-spa/destination.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/pages/tpc/speedcurve-no-spa/index.vue b/test/fixtures/basic/pages/tpc/speedcurve-no-spa/index.vue new file mode 100644 index 00000000..d7a6b9f9 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve-no-spa/index.vue @@ -0,0 +1,28 @@ + + + diff --git a/test/fixtures/basic/pages/tpc/speedcurve/destination.vue b/test/fixtures/basic/pages/tpc/speedcurve/destination.vue new file mode 100644 index 00000000..3b0dde91 --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve/destination.vue @@ -0,0 +1,6 @@ + diff --git a/test/fixtures/basic/pages/tpc/speedcurve/index.vue b/test/fixtures/basic/pages/tpc/speedcurve/index.vue new file mode 100644 index 00000000..6ed3bc9c --- /dev/null +++ b/test/fixtures/basic/pages/tpc/speedcurve/index.vue @@ -0,0 +1,38 @@ + + + From 02385f51263ab35ec641f702afbfc83b7db12fcc Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:48:20 +0100 Subject: [PATCH 14/32] test(speedcurve): add comprehensive e2e tests Add e2e test suite for useScriptSpeedCurve covering: - Primer injection and snippetVersion initialization - SPA auto-tracking: startSoftNavigation and markLoadTime flow - Navigation timing and sequencing - Label generation (default Nuxt route names) - Back navigation multi-calls - Canceled/blocked navigation with luxNavFailed tag - SPA mode disabled (no calls) - Custom label function (labelFor) - Label persistence when labelFor is false - Manual tracking mode (no auto calls) Uses fixture pages pre-installed with spy object on window.LUX that captures method calls before composable hooks fire during hydration. --- test/e2e/basic.test.ts | 190 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index b132ef2b..8a3b36e7 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -635,3 +635,193 @@ describe.skip('social-embeds', () => { expect(hasProxiedImages).toBe(true) }) }) + +describe('speedcurve', () => { + // Helper: read captured LUX calls from the page's spy + async function getLuxCalls(page: any) { + return page.evaluate(() => (window as any)._luxCalls as Array<{ method: string, args: unknown[] }>) + } + + // Helper: wait for a specific LUX method to appear in _luxCalls + async function waitForLuxCall(page: any, method: string, timeoutMs = 5000) { + await page.waitForFunction( + (m: string) => ((window as any)._luxCalls as any[])?.some((c: any) => c.method === m), + method, + { timeout: timeoutMs }, + ) + } + + it('primer: injects LUX into with correct snippetVersion', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/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', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve') + + // Navigate via NuxtLink (SPA navigation, no full reload) + 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: 15000, + }, async () => { + const { page } = await createPage('/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', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve') + + 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', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + const label = await page.evaluate(() => (window as any).LUX?.label) + // Nuxt generates a route name from the file path — e.g. 'tpc-speedcurve-destination'. + // String(to.name ?? to.path) returns this name. Path fallback (to.name === undefined) + // is unreachable via Nuxt file-based routing — it is covered by unit test instead. + expect(typeof label).toBe('string') + expect(label).toMatch(/speedcurve.*destination/) + }) + + it('SPA auto: back navigation triggers second startSoftNavigation', { + timeout: 20000, + }, async () => { + const { page } = await createPage('/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', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve') + + // A guard in the fixture blocks navigation to /blocked and returns false + await page.click('#nav-blocked') + + // The afterEach(failure) handler fires synchronously after the guard blocks + await page.waitForFunction( + () => ((window as any)._luxCalls as any[])?.some((c: any) => 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() + + // We must still be on the index page (navigation was blocked) + expect(page.url()).toContain('/tpc/speedcurve') + expect(page.url()).not.toContain('/blocked') + }) + + it('SPA off: no startSoftNavigation call when spaMode is false', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve-no-spa') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + await page.waitForTimeout(1000) // give hooks time to 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', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve-custom-label') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + await page.waitForTimeout(500) + 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', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve-label-off') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + + await page.waitForTimeout(500) + const label = await page.evaluate(() => (window as any).LUX?.label) + // The fixture initialises label to 'original-label'; with labelFor:false the composable + // must never overwrite it, regardless of which route was navigated to. + expect(label).toBe('original-label') + }) + + it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', { + timeout: 15000, + }, async () => { + const { page } = await createPage('/tpc/speedcurve-manual') + + await page.click('#nav-destination') + await page.waitForSelector('#page') + await page.waitForTimeout(1000) // give hooks time to fire if they mistakenly exist + + const calls = await getLuxCalls(page) + const softNavCalls = calls.filter(c => c.method === 'startSoftNavigation') + expect(softNavCalls).toHaveLength(0) + }) +}) From 25cb1abe928b26b8164c59493bb4c44b2cb8af37 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:49:54 +0100 Subject: [PATCH 15/32] feat(speedcurve): add playground page --- .../speed-curve/nuxt-scripts.vue | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 playground/pages/third-parties/speed-curve/nuxt-scripts.vue diff --git a/playground/pages/third-parties/speed-curve/nuxt-scripts.vue b/playground/pages/third-parties/speed-curve/nuxt-scripts.vue new file mode 100644 index 00000000..02295bc3 --- /dev/null +++ b/playground/pages/third-parties/speed-curve/nuxt-scripts.vue @@ -0,0 +1,30 @@ + + + From 3d0f6da4c23fcae9c3f1245a214554d80b7a8211 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:50:07 +0100 Subject: [PATCH 16/32] docs(speedcurve): add user-facing documentation --- docs/content/scripts/speedcurve.md | 107 +++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/content/scripts/speedcurve.md diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md new file mode 100644 index 00000000..5e0d99ed --- /dev/null +++ b/docs/content/scripts/speedcurve.md @@ -0,0 +1,107 @@ +--- +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 actually 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 `` 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()` (closes the previous beacon, starts a new one) +- `nuxt.hook('page:finish')` calls `LUX.markLoadTime()` after the next paint (sets the END mark) +- Cancelled navigations seal the phantom beacon with `addData('luxNavFailed', '1')` 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)` as the page label for each navigation. Override with `labelFor`: + +```ts +useScriptSpeedCurve({ + id: 'YOUR_ID', + spaMode: true, + labelFor: to => to.meta.title as string ?? to.path, +}) +``` + +Set `labelFor: false` to disable labeling entirely. + +## CSP + +Add these directives to your Content Security Policy: + +``` +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] + +``` From a78d4e29dd02ba74a838082b9f0a0d67a7514328 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 09:59:23 +0100 Subject: [PATCH 17/32] feat(speedcurve): add speedcurve entry to script-meta registry Adds the missing `speedcurve` key to `scriptMeta` in `script-meta.ts`, satisfying the `Record` constraint that was failing the unit typecheck. --- packages/script/src/script-meta.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index d0cb7445..75f7d452 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -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: [], + }, } satisfies Record From 0a504a5344b282a465d7178de419a83ab664bd50 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 10:15:20 +0100 Subject: [PATCH 18/32] chore(speedcurve): fix all ESLint issues - sort-keys: alphabetize @speedcurve/lux in package.json and pnpm-workspace.yaml - perfectionist/sort-imports + antfu/if-newline + style/arrow-parens: fix speedcurve.ts - style/max-statements-per-line: split inline rAF mock bodies in test files - style/quote-props: fix test file quote style - test/prefer-lowercase-title: restore SPA capitalization with eslint-disable tags - harlanzw/ai-deslop-passive-voice: disable for "is injected" in docs (technical term) --- docs/content/scripts/speedcurve.md | 14 +++++----- packages/script/package.json | 2 +- .../script/src/runtime/registry/speedcurve.ts | 27 ++++++++++++------- pnpm-workspace.yaml | 4 +-- test/e2e/basic.test.ts | 6 +++++ test/unit/speedcurve-after-next-paint.test.ts | 10 +++++-- test/unit/speedcurve-auto-tracker.test.ts | 11 +++++--- test/unit/speedcurve-config.test.ts | 2 +- test/unit/speedcurve-primer.test.ts | 2 +- 9 files changed, 51 insertions(+), 27 deletions(-) diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md index 5e0d99ed..fdbf4b13 100644 --- a/docs/content/scripts/speedcurve.md +++ b/docs/content/scripts/speedcurve.md @@ -8,7 +8,7 @@ links: size: xs --- -[SpeedCurve LUX](https://speedcurve.com/features/lux/) is a Real User Monitoring (RUM) tool that measures the performance your users actually experience. It tracks Core Web Vitals, custom timing marks, and JavaScript errors. +[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 :: @@ -17,7 +17,8 @@ links: :: The composable comes with the following defaults: -- **Trigger: Client** The LUX primer is injected into `` immediately; `lux.js` loads when Nuxt hydrates. + +- **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. @@ -41,9 +42,9 @@ onLoaded(({ LUX }) => { Set `spaMode: true` to enable SpeedCurve's SPA tracking mode. The composable wires Vue Router automatically: -- `router.beforeEach` calls `LUX.startSoftNavigation()` (closes the previous beacon, starts a new one) -- `nuxt.hook('page:finish')` calls `LUX.markLoadTime()` after the next paint (sets the END mark) -- Cancelled navigations seal the phantom beacon with `addData('luxNavFailed', '1')` for easy filtering +- `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({ @@ -66,7 +67,8 @@ useScriptSpeedCurve({ ## Custom page labels -By default the composable uses `String(to.name ?? to.path)` as the page label for each navigation. Override with `labelFor`: +By default the composable uses `String(to.name ?? to.path)`{lang="ts"} as the page label for each navigation. Override with `labelFor`: + ```ts useScriptSpeedCurve({ diff --git a/packages/script/package.json b/packages/script/package.json index f0fa1752..6f6e7e19 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -134,8 +134,8 @@ }, "devDependencies": { "@nuxt/kit": "catalog:", - "@speedcurve/lux": "catalog:", "@nuxt/module-builder": "catalog:", + "@speedcurve/lux": "catalog:", "rollup": "catalog:", "unbuild": "catalog:", "unimport": "catalog:" diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index f4f4c966..2cf9fc21 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -1,8 +1,8 @@ import type { LuxGlobal, UserConfig } from '@speedcurve/lux' -import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' import type { RouteLocationNormalized } from 'vue-router' -import { useHead, useNuxtApp, useRouter } from 'nuxt/app' +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' @@ -49,7 +49,7 @@ const LUX_USER_CONFIG_KEYS: (keyof UserConfig)[] = [ ] export function useScriptSpeedCurve(_options?: SpeedCurveInput): UseScriptContext { - return useRegistryScript('speedcurve', (options) => ({ + return useRegistryScript('speedcurve', options => ({ scriptInput: { src: `https://cdn.speedcurve.com/js/lux.js?id=${options.id}`, crossorigin: 'anonymous', @@ -81,7 +81,8 @@ export function useScriptSpeedCurve(_options?: SpeedCur export function applyConfig(options: SpeedCurveInput): void { const lux = window.LUX as Record | undefined - if (!lux) return + if (!lux) + return for (const k of LUX_USER_CONFIG_KEYS) { const v = (options as Record)[k as string] if (v !== undefined) @@ -90,7 +91,8 @@ export function applyConfig(options: SpeedCurveInput): void { } export function installAutoTracker(options?: SpeedCurveInput): void { - if (window.__speedcurveLuxWired) return + if (window.__speedcurveLuxWired) + return window.__speedcurveLuxWired = true const router = useRouter() @@ -106,21 +108,26 @@ export function installAutoTracker(options?: SpeedCurveInput): void { router.beforeEach((to) => { const lux = window.LUX - if (!lux) return + if (!lux) + return if (pendingInitial) { pendingInitial = false - if (labelFor) lux.label = labelFor(to) + if (labelFor) + lux.label = labelFor(to) return } lux.startSoftNavigation() - if (labelFor) lux.label = labelFor(to) + if (labelFor) + lux.label = labelFor(to) }) // If a guard cancels navigation, seal the phantom beacon with a filterable tag. router.afterEach((_to, _from, failure) => { - if (!failure) return + if (!failure) + return const lux = window.LUX - if (!lux) return + if (!lux) + return lux.markLoadTime() lux.addData('luxNavFailed', '1') }) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ac290adb..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 @@ -46,8 +48,6 @@ catalog: pkg-types: ^2.3.1 playwright-core: ^1.60.0 posthog-js: ^1.373.4 - # 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 rollup: ^4.60.3 shiki: ^4.0.2 sirv: ^3.0.2 diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 8a3b36e7..92f9ca69 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -663,6 +663,7 @@ describe('speedcurve', () => { expect(String(snippetVersion)).toMatch(/^\d+\.\d+/) }) + // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: calls startSoftNavigation when navigating to a new route', { timeout: 15000, }, async () => { @@ -678,6 +679,7 @@ describe('speedcurve', () => { expect(softNavCalls).toHaveLength(1) }) + // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: calls markLoadTime after page:finish + paint', { timeout: 15000, }, async () => { @@ -693,6 +695,7 @@ describe('speedcurve', () => { expect(loadTimeCalls.length).toBeGreaterThanOrEqual(1) }) + // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: timing order is startSoftNavigation before markLoadTime', { timeout: 15000, }, async () => { @@ -709,6 +712,7 @@ describe('speedcurve', () => { expect(markLoadIdx).toBeGreaterThan(softNavIdx) }) + // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto default label: uses Nuxt-generated route name as label', { timeout: 15000, }, async () => { @@ -725,6 +729,7 @@ describe('speedcurve', () => { expect(label).toMatch(/speedcurve.*destination/) }) + // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: back navigation triggers second startSoftNavigation', { timeout: 20000, }, async () => { @@ -769,6 +774,7 @@ describe('speedcurve', () => { expect(page.url()).not.toContain('/blocked') }) + // eslint-disable-next-line test/prefer-lowercase-title it('SPA off: no startSoftNavigation call when spaMode is false', { timeout: 15000, }, async () => { diff --git a/test/unit/speedcurve-after-next-paint.test.ts b/test/unit/speedcurve-after-next-paint.test.ts index ab66e083..2308f53c 100644 --- a/test/unit/speedcurve-after-next-paint.test.ts +++ b/test/unit/speedcurve-after-next-paint.test.ts @@ -6,7 +6,10 @@ import { afterNextPaint } from '../../packages/script/src/runtime/utils/after-ne describe('afterNextPaint', () => { beforeEach(() => { - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) }) afterEach(() => { @@ -34,7 +37,10 @@ describe('afterNextPaint', () => { 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 + .mockImplementationOnce((cb) => { + outerCb = cb + return 0 + }) // capture outer rAF .mockImplementation(() => 0) // inner rAF: don't fire const cb = vi.fn() diff --git a/test/unit/speedcurve-auto-tracker.test.ts b/test/unit/speedcurve-auto-tracker.test.ts index 337fbbcf..9023a80e 100644 --- a/test/unit/speedcurve-auto-tracker.test.ts +++ b/test/unit/speedcurve-auto-tracker.test.ts @@ -11,7 +11,7 @@ vi.mock('nuxt/app', () => ({ useHead: vi.fn(), useRouter: () => ({ beforeEach: mockBeforeEach, afterEach: mockAfterEach }), useNuxtApp: () => ({ hook: mockHook, payload: { serverRendered: true } }), - useRuntimeConfig: () => ({ public: { scripts: {}, 'nuxt-scripts': {} } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), createError: (e: any) => new Error(e.message), injectHead: vi.fn(), onNuxtReady: vi.fn(), @@ -80,7 +80,7 @@ describe('installAutoTracker', () => { useHead: vi.fn(), useRouter: () => ({ beforeEach: mockBeforeEach, afterEach: mockAfterEach }), useNuxtApp: () => ({ hook: mockHook, payload: { serverRendered: false } }), - useRuntimeConfig: () => ({ public: { scripts: {}, 'nuxt-scripts': {} } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), createError: (e: any) => new Error(e.message), injectHead: vi.fn(), onNuxtReady: vi.fn(), @@ -115,7 +115,7 @@ describe('installAutoTracker', () => { useHead: vi.fn(), useRouter: () => ({ beforeEach: mockBeforeEach, afterEach: mockAfterEach }), useNuxtApp: () => ({ hook: mockHook, payload: { serverRendered: true } }), - useRuntimeConfig: () => ({ public: { scripts: {}, 'nuxt-scripts': {} } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), createError: (e: any) => new Error(e.message), injectHead: vi.fn(), onNuxtReady: vi.fn(), @@ -191,7 +191,10 @@ describe('installAutoTracker', () => { 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 }) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) const { installAutoTracker } = await import('../../packages/script/src/runtime/registry/speedcurve') installAutoTracker({ id: '123' }) diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts index d597897c..42db3f71 100644 --- a/test/unit/speedcurve-config.test.ts +++ b/test/unit/speedcurve-config.test.ts @@ -7,7 +7,7 @@ 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': {} } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), createError: (e: any) => new Error(e.message), injectHead: vi.fn(), onNuxtReady: vi.fn(), diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts index a2e9075f..df09ac2d 100644 --- a/test/unit/speedcurve-primer.test.ts +++ b/test/unit/speedcurve-primer.test.ts @@ -9,7 +9,7 @@ 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': {} } }), + useRuntimeConfig: () => ({ public: { 'scripts': {}, 'nuxt-scripts': {} } }), createError: (e: any) => new Error(e.message), injectHead: vi.fn(), onNuxtReady: vi.fn(), From c2dd394aac6dad040cef6a65ccde8441056d5c48 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 10:28:41 +0100 Subject: [PATCH 19/32] chore(speedcurve): fix ESLint issues, restore SPA capitalization, consolidate e2e timeouts - Restore SPA capitalization in e2e test names (test/prefer-lowercase-title was auto-lowercasing 'SPA' to 'sPA'); add eslint-disable-next-line tags - Consolidate per-test timeouts into describe-level { timeout: 15000 }; only back-navigation test keeps its own 20000ms override - Add eslint-disable for passive voice on "is injected" in docs - Fix style/max-statements-per-line in afterNextPaint tests - Apply auto-fixed style issues (sort-keys, sort-imports, if-newline, etc.) --- test/e2e/basic.test.ts | 42 +++++++++++------------------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 92f9ca69..f10d9172 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -636,7 +636,7 @@ describe.skip('social-embeds', () => { }) }) -describe('speedcurve', () => { +describe('speedcurve', { timeout: 15000 }, () => { // Helper: read captured LUX calls from the page's spy async function getLuxCalls(page: any) { return page.evaluate(() => (window as any)._luxCalls as Array<{ method: string, args: unknown[] }>) @@ -651,9 +651,7 @@ describe('speedcurve', () => { ) } - it('primer: injects LUX into with correct snippetVersion', { - timeout: 15000, - }, async () => { + it('primer: injects LUX into with correct snippetVersion', async () => { const { page } = await createPage('/tpc/speedcurve') const snippetVersion = await page.evaluate( @@ -664,9 +662,7 @@ describe('speedcurve', () => { }) // eslint-disable-next-line test/prefer-lowercase-title - it('SPA auto: calls startSoftNavigation when navigating to a new route', { - timeout: 15000, - }, async () => { + it('SPA auto: calls startSoftNavigation when navigating to a new route', async () => { const { page } = await createPage('/tpc/speedcurve') // Navigate via NuxtLink (SPA navigation, no full reload) @@ -680,9 +676,7 @@ describe('speedcurve', () => { }) // eslint-disable-next-line test/prefer-lowercase-title - it('SPA auto: calls markLoadTime after page:finish + paint', { - timeout: 15000, - }, async () => { + it('SPA auto: calls markLoadTime after page:finish + paint', async () => { const { page } = await createPage('/tpc/speedcurve') await page.click('#nav-destination') @@ -696,9 +690,7 @@ describe('speedcurve', () => { }) // eslint-disable-next-line test/prefer-lowercase-title - it('SPA auto: timing order is startSoftNavigation before markLoadTime', { - timeout: 15000, - }, async () => { + it('SPA auto: timing order is startSoftNavigation before markLoadTime', async () => { const { page } = await createPage('/tpc/speedcurve') await page.click('#nav-destination') @@ -713,9 +705,7 @@ describe('speedcurve', () => { }) // eslint-disable-next-line test/prefer-lowercase-title - it('SPA auto default label: uses Nuxt-generated route name as label', { - timeout: 15000, - }, async () => { + it('SPA auto default label: uses Nuxt-generated route name as label', async () => { const { page } = await createPage('/tpc/speedcurve') await page.click('#nav-destination') @@ -748,9 +738,7 @@ describe('speedcurve', () => { expect(softNavCalls.length).toBeGreaterThanOrEqual(2) }) - it('canceled navigation: seals phantom beacon with luxNavFailed tag', { - timeout: 15000, - }, async () => { + it('canceled navigation: seals phantom beacon with luxNavFailed tag', async () => { const { page } = await createPage('/tpc/speedcurve') // A guard in the fixture blocks navigation to /blocked and returns false @@ -775,9 +763,7 @@ describe('speedcurve', () => { }) // eslint-disable-next-line test/prefer-lowercase-title - it('SPA off: no startSoftNavigation call when spaMode is false', { - timeout: 15000, - }, async () => { + it('SPA off: no startSoftNavigation call when spaMode is false', async () => { const { page } = await createPage('/tpc/speedcurve-no-spa') await page.click('#nav-destination') @@ -789,9 +775,7 @@ describe('speedcurve', () => { expect(softNavCalls).toHaveLength(0) }) - it('custom labelFor: applies the user-provided label function', { - timeout: 15000, - }, async () => { + it('custom labelFor: applies the user-provided label function', async () => { const { page } = await createPage('/tpc/speedcurve-custom-label') await page.click('#nav-destination') @@ -802,9 +786,7 @@ describe('speedcurve', () => { expect(label).toBe('custom:/tpc/speedcurve-custom-label/destination') }) - it('labelFor false: leaves window.LUX.label unchanged after navigation', { - timeout: 15000, - }, async () => { + it('labelFor false: leaves window.LUX.label unchanged after navigation', async () => { const { page } = await createPage('/tpc/speedcurve-label-off') await page.click('#nav-destination') @@ -817,9 +799,7 @@ describe('speedcurve', () => { expect(label).toBe('original-label') }) - it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', { - timeout: 15000, - }, async () => { + it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', async () => { const { page } = await createPage('/tpc/speedcurve-manual') await page.click('#nav-destination') From c8cee0eff771485058f13f209d84bd8a65078c44 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 10:43:12 +0100 Subject: [PATCH 20/32] refactor(speedcurve): type SpeedCurveInput with upstream UserConfig; derive forwarded keys from schema SpeedCurveInput now intersects UserConfig (= Partial) from @speedcurve/lux so the TypeScript type is linked to the upstream source of truth rather than duplicating it. LUX_USER_CONFIG_KEYS is derived from SpeedCurveOptions.entries at module-load time (filtering out composable-only keys id and autoTrackSpaNavigations), so the schema is the single source of truth for which fields get forwarded to window.LUX. --- .../script/src/runtime/registry/speedcurve.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 2cf9fc21..5eb3f04f 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -23,7 +23,7 @@ declare global { } } -export type SpeedCurveInput = RegistryScriptInput & { +export type SpeedCurveInput = RegistryScriptInput & UserConfig & { /** * Derive a page label for each SPA navigation. * Defaults to `String(to.name ?? to.path)`. @@ -32,21 +32,10 @@ export type SpeedCurveInput = RegistryScriptInput & { labelFor?: ((to: RouteLocationNormalized) => string) | false } -// LUX UserConfig keys that map 1:1 to our schema (autoTrackSpaNavigations is composable-only). -const LUX_USER_CONFIG_KEYS: (keyof UserConfig)[] = [ - 'spaMode', - 'auto', - 'label', - 'samplerate', - 'sendBeaconOnPageHidden', - 'trackErrors', - 'maxErrors', - 'minMeasureTime', - 'maxMeasureTime', - 'newBeaconOnPageShow', - 'trackHiddenPages', - 'cookieDomain', -] +// 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', +) as (keyof UserConfig)[] export function useScriptSpeedCurve(_options?: SpeedCurveInput): UseScriptContext { return useRegistryScript('speedcurve', options => ({ From 6762b9fa7663adb6630aa3d217ebe4cf6ea55d33 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 12:29:28 +0100 Subject: [PATCH 21/32] test(speedcurve): increase timeout for autoTrackSpaNavigations=false e2e test The autoTrackSpaNavigations=false test is last in the describe block and runs after 10 other tests (some with 20s timeouts), making it the most susceptible to accumulated fixture-server latency. Give it the same 20s override as the back-nav test. --- test/e2e/basic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index f10d9172..a9d04345 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -799,7 +799,7 @@ describe('speedcurve', { timeout: 15000 }, () => { expect(label).toBe('original-label') }) - it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', async () => { + it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', { timeout: 20000 }, async () => { const { page } = await createPage('/tpc/speedcurve-manual') await page.click('#nav-destination') From 8caadd05ad46da844c639c595be16c896bb2b0b5 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 17 May 2026 21:16:05 +0100 Subject: [PATCH 22/32] fix(speedcurve): block CDN in e2e tests; fix composableName casing - Add composableName: 'useScriptSpeedCurve' to registry def so auto-import resolves the correct casing (fixes 500 on all speedcurve fixture routes) - Add setup callback to createPage helper for pre-navigation Playwright config - Use scPage wrapper to block cdn.speedcurve.com so the CDN script never overwrites the window.LUX spy set up by test fixtures - Guard window._luxCalls init with !window._luxCalls so back-navigation remounts don't reset the accumulated call log - Wait for initial page:finish markLoadTime before clearing _luxCalls in the timing-order test (rAF fires between evaluate and click otherwise) --- packages/script/src/registry.ts | 1 + test/e2e/basic.test.ts | 35 +++++++++++++------ .../basic/pages/tpc/speedcurve/index.vue | 2 +- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 7f399eba..05884d64 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -859,6 +859,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro src: false, category: 'utility', envDefaults: { id: '' }, + composableName: 'useScriptSpeedCurve', }), ]) } diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index a9d04345..4bd962a1 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -35,6 +35,8 @@ async function createPage(path: string, options?: any) { await waitForHydration(page, url2, waitUntil) return res } + if (options?.setup) + await options.setup(page) if (path) { // @ts-expect-error untyped await page.goto(url(path), options?.javaScriptEnabled === false ? {} : { waitUntil: 'hydration' }) @@ -637,6 +639,13 @@ describe.skip('social-embeds', () => { }) describe('speedcurve', { timeout: 15000 }, () => { + // Block the real CDN so window.LUX spy set up in fixtures is never overwritten + async function scPage(path: string) { + return createPage(path, { + setup: (p: any) => p.route('**/cdn.speedcurve.com/**', (r: any) => r.abort()), + }) + } + // Helper: read captured LUX calls from the page's spy async function getLuxCalls(page: any) { return page.evaluate(() => (window as any)._luxCalls as Array<{ method: string, args: unknown[] }>) @@ -652,7 +661,7 @@ describe('speedcurve', { timeout: 15000 }, () => { } it('primer: injects LUX into with correct snippetVersion', async () => { - const { page } = await createPage('/tpc/speedcurve') + const { page } = await scPage('/tpc/speedcurve') const snippetVersion = await page.evaluate( () => (window as any).LUX?.snippetVersion, @@ -663,7 +672,7 @@ describe('speedcurve', { timeout: 15000 }, () => { // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: calls startSoftNavigation when navigating to a new route', async () => { - const { page } = await createPage('/tpc/speedcurve') + const { page } = await scPage('/tpc/speedcurve') // Navigate via NuxtLink (SPA navigation, no full reload) await page.click('#nav-destination') @@ -677,7 +686,7 @@ describe('speedcurve', { timeout: 15000 }, () => { // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: calls markLoadTime after page:finish + paint', async () => { - const { page } = await createPage('/tpc/speedcurve') + const { page } = await scPage('/tpc/speedcurve') await page.click('#nav-destination') await page.waitForSelector('#page') @@ -691,7 +700,11 @@ describe('speedcurve', { timeout: 15000 }, () => { // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto: timing order is startSoftNavigation before markLoadTime', async () => { - const { page } = await createPage('/tpc/speedcurve') + 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') @@ -706,7 +719,7 @@ describe('speedcurve', { timeout: 15000 }, () => { // eslint-disable-next-line test/prefer-lowercase-title it('SPA auto default label: uses Nuxt-generated route name as label', async () => { - const { page } = await createPage('/tpc/speedcurve') + const { page } = await scPage('/tpc/speedcurve') await page.click('#nav-destination') await page.waitForSelector('#page') @@ -723,7 +736,7 @@ describe('speedcurve', { timeout: 15000 }, () => { it('SPA auto: back navigation triggers second startSoftNavigation', { timeout: 20000, }, async () => { - const { page } = await createPage('/tpc/speedcurve') + const { page } = await scPage('/tpc/speedcurve') await page.click('#nav-destination') await page.waitForSelector('#page') @@ -739,7 +752,7 @@ describe('speedcurve', { timeout: 15000 }, () => { }) it('canceled navigation: seals phantom beacon with luxNavFailed tag', async () => { - const { page } = await createPage('/tpc/speedcurve') + const { page } = await scPage('/tpc/speedcurve') // A guard in the fixture blocks navigation to /blocked and returns false await page.click('#nav-blocked') @@ -764,7 +777,7 @@ describe('speedcurve', { timeout: 15000 }, () => { // eslint-disable-next-line test/prefer-lowercase-title it('SPA off: no startSoftNavigation call when spaMode is false', async () => { - const { page } = await createPage('/tpc/speedcurve-no-spa') + const { page } = await scPage('/tpc/speedcurve-no-spa') await page.click('#nav-destination') await page.waitForSelector('#page') @@ -776,7 +789,7 @@ describe('speedcurve', { timeout: 15000 }, () => { }) it('custom labelFor: applies the user-provided label function', async () => { - const { page } = await createPage('/tpc/speedcurve-custom-label') + const { page } = await scPage('/tpc/speedcurve-custom-label') await page.click('#nav-destination') await page.waitForSelector('#page') @@ -787,7 +800,7 @@ describe('speedcurve', { timeout: 15000 }, () => { }) it('labelFor false: leaves window.LUX.label unchanged after navigation', async () => { - const { page } = await createPage('/tpc/speedcurve-label-off') + const { page } = await scPage('/tpc/speedcurve-label-off') await page.click('#nav-destination') await page.waitForSelector('#page') @@ -800,7 +813,7 @@ describe('speedcurve', { timeout: 15000 }, () => { }) it('autoTrackSpaNavigations false: no startSoftNavigation calls on navigation', { timeout: 20000 }, async () => { - const { page } = await createPage('/tpc/speedcurve-manual') + const { page } = await scPage('/tpc/speedcurve-manual') await page.click('#nav-destination') await page.waitForSelector('#page') diff --git a/test/fixtures/basic/pages/tpc/speedcurve/index.vue b/test/fixtures/basic/pages/tpc/speedcurve/index.vue index 6ed3bc9c..520a6a57 100644 --- a/test/fixtures/basic/pages/tpc/speedcurve/index.vue +++ b/test/fixtures/basic/pages/tpc/speedcurve/index.vue @@ -1,7 +1,7 @@ + + 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/nuxt-scripts.vue b/playground/pages/third-parties/speed-curve/nuxt-scripts.vue deleted file mode 100644 index 02295bc3..00000000 --- a/playground/pages/third-parties/speed-curve/nuxt-scripts.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - 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..89313cd3 --- /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..b5814793 --- /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 @@ + + + From 8bd9201cd9ea54c260498bffd2da87d88762acba Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Mon, 18 May 2026 13:11:46 +0100 Subject: [PATCH 27/32] fix(speedcurve): unify label/labelFor, remove auto option - Merge labelFor into label: accepts string | (to) => string | false - Remove auto from schema (incompatible with spaMode; accessible via proxy) - applyConfig skips label when non-string (functions handled by installAutoTracker) - Add function_/literal(false) union to schema for docs table + dev validation - Add function_() support to generate-registry-types.ts AST resolver - Add unit tests for all three label variants in applyConfig and installAutoTracker --- docs/content/scripts/speedcurve.md | 7 ++-- .../script/src/runtime/registry/schemas.ts | 12 +++--- .../script/src/runtime/registry/speedcurve.ts | 38 ++++++++++--------- scripts/generate-registry-types.ts | 2 + test/unit/speedcurve-auto-tracker.test.ts | 22 +++++++++-- test/unit/speedcurve-config.test.ts | 30 +++++++++++++++ 6 files changed, 78 insertions(+), 33 deletions(-) diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md index fdbf4b13..810bfb67 100644 --- a/docs/content/scripts/speedcurve.md +++ b/docs/content/scripts/speedcurve.md @@ -67,18 +67,17 @@ useScriptSpeedCurve({ ## Custom page labels -By default the composable uses `String(to.name ?? to.path)`{lang="ts"} as the page label for each navigation. Override with `labelFor`: - +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, - labelFor: to => to.meta.title as string ?? to.path, + label: to => to.meta.title as string ?? to.path, }) ``` -Set `labelFor: false` to disable labeling entirely. +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 diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index e1a2b486..449f7c46 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, minLength, number, object, optional, pipe, record, string, union } from 'valibot' // Shared GCMv2 consent category value. const consentCategoryValue = union([literal('granted'), literal('denied')]) @@ -1025,15 +1025,13 @@ export const SpeedCurveOptions = object({ * @default true (when spaMode is true) */ autoTrackSpaNavigations: optional(boolean()), - /** - * Automatically send a LUX beacon when the page loads. - * @default true - */ - auto: 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(string()), + label: optional(union([string(), function_(), literal(false)])), /** * Sampling rate (0–100). Percentage of sessions that send beacons. * Upstream spelling is lowercase — matches LUX UserConfig. diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 9d8a7454..a0f438fc 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -21,13 +21,8 @@ declare global { } } -export type SpeedCurveInput = RegistryScriptInput & UserConfig & { - /** - * Derive a page label for each SPA navigation. - * Defaults to `String(to.name ?? to.path)`. - * Set to `false` to disable labeling. - */ - labelFor?: ((to: RouteLocationNormalized) => string) | false +export type SpeedCurveInput = Omit, 'label'> & Omit & { + label?: string | ((to: RouteLocationNormalized) => string) | false } // Derived from the schema: all schema keys except the composable-only ones. @@ -61,11 +56,12 @@ export function useScriptSpeedCurve(_options?: SpeedCur clientInit: import.meta.server ? undefined : () => { - applyConfig(options) + const input = options as unknown as SpeedCurveInput + applyConfig(input) if (options.spaMode && options.autoTrackSpaNavigations !== false) - installAutoTracker(options) + installAutoTracker(input) }, - }), _options) as UseScriptContext + }), _options as unknown as RegistryScriptInput) as UseScriptContext } export function applyConfig(options: SpeedCurveInput): void { @@ -74,8 +70,12 @@ export function applyConfig(options: SpeedCurveInput): void { return for (const k of LUX_USER_CONFIG_KEYS) { const v = (options as Record)[k as string] - if (v !== undefined) - lux[k as string] = v + 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 } } @@ -91,9 +91,11 @@ export function installAutoTracker(options?: SpeedCurveInput): void { // client-side render — not a user navigation. Skip startSoftNavigation. let pendingInitial = nuxt.payload?.serverRendered === false - const labelFor = options?.labelFor === false + const label = options?.label === false ? null - : (options?.labelFor ?? ((to: RouteLocationNormalized) => String(to.name ?? to.path))) + : typeof options?.label === 'function' + ? options.label + : (to: RouteLocationNormalized) => String(to.name ?? to.path) router.beforeEach((to) => { const lux = window.LUX @@ -101,13 +103,13 @@ export function installAutoTracker(options?: SpeedCurveInput): void { return if (pendingInitial) { pendingInitial = false - if (labelFor) - lux.label = labelFor(to) + if (label) + lux.label = label(to) return } lux.startSoftNavigation() - if (labelFor) - lux.label = labelFor(to) + if (label) + lux.label = label(to) }) // If a guard cancels navigation, seal the phantom beacon with a filterable tag. 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/unit/speedcurve-auto-tracker.test.ts b/test/unit/speedcurve-auto-tracker.test.ts index 0549ec65..5aee5450 100644 --- a/test/unit/speedcurve-auto-tracker.test.ts +++ b/test/unit/speedcurve-auto-tracker.test.ts @@ -140,12 +140,26 @@ describe('installAutoTracker', () => { expect(lux.label).toBe('about') }) - it('uses labelFor function when provided', async () => { + 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', labelFor: to => `custom:${to.path}` }) + 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' }) @@ -153,12 +167,12 @@ describe('installAutoTracker', () => { expect(lux.label).toBe('custom:/about') }) - it('does not set label when labelFor is false', async () => { + 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', labelFor: false }) + installAutoTracker({ id: '123', label: false }) const handler = mockBeforeEach.mock.calls[0][0] as (to: any) => void handler({ name: 'about', path: '/about' }) diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts index 42db3f71..3e66c980 100644 --- a/test/unit/speedcurve-config.test.ts +++ b/test/unit/speedcurve-config.test.ts @@ -75,4 +75,34 @@ describe('applyConfig', () => { 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() + }) }) From 539a02a194ff254705ae652312fb475bebf3094f Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Mon, 18 May 2026 13:42:01 +0100 Subject: [PATCH 28/32] fix(speedcurve): address CodeRabbit review feedback - Regenerate registry-types.json: remove stale `auto` field, update `label` to reflect union([string(), function_(), literal(false)]) - Add minValue(0)/maxValue(100) bounds to samplerate schema field - Allow label callback to return `false` per navigation to skip that navigation's label assignment (type + runtime guard) - Add `text` language to CSP fenced code block in docs (MD040) --- docs/content/scripts/speedcurve.md | 2 +- packages/script/src/registry-types.json | 14 ++++---------- packages/script/src/runtime/registry/schemas.ts | 4 ++-- .../script/src/runtime/registry/speedcurve.ts | 16 +++++++++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md index 810bfb67..68bd3f83 100644 --- a/docs/content/scripts/speedcurve.md +++ b/docs/content/scripts/speedcurve.md @@ -83,7 +83,7 @@ Set `label: false` to disable labeling entirely. Pass a plain string to set a st 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; diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 0d6f119f..e023701b 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -970,7 +970,7 @@ { "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 * Automatically send a LUX beacon when the page loads.\n * @default true\n */\n auto: optional(boolean()),\n /**\n * Page label shown in the SpeedCurve dashboard.\n */\n label: optional(string()),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(number()),\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})" + "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", @@ -2392,18 +2392,12 @@ "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": "auto", - "type": "boolean", - "required": false, - "description": "Automatically send a LUX beacon when the page loads.", - "defaultValue": "true" - }, { "name": "label", - "type": "string", + "type": "string | Function | false", "required": false, - "description": "Page label shown in the SpeedCurve dashboard." + "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", diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 449f7c46..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, function_, 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')]) @@ -1036,7 +1036,7 @@ export const SpeedCurveOptions = object({ * Sampling rate (0–100). Percentage of sessions that send beacons. * Upstream spelling is lowercase — matches LUX UserConfig. */ - samplerate: optional(number()), + samplerate: optional(pipe(number(), minValue(0), maxValue(100))), /** * Send the beacon when the page is hidden (pagehide event). * @default true diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index a0f438fc..18c82db0 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -22,7 +22,7 @@ declare global { } export type SpeedCurveInput = Omit, 'label'> & Omit & { - label?: string | ((to: RouteLocationNormalized) => string) | false + label?: string | ((to: RouteLocationNormalized) => string | false) | false } // Derived from the schema: all schema keys except the composable-only ones. @@ -103,13 +103,19 @@ export function installAutoTracker(options?: SpeedCurveInput): void { return if (pendingInitial) { pendingInitial = false - if (label) - lux.label = label(to) + if (label) { + const nextLabel = label(to) + if (nextLabel !== false) + lux.label = nextLabel + } return } lux.startSoftNavigation() - if (label) - lux.label = label(to) + 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. From 59b692544604b3f6588a5717f99a30468a207d50 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Mon, 18 May 2026 13:50:12 +0100 Subject: [PATCH 29/32] =?UTF-8?q?fix(speedcurve):=20address=20CR=20nitpick?= =?UTF-8?q?s=20=E2=80=94=20imports,=20regression=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit imports for ref/onMounted in spa-auto/a.vue and b.vue to match the explicit import style used for useHead/useScriptSpeedCurve - Add regression test: label callback returning false must not update lux.label (covers the string|false return branch added in prior commit) --- .../pages/third-parties/speed-curve/spa-auto/a.vue | 2 +- .../pages/third-parties/speed-curve/spa-auto/b.vue | 2 +- test/unit/speedcurve-auto-tracker.test.ts | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/playground/pages/third-parties/speed-curve/spa-auto/a.vue b/playground/pages/third-parties/speed-curve/spa-auto/a.vue index 89313cd3..b4036298 100644 --- a/playground/pages/third-parties/speed-curve/spa-auto/a.vue +++ b/playground/pages/third-parties/speed-curve/spa-auto/a.vue @@ -1,5 +1,5 @@ diff --git a/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue b/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue index 7fefb658..87b0bf20 100644 --- a/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue +++ b/test/fixtures/speedcurve/pages/tpc/speedcurve-label-off/index.vue @@ -18,7 +18,7 @@ const { status } = useScriptSpeedCurve({ id: '123456789', spaMode: true, autoTrackSpaNavigations: true, - labelFor: false, + label: false, })