From 911b04d6e3b70fee3b130ddc415d9a9027db7592 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 10:23:21 -0400 Subject: [PATCH 01/23] feat(linkedin-insight): add LinkedInInsightOptions schema --- .../script/src/runtime/registry/schemas.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 796c6ef4..ece1555b 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -824,6 +824,39 @@ export const BingUetOptions = object({ })), }) +export const LinkedInInsightOptions = object({ + /** + * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push + * onto window._linkedin_data_partner_ids. The first ID is used as the + * primary _linkedin_partner_id global. + * @example '541681' + * @example ['541681', '987654'] + * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id + */ + id: union([pipe(string(), minLength(1)), array(pipe(string(), minLength(1)))]), + /** + * Optional page-load event ID for Conversions API deduplication. Assigned + * to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must + * match the eventId sent with the corresponding server-side Conversions + * API event. + * + * Per-event conversion deduplication uses the per-call event_id passed to + * lintrk('track', { conversion_id, event_id }) instead. + * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication + */ + eventId: optional(string()), + /** + * Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page + * views). When true, suppresses the script's built-in auto-page-view via + * window._wait_for_lintrk and tracks every navigation including the + * initial page through Nuxt's page:finish hook. When false, the script + * fires its own page-view exactly once on load and SPA navigations are + * not tracked unless lintrk('track') is called manually. + * @default false + */ + enableAutoSpaTracking: optional(boolean()), +}) + export const SegmentOptions = object({ /** * Your Segment write key. From 966fb086c35065f6f3d673e3770672ab6e3144b9 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 10:31:40 -0400 Subject: [PATCH 02/23] feat(linkedin-insight): add useScriptLinkedInInsight composable --- .../src/runtime/registry/linkedin-insight.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/script/src/runtime/registry/linkedin-insight.ts diff --git a/packages/script/src/runtime/registry/linkedin-insight.ts b/packages/script/src/runtime/registry/linkedin-insight.ts new file mode 100644 index 00000000..fd28ca5c --- /dev/null +++ b/packages/script/src/runtime/registry/linkedin-insight.ts @@ -0,0 +1,101 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useScriptEventPage } from '../composables/useScriptEventPage' +import { useRegistryScript } from '../utils' +import { LinkedInInsightOptions } from './schemas' + +export { LinkedInInsightOptions } + +export type LinkedInInsightInput = RegistryScriptInput + +interface LintrkTrackParams { + /** Conversion ID from Campaign Manager. Omit for plain page-view. */ + conversion_id?: number + /** Per-event dedup ID matching the server-side Conversions API event. */ + event_id?: string + /** Optional callback fired after the beacon completes. */ + commandCallback?: () => void + [key: string]: any +} + +interface LintrkUserData { + /** Plain email; the script SHA-256 hashes it before sending. */ + email: string +} + +type LintrkFns + = & ((cmd: 'track', params?: LintrkTrackParams) => void) + & ((cmd: 'setUserData', data: LintrkUserData) => void) + & ((cmd: (string & {}), ...args: any[]) => void) + +export interface LinkedInInsightApi { + lintrk: LintrkFns & { q?: unknown[] } +} + +declare global { + interface Window { + lintrk: LinkedInInsightApi['lintrk'] + _linkedin_partner_id?: string + _linkedin_data_partner_ids?: string[] + _linkedin_event_id?: string + _wait_for_lintrk?: boolean + _already_called_lintrk?: boolean + } +} + +export function useScriptLinkedInInsight( + _options?: LinkedInInsightInput, +): UseScriptContext { + const instance = useRegistryScript('linkedinInsight', options => ({ + scriptInput: { + src: 'https://snap.licdn.com/li.lms-analytics/insight.min.js', + crossorigin: false, + }, + schema: import.meta.dev ? LinkedInInsightOptions : undefined, + scriptOptions: { + use() { + return { lintrk: window.lintrk } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + const ids = Array.isArray(options.id) ? options.id : [options.id] + const primary = ids[0] + + // 1. Page-load event ID MUST be set first per LinkedIn dedup doc + if (options.eventId) + window._linkedin_event_id = options.eventId + + // 2. Suppress built-in auto-page-view if we own SPA tracking + if (options.enableAutoSpaTracking) + window._wait_for_lintrk = true + + // 3. Partner ID globals + window._linkedin_partner_id = primary + window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [] + for (const id of ids) { + if (!window._linkedin_data_partner_ids.includes(id)) + window._linkedin_data_partner_ids.push(id) + } + + // 4. Stub the lintrk queue (idempotent — only if not already defined) + if (!window.lintrk) { + const lintrk = function (a: string, b: any) { + ;(lintrk as any).q.push([a, b]) + } as LinkedInInsightApi['lintrk'] + ;(lintrk as any).q = [] + window.lintrk = lintrk + } + }, + }), _options) + + // 5. Wire SPA route tracking after the registry script is set up. + // Same primitive as runtime/registry/matomo-analytics.ts:90. + if (import.meta.client && _options?.enableAutoSpaTracking) { + useScriptEventPage(() => { + window.lintrk('track') + }) + } + + return instance +} From 0536203013f8c3b1e3e873d4278b4bd9dd752a08 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 11:59:36 -0400 Subject: [PATCH 03/23] fix(linkedin-insight): align Window types and move SPA hook into clientInit --- .../src/runtime/registry/linkedin-insight.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/script/src/runtime/registry/linkedin-insight.ts b/packages/script/src/runtime/registry/linkedin-insight.ts index fd28ca5c..d7897482 100644 --- a/packages/script/src/runtime/registry/linkedin-insight.ts +++ b/packages/script/src/runtime/registry/linkedin-insight.ts @@ -32,8 +32,7 @@ export interface LinkedInInsightApi { } declare global { - interface Window { - lintrk: LinkedInInsightApi['lintrk'] + interface Window extends LinkedInInsightApi { _linkedin_partner_id?: string _linkedin_data_partner_ids?: string[] _linkedin_event_id?: string @@ -86,16 +85,16 @@ export function useScriptLinkedInInsight( ;(lintrk as any).q = [] window.lintrk = lintrk } + + // 5. Wire SPA route tracking. Same primitive as + // runtime/registry/matomo-analytics.ts:90. + if (options.enableAutoSpaTracking) { + useScriptEventPage(() => { + window.lintrk('track') + }) + } }, }), _options) - // 5. Wire SPA route tracking after the registry script is set up. - // Same primitive as runtime/registry/matomo-analytics.ts:90. - if (import.meta.client && _options?.enableAutoSpaTracking) { - useScriptEventPage(() => { - window.lintrk('track') - }) - } - return instance } From ef66758334ab3dbd32b17ebbc7c69f54809d2921 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 12:05:02 -0400 Subject: [PATCH 04/23] feat(linkedin-insight): add logo --- packages/script/src/registry-logos.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 1c572e86..b4d5613d 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -24,6 +24,7 @@ export const LOGOS = { }, tiktokPixel: ``, redditPixel: ` `, + linkedinInsight: ``, googleAdsense: ``, carbonAds: { light: ``, From a8284b8fa2d788d75e97c6b7624b9bf43f56b790 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 15:38:17 -0400 Subject: [PATCH 05/23] feat(linkedin-insight): register in metadata and async registry --- packages/script/src/registry.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index e7ddbc60..9117c430 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -30,6 +30,7 @@ import { HotjarOptions, InstagramEmbedOptions, IntercomOptions, + LinkedInInsightOptions, MatomoAnalyticsOptions, MetaPixelOptions, MixpanelAnalyticsOptions, @@ -138,6 +139,7 @@ export const registryMeta: RegistryScriptMeta[] = [ m('tiktokPixel', 'TikTok Pixel', 'ad', 'useScriptTikTokPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), m('snapchatPixel', 'Snapchat Pixel', 'ad', 'useScriptSnapchatPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), m('redditPixel', 'Reddit Pixel', 'ad', 'useScriptRedditPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), + m('linkedinInsight', 'LinkedIn Insight Tag', 'ad', 'useScriptLinkedInInsight', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), m('googleAdsense', 'Google Adsense', 'ad', 'useScriptGoogleAdsense', { bundle: true, proxy: true }, PRIVACY_HEATMAP), m('carbonAds', 'Carbon Ads', 'ad', false, { proxy: true }, PRIVACY_IP_ONLY), // tag-manager @@ -508,6 +510,21 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, partytown: { forwards: ['rdt'] }, }), + def('linkedinInsight', { + // explicit override: auto-derived would be `useScriptLinkedinInsight` (lowercase i) + composableName: 'useScriptLinkedInInsight', + schema: LinkedInInsightOptions, + label: 'LinkedIn Insight Tag', + src: 'https://snap.licdn.com/li.lms-analytics/insight.min.js', + category: 'ad', + envDefaults: { id: '' }, + bundle: true, + proxy: { + domains: ['snap.licdn.com', 'px.ads.linkedin.com'], + privacy: PRIVACY_FULL, + }, + partytown: { forwards: ['lintrk'] }, + }), def('googleAdsense', { schema: GoogleAdsenseOptions, label: 'Google Adsense', From 6b726518c23e8fe82ec19468f76e614c45633b68 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 15:39:29 -0400 Subject: [PATCH 06/23] chore(linkedin-insight): regenerate registry-types.json --- packages/script/src/registry-types.json | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 81e2e78e..916a6529 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -606,6 +606,33 @@ "code": "interface ScriptLemonSqueezySlots {\n default?: () => any\n}" } ], + "linkedin-insight": [ + { + "name": "LinkedInInsightOptions", + "kind": "const", + "code": "export const LinkedInInsightOptions = object({\n /**\n * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push\n * onto window._linkedin_data_partner_ids. The first ID is used as the\n * primary _linkedin_partner_id global.\n * @example '541681'\n * @example ['541681', '987654']\n * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id\n */\n id: union([pipe(string(), minLength(1)), array(pipe(string(), minLength(1)))]),\n /**\n * Optional page-load event ID for Conversions API deduplication. Assigned\n * to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must\n * match the eventId sent with the corresponding server-side Conversions\n * API event.\n *\n * Per-event conversion deduplication uses the per-call event_id passed to\n * lintrk('track', { conversion_id, event_id }) instead.\n * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication\n */\n eventId: optional(string()),\n /**\n * Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page\n * views). When true, suppresses the script's built-in auto-page-view via\n * window._wait_for_lintrk and tracks every navigation including the\n * initial page through Nuxt's page:finish hook. When false, the script\n * fires its own page-view exactly once on load and SPA navigations are\n * not tracked unless lintrk('track') is called manually.\n * @default false\n */\n enableAutoSpaTracking: optional(boolean()),\n})" + }, + { + "name": "LintrkTrackParams", + "kind": "interface", + "code": "interface LintrkTrackParams {\n /** Conversion ID from Campaign Manager. Omit for plain page-view. */\n conversion_id?: number\n /** Per-event dedup ID matching the server-side Conversions API event. */\n event_id?: string\n /** Optional callback fired after the beacon completes. */\n commandCallback?: () => void\n [key: string]: any\n}" + }, + { + "name": "LintrkUserData", + "kind": "interface", + "code": "interface LintrkUserData {\n /** Plain email; the script SHA-256 hashes it before sending. */\n email: string\n}" + }, + { + "name": "LintrkFns", + "kind": "type", + "code": "type LintrkFns\n = & ((cmd: 'track', params?: LintrkTrackParams) => void)\n & ((cmd: 'setUserData', data: LintrkUserData) => void)\n & ((cmd: (string & {}), ...args: any[]) => void)" + }, + { + "name": "LinkedInInsightApi", + "kind": "interface", + "code": "export interface LinkedInInsightApi {\n lintrk: LintrkFns & { q?: unknown[] }\n}" + } + ], "matomo-analytics": [ { "name": "MatomoAnalyticsOptions", @@ -2012,6 +2039,27 @@ "description": "Default consent state fired as `uetq.push('consent', 'default', ...)` before UET init." } ], + "LinkedInInsightOptions": [ + { + "name": "id", + "type": "string | string[]", + "required": true, + "description": "Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push onto window._linkedin_data_partner_ids. The first ID is used as the primary _linkedin_partner_id global." + }, + { + "name": "eventId", + "type": "string", + "required": false, + "description": "Optional page-load event ID for Conversions API deduplication. Assigned to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must match the eventId sent with the corresponding server-side Conversions API event. Per-event conversion deduplication uses the per-call event_id passed to lintrk('track', { conversion_id, event_id }) instead." + }, + { + "name": "enableAutoSpaTracking", + "type": "boolean", + "required": false, + "description": "Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page views). When true, suppresses the script's built-in auto-page-view via window._wait_for_lintrk and tracks every navigation including the initial page through Nuxt's page:finish hook. When false, the script fires its own page-view exactly once on load and SPA navigations are not tracked unless lintrk('track') is called manually.", + "defaultValue": "false" + } + ], "SegmentOptions": [ { "name": "writeKey", From da0929e36e44d62dbcab7b4e84d799fa92a4c566 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 15:48:13 -0400 Subject: [PATCH 07/23] feat(linkedin-insight): use LinkedIn-official logo with light/dark variants LinkedIn ships separate light/dark SVG assets. Light is a solid blue square with the "in" carved out (#0a66c2). Dark is a white rounded square with the blue "in" rendered on top (#0077b5), so the mark is readable on dark backgrounds. Strip page-instrumentation attributes (data-supported-dps, focusable, class, role, aria-*, componentkey, style, var(--svgDisplay*)) and split into the {light, dark} object shape used by xPixel and carbonAds. --- packages/script/src/registry-logos.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index b4d5613d..233c3a68 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -24,7 +24,10 @@ export const LOGOS = { }, tiktokPixel: ``, redditPixel: ` `, - linkedinInsight: ``, + linkedinInsight: { + light: ``, + dark: ``, + }, googleAdsense: ``, carbonAds: { light: ``, From 3d15aaa6c19391dd23581366e06e9fc23c44f254 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 15:57:11 -0400 Subject: [PATCH 08/23] feat(linkedin-insight): add script-meta entry --- packages/script/src/script-meta.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 32d8adf4..d212e024 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -99,6 +99,11 @@ export const scriptMeta = { trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'], testId: 'a2_ilz4u0kbdr3v', }, + linkedinInsight: { + urls: ['https://snap.licdn.com/li.lms-analytics/insight.min.js'], + trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'], + testId: '541681', + }, googleAdsense: { urls: ['https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'], trackedData: ['page-views', 'retargeting', 'audiences'], From b494593bf17017c352ab196e09a554595b03dbc2 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 16:03:44 -0400 Subject: [PATCH 09/23] test(linkedin-insight): assert registry type is not any --- packages/script/src/runtime/types.ts | 4 +++- test/types/types.test-d.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 4d01f6a8..75aad6b2 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -23,6 +23,7 @@ import type { HotjarInput } from './registry/hotjar' import type { InstagramEmbedInput } from './registry/instagram-embed' import type { IntercomInput } from './registry/intercom' import type { LemonSqueezyInput } from './registry/lemon-squeezy' +import type { LinkedInInsightInput } from './registry/linkedin-insight' import type { MatomoAnalyticsInput } from './registry/matomo-analytics' import type { MetaPixelInput } from './registry/meta-pixel' import type { MixpanelAnalyticsInput } from './registry/mixpanel-analytics' @@ -239,6 +240,7 @@ export interface ScriptRegistry { googleTagManager?: GoogleTagManagerInput hotjar?: HotjarInput intercom?: IntercomInput + linkedinInsight?: LinkedInInsightInput paypal?: PayPalInput posthog?: PostHogInput matomoAnalytics?: MatomoAnalyticsInput @@ -269,7 +271,7 @@ export type BuiltInRegistryScriptKey | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' - | 'hotjar' | 'intercom' | 'paypal' | 'posthog' | 'matomoAnalytics' + | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'youtubePlayer' | 'vercelAnalytics' | 'vimeoPlayer' | 'umamiAnalytics' | 'gravatar' | 'npm' diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 155f9bac..1c073604 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -31,6 +31,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() From dfff90b5b4cc06af72c693ddb4b9227e6dc0fab7 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 16:11:16 -0400 Subject: [PATCH 10/23] test(linkedin-insight): add proxy-config assertions --- test/unit/proxy-configs.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index dc450f57..a65ceeac 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -418,6 +418,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('xPixel') expect(configs).toHaveProperty('snapchatPixel') expect(configs).toHaveProperty('redditPixel') + expect(configs).toHaveProperty('linkedinInsight') expect(configs).toHaveProperty('posthog') expect(configs).toHaveProperty('plausibleAnalytics') expect(configs).toHaveProperty('cloudflareWebAnalytics') @@ -433,7 +434,7 @@ describe('proxy configs', () => { it('all configs have valid structure', async () => { const configs = await getProxyConfigs() - const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel'] + const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel', 'linkedinInsight'] const ipOnly = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have domains`).toHaveProperty('domains') From 480c630e167a17a2f4b9e6a5c983ce713b8ca650 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 16:13:46 -0400 Subject: [PATCH 11/23] test(linkedin-insight): add to first-party supported scripts list --- test/unit/first-party.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/first-party.test.ts b/test/unit/first-party.test.ts index 3bbf47b6..7097bbe3 100644 --- a/test/unit/first-party.test.ts +++ b/test/unit/first-party.test.ts @@ -263,6 +263,7 @@ describe('first-party mode', () => { 'xPixel', 'snapchatPixel', 'redditPixel', + 'linkedinInsight', 'clarity', 'hotjar', ] From 48519361cfad71deea375dfc58efde48ca90d5a8 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 16:15:31 -0400 Subject: [PATCH 12/23] test(linkedin-insight): add first-party fixture page --- test/fixtures/first-party/nuxt.config.ts | 2 + test/fixtures/first-party/pages/linkedin.vue | 49 ++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 test/fixtures/first-party/pages/linkedin.vue diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 666bee64..2fd830d7 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -26,6 +26,7 @@ export default defineNuxtConfig({ hotjar: { id: 3925006, sv: 6 }, tiktokPixel: { id: 'TEST_PIXEL_ID' }, redditPixel: { id: 'a2_ilz4u0kbdr3v' }, + linkedinInsight: { id: '541681' }, plausibleAnalytics: { domain: 'scripts.nuxt.com', extension: 'local' }, cloudflareWebAnalytics: { token: 'ade278253a19413c9bd923b079870902' }, rybbitAnalytics: { siteId: '874' }, @@ -71,6 +72,7 @@ export default defineNuxtConfig({ hotjar: [{ id: 3925006, sv: 6 }, manual], tiktokPixel: [{ id: 'TEST_PIXEL_ID' }, manual], redditPixel: [{ id: 'a2_ilz4u0kbdr3v' }, manual], + linkedinInsight: [{ id: '541681' }, manual], plausibleAnalytics: [{ domain: 'scripts.nuxt.com', extension: 'local' }, manual], cloudflareWebAnalytics: [{ token: 'ade278253a19413c9bd923b079870902' }, manual], rybbitAnalytics: [{ siteId: '874' }, manual], diff --git a/test/fixtures/first-party/pages/linkedin.vue b/test/fixtures/first-party/pages/linkedin.vue new file mode 100644 index 00000000..4ad0818d --- /dev/null +++ b/test/fixtures/first-party/pages/linkedin.vue @@ -0,0 +1,49 @@ + + + From e7690fe4d9b3a462f6508cbf12774fedef00e122 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 16:18:30 -0400 Subject: [PATCH 13/23] docs(linkedin-insight): add docs page with examples --- docs/content/scripts/linkedin-insight.md | 112 +++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/content/scripts/linkedin-insight.md diff --git a/docs/content/scripts/linkedin-insight.md b/docs/content/scripts/linkedin-insight.md new file mode 100644 index 00000000..609431e4 --- /dev/null +++ b/docs/content/scripts/linkedin-insight.md @@ -0,0 +1,112 @@ +--- +title: LinkedIn Insight Tag +description: Use the LinkedIn Insight Tag in your Nuxt app to track conversions, retarget visitors, and learn about your audience. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/linkedin-insight.ts + size: xs +--- + +The [LinkedIn Insight Tag](https://business.linkedin.com/marketing-solutions/insight-tag) is a lightweight JavaScript snippet for conversion tracking, retargeting, and audience insights on LinkedIn Ads campaigns. + +Nuxt Scripts provides a registry script composable [`useScriptLinkedInInsight()`{lang="ts"}](/scripts/linkedin-insight) to integrate it in your Nuxt app. + +::script-stats +:: + +::script-docs +:: + +::script-types +:: + +## Examples + +### Tracking a conversion + +```vue + +``` + +### Per-event deduplication with the Conversions API + +When you also send conversions through LinkedIn's server-side Conversions API, pass the same `event_id` to both — LinkedIn discards the server-side duplicate and counts the Insight Tag event. See [LinkedIn deduplication](https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication?view=li-lms-2026-01). + +```vue + +``` + +### Page-load conversion deduplication + +For dedup on the auto-fired page-view, set `eventId` at registration. The composable assigns `window._linkedin_event_id` *before* the Insight Tag base code runs, so the page-view URL includes `&eventId=…` automatically. + +```vue + +``` + +### Enhanced matching with `setUserData` + +Pass plain email — the Insight Tag SHA-256 hashes it before sending. See [LinkedIn enhanced matching](https://www.linkedin.com/help/lms/answer/a6246095). + +```vue + +``` + +### SPA virtual page views + +By default, the Insight Tag fires a page-view exactly once when the script loads, so SPA route changes go untracked. Opt in to per-route tracking with `enableAutoSpaTracking`: + +```vue + +``` + +When enabled, the composable suppresses the script's built-in auto-page-view (via `window._wait_for_lintrk = true`) and fires `lintrk('track')` on Nuxt's `page:finish` hook — once per route, including the initial SSR page. + +### Multiple Partner IDs + +If you need to push more than one Partner ID onto `window._linkedin_data_partner_ids`, pass an array. The first ID is used as the primary `_linkedin_partner_id` global. + +```vue + +``` From 5453a868b3a466a81ee2f50d93ddeb2da7e54e2e Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 16:27:21 -0400 Subject: [PATCH 14/23] docs(linkedin-insight): clear lint warnings in docs page Replace em-dash separators with semicolons or sentence breaks per the project's harlanzw/ai-deslop-no-em-dash rule. Add {lang="ts"} hint to inline lintrk('track') so it's syntax-highlighted. --- docs/content/scripts/linkedin-insight.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/content/scripts/linkedin-insight.md b/docs/content/scripts/linkedin-insight.md index 609431e4..87a74f6c 100644 --- a/docs/content/scripts/linkedin-insight.md +++ b/docs/content/scripts/linkedin-insight.md @@ -39,7 +39,7 @@ function trackPurchase() { ### Per-event deduplication with the Conversions API -When you also send conversions through LinkedIn's server-side Conversions API, pass the same `event_id` to both — LinkedIn discards the server-side duplicate and counts the Insight Tag event. See [LinkedIn deduplication](https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication?view=li-lms-2026-01). +When you also send conversions through LinkedIn's server-side Conversions API, pass the same `event_id` to both. LinkedIn discards the server-side duplicate and counts the Insight Tag event. See [LinkedIn deduplication](https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication?view=li-lms-2026-01). ```vue ``` -When enabled, the composable suppresses the script's built-in auto-page-view (via `window._wait_for_lintrk = true`) and fires `lintrk('track')` on Nuxt's `page:finish` hook — once per route, including the initial SSR page. +When enabled, the composable suppresses the script's built-in auto-page-view (via `window._wait_for_lintrk = true`) and fires `lintrk('track')`{lang="ts"} on Nuxt's `page:finish` hook once per route, including the initial SSR page. ### Multiple Partner IDs -If you need to push more than one Partner ID onto `window._linkedin_data_partner_ids`, pass an array. The first ID is used as the primary `_linkedin_partner_id` global. +If you need to push more than one Partner ID onto `window._linkedin_data_partner_ids`, pass an array. The composable promotes the first ID to the primary `_linkedin_partner_id` global. ```vue + + diff --git a/test/fixtures/linkedin-insight-cdn/pages/linkedin-spa.vue b/test/fixtures/linkedin-insight-cdn/pages/linkedin-spa.vue new file mode 100644 index 00000000..ae633e0c --- /dev/null +++ b/test/fixtures/linkedin-insight-cdn/pages/linkedin-spa.vue @@ -0,0 +1,19 @@ + + + diff --git a/test/fixtures/first-party/pages/linkedin.vue b/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue similarity index 50% rename from test/fixtures/first-party/pages/linkedin.vue rename to test/fixtures/linkedin-insight-cdn/pages/linkedin.vue index 4ad0818d..21f527f1 100644 --- a/test/fixtures/first-party/pages/linkedin.vue +++ b/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue @@ -1,49 +1,59 @@ diff --git a/test/fixtures/linkedin-insight-cdn/tsconfig.json b/test/fixtures/linkedin-insight-cdn/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/linkedin-insight-cdn/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/fixtures/linkedin-insight/app.vue b/test/fixtures/linkedin-insight/app.vue new file mode 100644 index 00000000..8f62b8bf --- /dev/null +++ b/test/fixtures/linkedin-insight/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/linkedin-insight/nuxt.config.ts b/test/fixtures/linkedin-insight/nuxt.config.ts new file mode 100644 index 00000000..b9e3f52e --- /dev/null +++ b/test/fixtures/linkedin-insight/nuxt.config.ts @@ -0,0 +1,15 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Bundled fixture (default `bundle: true` from def(), so the script is +// served from /_scripts/assets/ after AST rewrite). The CDN fixture extends +// this one and overrides only the bundle setting + the page composable calls. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + defaultScriptOptions: { trigger: 'onNuxtReady' }, + registry: { + linkedinInsight: { id: ['541681', '987654'], eventId: 'page-load-event-id-test', enableAutoSpaTracking: true }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/linkedin-insight/package.json b/test/fixtures/linkedin-insight/package.json new file mode 100644 index 00000000..b9826b34 --- /dev/null +++ b/test/fixtures/linkedin-insight/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/linkedin-insight/pages/index.vue b/test/fixtures/linkedin-insight/pages/index.vue new file mode 100644 index 00000000..a2287ce5 --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/fixtures/linkedin-insight/pages/linkedin-no-spa.vue b/test/fixtures/linkedin-insight/pages/linkedin-no-spa.vue new file mode 100644 index 00000000..65fdc26b --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/linkedin-no-spa.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/linkedin-insight/pages/linkedin-spa.vue b/test/fixtures/linkedin-insight/pages/linkedin-spa.vue new file mode 100644 index 00000000..95c92e99 --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/linkedin-spa.vue @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/linkedin-insight/pages/linkedin.vue b/test/fixtures/linkedin-insight/pages/linkedin.vue new file mode 100644 index 00000000..9f0ae854 --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/linkedin.vue @@ -0,0 +1,48 @@ + + + diff --git a/test/fixtures/linkedin-insight/tsconfig.json b/test/fixtures/linkedin-insight/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/linkedin-insight/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} From 281318a2e7677b047457b372cc4e1de57c092609 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 3 May 2026 22:35:04 +0100 Subject: [PATCH 19/23] docs(linkedin-insight): add to first-party guides and SPA tracking notes - FIRST_PARTY.md and docs/content/docs/1.guides/2.first-party.md: add LinkedIn to the PRIVACY_FULL group and Ad Pixels list. - docs/content/scripts/linkedin-insight.md: explain that setUserData transmits the hashed email via /wa/ on the next page load (not the same-page /collect), and that enableAutoSpaTracking suppresses the built-in auto-page-view so each route fires exactly one /collect. --- FIRST_PARTY.md | 3 ++- docs/content/docs/1.guides/2.first-party.md | 4 ++-- docs/content/scripts/linkedin-insight.md | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index a59c6e88..fd01a1a9 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -111,7 +111,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | Preset | Flags | Used by | |---|---|---| | `PRIVACY_NONE` | all false | (not currently assigned to any script) | -| `PRIVACY_FULL` | all true | Meta, TikTok, X, Snap, Reddit | +| `PRIVACY_FULL` | all true | Meta, TikTok, X, Snap, Reddit, LinkedIn | | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | | `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo | @@ -127,6 +127,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa | `xPixel` | xPixel | `PRIVACY_FULL` | Path A | | `snapchatPixel` | snapchatPixel | `PRIVACY_FULL` | Path A | | `redditPixel` | redditPixel | `PRIVACY_FULL` | Path A | +| `linkedinInsight` | linkedinInsight | `PRIVACY_FULL` | Path A | | `clarity` | clarity | `PRIVACY_HEATMAP` | Path A | | `hotjar` | hotjar | `PRIVACY_HEATMAP` | Path A | | `posthog` | posthog | `PRIVACY_IP_ONLY` | **Path B** (npm-only) + autoInject | diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 7130009f..e38044c5 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -61,7 +61,7 @@ Every proxied script defaults to a privacy tier based on what level of anonymisa | Tier | What's anonymised | Scripts | |------|-------------------|---------| -| **Full** | IP, user agent, language, screen, timezone, hardware fingerprints | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel | +| **Full** | IP, user agent, language, screen, timezone, hardware fingerprints | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel, LinkedIn Insight Tag | | **Heatmap-safe** | IP, language, hardware fingerprints (preserves screen and user agent for session replay) | Google Analytics, Microsoft Clarity, Hotjar | | **IP only** | IP addresses anonymised to subnet level | Plausible, PostHog, Umami, Fathom, CF Web Analytics, Vercel Analytics, Rybbit, Databuddy, Matomo, Intercom, YouTube, Vimeo, Gravatar, Carbon Ads, Lemon Squeezy, Google AdSense | @@ -324,7 +324,7 @@ These scripts are downloaded at build time, served from your domain, and have th | Category | Scripts | |----------|---------| | **Analytics** | [Google Analytics](/scripts/google-analytics), [Plausible](/scripts/plausible-analytics), [Cloudflare Web Analytics](/scripts/cloudflare-web-analytics), [Umami](/scripts/umami-analytics), [Fathom](/scripts/fathom-analytics), [Rybbit](/scripts/rybbit-analytics), [Databuddy](/scripts/databuddy-analytics), [Vercel Analytics](/scripts/vercel-analytics), [Microsoft Clarity](/scripts/clarity), [Hotjar](/scripts/hotjar) | -| **Ad Pixels** | [Meta Pixel](/scripts/meta-pixel), [TikTok Pixel](/scripts/tiktok-pixel), [X Pixel](/scripts/x-pixel), [Snapchat Pixel](/scripts/snapchat-pixel), [Reddit Pixel](/scripts/reddit-pixel), [Google AdSense](/scripts/google-adsense) | +| **Ad Pixels** | [Meta Pixel](/scripts/meta-pixel), [TikTok Pixel](/scripts/tiktok-pixel), [X Pixel](/scripts/x-pixel), [Snapchat Pixel](/scripts/snapchat-pixel), [Reddit Pixel](/scripts/reddit-pixel), [LinkedIn Insight Tag](/scripts/linkedin-insight), [Google AdSense](/scripts/google-adsense) | | **Video** | [YouTube Player](/scripts/youtube-player), [Vimeo Player](/scripts/vimeo-player) | | **Utility** | [Intercom](/scripts/intercom), [Gravatar](/scripts/gravatar) | diff --git a/docs/content/scripts/linkedin-insight.md b/docs/content/scripts/linkedin-insight.md index 87a74f6c..91dcce47 100644 --- a/docs/content/scripts/linkedin-insight.md +++ b/docs/content/scripts/linkedin-insight.md @@ -70,7 +70,7 @@ const { proxy } = useScriptLinkedInInsight({ ### Enhanced matching with `setUserData` -Pass plain email; the Insight Tag SHA-256 hashes it before sending. See [LinkedIn enhanced matching](https://www.linkedin.com/help/lms/answer/a6246095). +Pass plain email; the Insight Tag SHA-256 hashes it on-device. See [LinkedIn enhanced matching](https://www.linkedin.com/help/lms/answer/a6246095). ```vue ``` +The Insight Tag stores the hashed email in `localStorage["li_hem"]` and transmits it on the next page-view via a separate POST to `https://px.ads.linkedin.com/wa/...` (the WebsiteActions gateway). The `/collect` URL of the page-view immediately following `setUserData` does not carry it; LinkedIn picks it up on the next full page load when the bootstrap reads it back from `localStorage`. + ### SPA virtual page views By default, the Insight Tag fires a page-view exactly once when the script loads, so SPA route changes go untracked. Opt in to per-route tracking with `enableAutoSpaTracking`: @@ -97,7 +99,7 @@ useScriptLinkedInInsight({ ``` -When enabled, the composable suppresses the script's built-in auto-page-view (via `window._wait_for_lintrk = true`) and fires `lintrk('track')`{lang="ts"} on Nuxt's `page:finish` hook once per route, including the initial SSR page. +When enabled, the composable suppresses the script's built-in auto-page-view (via `window._wait_for_lintrk = true`) and fires `lintrk('track')`{lang="ts"} on Nuxt's `page:finish` hook instead, so each route (including the initial page) produces exactly one `/collect` beacon. ### Multiple Partner IDs From fc7e3fada91c3f82b111fac7c4f19c0a302e4181 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 3 May 2026 22:35:18 +0100 Subject: [PATCH 20/23] chore(linkedin-insight): add playground demo Mirrors the redditPixel layout: a default.vue (manual install via useHead script tag) and a nuxt-scripts.vue (using useScriptLinkedInInsight). Registered in playground/pages/index.vue. --- playground/pages/index.vue | 5 ++ .../linkedin-insight/default.vue | 26 ++++++++++ .../linkedin-insight/nuxt-scripts.vue | 52 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 playground/pages/third-parties/linkedin-insight/default.vue create mode 100644 playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue diff --git a/playground/pages/index.vue b/playground/pages/index.vue index dcfa1b1f..a0b83dc8 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -29,6 +29,7 @@ function getPlaygroundPath(script: any): string | null { 'meta-pixel': '/third-parties/meta-pixel', 'x-pixel': '/third-parties/x-pixel/nuxt-scripts', 'reddit-pixel': '/third-parties/reddit-pixel/nuxt-scripts', + 'linkedin-insight': '/third-parties/linkedin-insight/nuxt-scripts', 'snapchat-pixel': '/third-parties/snapchat/nuxt-scripts', 'tiktok-pixel': '/third-parties/tiktok-pixel/nuxt-scripts', 'google-adsense': '/third-parties/google-adsense/nuxt-scripts', @@ -267,6 +268,10 @@ const benchmark = [ name: 'Reddit Pixel (Default)', path: '/third-parties/reddit-pixel/default', }, + { + name: 'LinkedIn Insight (Default)', + path: '/third-parties/linkedin-insight/default', + }, { name: 'Snapchat (Default)', path: '/third-parties/snapchat/default', diff --git a/playground/pages/third-parties/linkedin-insight/default.vue b/playground/pages/third-parties/linkedin-insight/default.vue new file mode 100644 index 00000000..b4352587 --- /dev/null +++ b/playground/pages/third-parties/linkedin-insight/default.vue @@ -0,0 +1,26 @@ + + + diff --git a/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue b/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue new file mode 100644 index 00000000..be229973 --- /dev/null +++ b/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue @@ -0,0 +1,52 @@ + + + From 067cd2562c2e22cdf9d8fbe2480eb6d0cb0f1e99 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 3 May 2026 22:55:09 +0100 Subject: [PATCH 21/23] test(linkedin-insight): probe egress and skip beacon assertions when offline Tests fall into two groups: "wiring" assertions (script tag in DOM, partner ID globals set by clientInit) work offline because they observe state that exists before the LinkedIn script executes. "Behavior" assertions need the script to actually run and fire tracking, which requires reaching snap.licdn.com (CDN mode) and px.ads.linkedin.com (cookie-test bootstrap). Probe both endpoints once in beforeAll; behavior tests call ctx.skip() when egress is unavailable. Wiring tests rewritten to wait on the actual signal they assert (script tag attached, _linkedin_partner_id defined) instead of the #status:has-text("loaded") sentinel that requires a successful fetch. --- test/e2e/_linkedin-insight-suite.ts | 63 +++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/test/e2e/_linkedin-insight-suite.ts b/test/e2e/_linkedin-insight-suite.ts index 6cf8b5b1..d96f0059 100644 --- a/test/e2e/_linkedin-insight-suite.ts +++ b/test/e2e/_linkedin-insight-suite.ts @@ -1,19 +1,35 @@ import { gunzipSync } from 'node:zlib' import { getBrowser, url } from '@nuxt/test-utils/e2e' -import { expect, it } from 'vitest' +import { beforeAll, expect, it } from 'vitest' // SHA-256 hex of 'test@example.com' (the email passed by setUserData below). const EXPECTED_HASHED_EMAIL = '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' +// Tests fall into two groups by network requirement: +// - "wiring" tests (script tag in DOM, partner globals set by clientInit) +// run offline — both happen before the LinkedIn script executes. +// - "behavior" tests need the LinkedIn script to actually run, which means +// loading from snap.licdn.com (CDN mode) and reaching px.ads.linkedin.com +// for the bootstrap cookie-test before the canonical /collect fires. +// We probe once; behavior tests skip when egress is unavailable. +const NETWORK_PROBE_TIMEOUT_MS = 5000 +async function probeLinkedInEgress(): Promise { + const probe = (u: string) => + fetch(u, { method: 'HEAD', signal: AbortSignal.timeout(NETWORK_PROBE_TIMEOUT_MS) }) + .then(() => true, () => false) + const results = await Promise.all([ + probe('https://snap.licdn.com/li.lms-analytics/insight.min.js'), + probe('https://px.ads.linkedin.com/collect'), + ]) + return results.every(Boolean) +} + interface CapturedRequest { method: string url: string postData: Buffer | null } -// Tests make real outbound HTTPS to snap.licdn.com and px.ads.linkedin.com. -// In sandboxed environments without that egress, the script never loads and -// these assertions all fail with "expected 0 to be greater than 0". async function newCapturePage() { const browser = await getBrowser() const page = await browser.newPage() @@ -59,11 +75,21 @@ interface SuiteOptions { } export function defineLinkedInInsightSuite(opts: SuiteOptions) { + let networkAvailable = false + beforeAll(async () => { + networkAvailable = await probeLinkedInEgress() + }) + it('script tag points at the expected origin', async () => { + // Asserts on the rendered ``` @@ -44,12 +44,12 @@ When you also send conversions through LinkedIn's server-side Conversions API, p ```vue @@ -62,7 +62,7 @@ For dedup on the auto-fired page-view, set `eventId` at registration. The compos ```vue @@ -75,7 +75,7 @@ Pass plain email; the Insight Tag SHA-256 hashes it on-device. See [LinkedIn enh ```vue @@ -108,7 +108,7 @@ If you need to push more than one Partner ID onto `window._linkedin_data_partner ```vue ``` diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 345c74c3..493e5a33 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -610,7 +610,7 @@ { "name": "LinkedInInsightOptions", "kind": "const", - "code": "export const LinkedInInsightOptions = object({\n /**\n * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push\n * onto window._linkedin_data_partner_ids. The first ID is used as the\n * primary _linkedin_partner_id global.\n * @example '541681'\n * @example ['541681', '987654']\n * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id\n */\n id: union([pipe(string(), minLength(1)), pipe(array(pipe(string(), minLength(1))), minLength(1))]),\n /**\n * Optional page-load event ID for Conversions API deduplication. Assigned\n * to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must\n * match the eventId sent with the corresponding server-side Conversions\n * API event.\n *\n * Per-event conversion deduplication uses the per-call event_id passed to\n * lintrk('track', { conversion_id, event_id }) instead.\n * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication\n */\n eventId: optional(string()),\n /**\n * Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page\n * views). When true, suppresses the script's built-in auto-page-view via\n * window._wait_for_lintrk and tracks every navigation including the\n * initial page through Nuxt's page:finish hook. When false, the script\n * fires its own page-view exactly once on load and SPA navigations are\n * not tracked unless lintrk('track') is called manually.\n * @default false\n */\n enableAutoSpaTracking: optional(boolean()),\n})" + "code": "export const LinkedInInsightOptions = object({\n /**\n * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push\n * onto window._linkedin_data_partner_ids. The first ID is used as the\n * primary _linkedin_partner_id global.\n * @example '111143'\n * @example ['111143', '111154']\n * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id\n */\n id: union([pipe(string(), minLength(1)), pipe(array(pipe(string(), minLength(1))), minLength(1))]),\n /**\n * Optional page-load event ID for Conversions API deduplication. Assigned\n * to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must\n * match the eventId sent with the corresponding server-side Conversions\n * API event.\n *\n * Per-event conversion deduplication uses the per-call event_id passed to\n * lintrk('track', { conversion_id, event_id }) instead.\n * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication\n */\n eventId: optional(string()),\n /**\n * Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page\n * views). When true, suppresses the script's built-in auto-page-view via\n * window._wait_for_lintrk and tracks every navigation including the\n * initial page through Nuxt's page:finish hook. When false, the script\n * fires its own page-view exactly once on load and SPA navigations are\n * not tracked unless lintrk('track') is called manually.\n * @default false\n */\n enableAutoSpaTracking: optional(boolean()),\n})" }, { "name": "LintrkTrackParams", diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index e6c4926a..1b1c250a 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -834,8 +834,8 @@ export const LinkedInInsightOptions = object({ * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push * onto window._linkedin_data_partner_ids. The first ID is used as the * primary _linkedin_partner_id global. - * @example '541681' - * @example ['541681', '987654'] + * @example '111143' + * @example ['111143', '111154'] * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id */ id: union([pipe(string(), minLength(1)), pipe(array(pipe(string(), minLength(1))), minLength(1))]), diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index d212e024..c2faa595 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -102,7 +102,7 @@ export const scriptMeta = { linkedinInsight: { urls: ['https://snap.licdn.com/li.lms-analytics/insight.min.js'], trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'], - testId: '541681', + testId: '111143', }, googleAdsense: { urls: ['https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'], diff --git a/playground/pages/third-parties/linkedin-insight/default.vue b/playground/pages/third-parties/linkedin-insight/default.vue index b4352587..e76a1ffa 100644 --- a/playground/pages/third-parties/linkedin-insight/default.vue +++ b/playground/pages/third-parties/linkedin-insight/default.vue @@ -4,7 +4,7 @@ import { useHead } from '#imports' useHead({ script: [ { - innerHTML: `_linkedin_partner_id = "541681"; window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || []; window._linkedin_data_partner_ids.push(_linkedin_partner_id);`, + innerHTML: `_linkedin_partner_id = "111143"; window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || []; window._linkedin_data_partner_ids.push(_linkedin_partner_id);`, }, { innerHTML: `(function(l) { if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])}; window.lintrk.q=[]} var s = document.getElementsByTagName("script")[0]; var b = document.createElement("script"); b.type = "text/javascript"; b.async = true; b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js"; s.parentNode.insertBefore(b, s);})(window.lintrk);`, @@ -13,7 +13,7 @@ useHead({ }) function triggerEvent() { - ;(window as any).lintrk('track', { conversion_id: 20529377 }) + ;(window as any).lintrk('track', { conversion_id: 1111111177 }) } diff --git a/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue b/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue index be229973..0f899ff8 100644 --- a/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue +++ b/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue @@ -10,7 +10,7 @@ useHead({ // hook fires automatically on page:finish — buttons trigger the // remaining commands manually. const { status, proxy } = useScriptLinkedInInsight({ - id: '541681', + id: '111143', enableAutoSpaTracking: true, }) @@ -19,7 +19,7 @@ function trackPageView() { } function trackConversion() { - proxy.lintrk('track', { conversion_id: 20529377, event_id: crypto.randomUUID() }) + proxy.lintrk('track', { conversion_id: 1111111177, event_id: crypto.randomUUID() }) } function setUserData() { diff --git a/test/e2e/_linkedin-insight-suite.ts b/test/e2e/_linkedin-insight-suite.ts index d96f0059..11a07e86 100644 --- a/test/e2e/_linkedin-insight-suite.ts +++ b/test/e2e/_linkedin-insight-suite.ts @@ -119,8 +119,8 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { eventId: (window as any)._linkedin_event_id, lintrkType: typeof (window as any).lintrk, })) - expect(globals.partnerId).toBe('541681') - expect(globals.partnerIds).toEqual(['541681', '987654']) + expect(globals.partnerId).toBe('111143') + expect(globals.partnerIds).toEqual(['111143', '111154']) expect(globals.eventId).toBe('page-load-event-id-test') expect(globals.lintrkType).toBe('function') } @@ -212,7 +212,7 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { await page.waitForTimeout(2000) const newCollectReqs = requests.slice(before).filter(r => r.url.includes('px.ads.linkedin.com/collect')) expect(newCollectReqs.length).toBeGreaterThan(0) - expect(newCollectReqs.some(r => r.url.includes('conversionId=20529377'))).toBe(true) + expect(newCollectReqs.some(r => r.url.includes('conversionId=1111111177'))).toBe(true) } finally { await page.close() diff --git a/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue b/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue index 21f527f1..d8a4002d 100644 --- a/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue +++ b/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue @@ -14,11 +14,11 @@ function trackPageView() { } function trackConversion() { - ;(window as any).lintrk('track', { conversion_id: 20529377 }) + ;(window as any).lintrk('track', { conversion_id: 1111111177 }) } function trackConversionWithEventId() { - ;(window as any).lintrk('track', { conversion_id: 20529377, event_id: 'per-event-id-test' }) + ;(window as any).lintrk('track', { conversion_id: 1111111177, event_id: 'per-event-id-test' }) } function setUserData() { diff --git a/test/fixtures/linkedin-insight/nuxt.config.ts b/test/fixtures/linkedin-insight/nuxt.config.ts index b9e3f52e..da07fe10 100644 --- a/test/fixtures/linkedin-insight/nuxt.config.ts +++ b/test/fixtures/linkedin-insight/nuxt.config.ts @@ -8,7 +8,7 @@ export default defineNuxtConfig({ scripts: { defaultScriptOptions: { trigger: 'onNuxtReady' }, registry: { - linkedinInsight: { id: ['541681', '987654'], eventId: 'page-load-event-id-test', enableAutoSpaTracking: true }, + linkedinInsight: { id: ['111143', '111154'], eventId: 'page-load-event-id-test', enableAutoSpaTracking: true }, }, }, compatibilityDate: '2024-07-05', diff --git a/test/fixtures/linkedin-insight/pages/linkedin.vue b/test/fixtures/linkedin-insight/pages/linkedin.vue index 9f0ae854..6e55ef43 100644 --- a/test/fixtures/linkedin-insight/pages/linkedin.vue +++ b/test/fixtures/linkedin-insight/pages/linkedin.vue @@ -6,11 +6,11 @@ useHead({ title: 'LinkedIn Insight Tag' }) const { status } = useScriptLinkedInInsight() function trackConversion() { - ;(window as any).lintrk('track', { conversion_id: 20529377 }) + ;(window as any).lintrk('track', { conversion_id: 1111111177 }) } function trackConversionWithEventId() { - ;(window as any).lintrk('track', { conversion_id: 20529377, event_id: 'per-event-id-test' }) + ;(window as any).lintrk('track', { conversion_id: 1111111177, event_id: 'per-event-id-test' }) } function setUserData() { From 0b07b391bcf4978b050a1c806822a9125ff22da3 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sun, 3 May 2026 23:21:43 +0100 Subject: [PATCH 23/23] test(linkedin-insight): replace fixed sleeps with deterministic polling Hard-coded waitForTimeout(2000/3000) calls can intermittently miss delayed beacons and slow healthy runs. Replace with two helpers: - waitFor(predicate): poll until expected request appears (positive assertions). Used in the SPA, conversion, and setUserData /wa/ tests. - waitForStable(count): poll until the count stops changing for a settle window. Used by the double-fire regression guard (must let a second beacon happen if it's going to) and the negative SPA-tracking test (must let a late beacon happen if it's going to). Healthy runs now finish faster (~26s for 14 tests vs ~30s). --- test/e2e/_linkedin-insight-suite.ts | 85 ++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/test/e2e/_linkedin-insight-suite.ts b/test/e2e/_linkedin-insight-suite.ts index 11a07e86..8ad53596 100644 --- a/test/e2e/_linkedin-insight-suite.ts +++ b/test/e2e/_linkedin-insight-suite.ts @@ -46,6 +46,45 @@ async function newCapturePage() { return { page, requests } } +// Poll a predicate until it's true, instead of fixed sleeps. Resolves quickly +// on healthy runs and surfaces the actual condition that timed out. +async function waitFor( + predicate: () => boolean, + { timeoutMs = 10000, intervalMs = 50, message = 'condition' }: { timeoutMs?: number, intervalMs?: number, message?: string } = {}, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) + return + await new Promise(r => setTimeout(r, intervalMs)) + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${message}`) +} + +// Wait until `count()` stops changing for `stableMs`. Used by the regression +// guard (must give a potential double-fire time to happen) and the negative +// SPA-tracking assertion (must give the page time to NOT fire). +async function waitForStable( + count: () => number, + { stableMs = 1500, timeoutMs = 10000, intervalMs = 100 }: { stableMs?: number, timeoutMs?: number, intervalMs?: number } = {}, +): Promise { + const deadline = Date.now() + timeoutMs + let last = count() + let stableSince = Date.now() + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, intervalMs)) + const c = count() + if (c !== last) { + last = c + stableSince = Date.now() + } + else if (Date.now() - stableSince >= stableMs) { + return c + } + } + return count() +} + // LinkedIn's payload formatter (`Pt` in insight.min.js) picks the first // supported of: `g` = gzip(JSON) → base64; `b` = base64(JSON); `a` = JSON. // All three are sent as text/plain; `g` and `b` are base64 strings on the @@ -136,12 +175,12 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { try { await page.goto(url('/linkedin'), { waitUntil: 'networkidle', timeout: 30000 }) await page.waitForSelector('#status:has-text("loaded")', { timeout: 15000 }) - const before = requests.filter(r => r.url.includes('px.ads.linkedin.com/collect')).length + const collectCount = () => requests.filter(r => r.url.includes('px.ads.linkedin.com/collect')).length + const before = collectCount() await page.click('#trigger-spa-nav') await page.waitForURL('**/linkedin-spa', { timeout: 5000 }) - await page.waitForTimeout(2000) - const after = requests.filter(r => r.url.includes('px.ads.linkedin.com/collect')).length - expect(after).toBeGreaterThan(before) + await waitFor(() => collectCount() > before, { message: 'SPA /collect beacon' }) + expect(collectCount()).toBeGreaterThan(before) } finally { await page.close() @@ -163,13 +202,17 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { try { await page.goto(url('/linkedin'), { waitUntil: 'networkidle', timeout: 30000 }) await page.waitForSelector('#status:has-text("loaded")', { timeout: 15000 }) - await page.waitForTimeout(2000) - const canonicalCollects = requests.filter(r => + const canonicalCollects = () => requests.filter(r => r.url.includes('px.ads.linkedin.com/collect') && !r.url.includes('cookiesTest=true') && !r.url.includes('liSync=true'), ) - expect(canonicalCollects.length, `expected exactly 1 canonical /collect on initial load, got ${canonicalCollects.length}: ${canonicalCollects.map(r => r.url.slice(0, 120)).join(' | ')}`).toBe(1) + // Wait for at least one canonical /collect, then for the count to stop + // changing — a potential double-fire would arrive within this window. + await waitFor(() => canonicalCollects().length >= 1, { message: 'first canonical /collect' }) + await waitForStable(() => canonicalCollects().length) + const found = canonicalCollects() + expect(found.length, `expected exactly 1 canonical /collect on initial load, got ${found.length}: ${found.map(r => r.url.slice(0, 120)).join(' | ')}`).toBe(1) } finally { await page.close() @@ -187,13 +230,16 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { try { await page.goto(url('/linkedin-no-spa'), { waitUntil: 'networkidle', timeout: 30000 }) await page.waitForSelector('#status:has-text("loaded")', { timeout: 15000 }) - const before = requests.filter(r => r.url.includes('px.ads.linkedin.com/collect')).length + const collectCount = () => requests.filter(r => r.url.includes('px.ads.linkedin.com/collect')).length + const before = collectCount() await page.click('#trigger-spa-nav') await page.waitForURL('**/', { timeout: 5000 }) - await page.waitForTimeout(2000) - const after = requests.filter(r => r.url.includes('px.ads.linkedin.com/collect')).length + // Negative assertion: wait for the count to stop changing, then verify + // it didn't change. Stability window catches a beacon that might fire + // late, which is the failure mode we're guarding against. + await waitForStable(collectCount) expect(before).toBeGreaterThan(0) - expect(after).toBe(before) + expect(collectCount()).toBe(before) } finally { await page.close() @@ -208,11 +254,12 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { await page.goto(url('/linkedin'), { waitUntil: 'networkidle', timeout: 30000 }) await page.waitForSelector('#status:has-text("loaded")', { timeout: 15000 }) const before = requests.length + const newCollectReqs = () => requests.slice(before).filter(r => r.url.includes('px.ads.linkedin.com/collect')) await page.click('#trigger-conversion') - await page.waitForTimeout(2000) - const newCollectReqs = requests.slice(before).filter(r => r.url.includes('px.ads.linkedin.com/collect')) - expect(newCollectReqs.length).toBeGreaterThan(0) - expect(newCollectReqs.some(r => r.url.includes('conversionId=1111111177'))).toBe(true) + await waitFor(() => newCollectReqs().some(r => r.url.includes('conversionId=1111111177')), { message: '/collect with conversionId' }) + const found = newCollectReqs() + expect(found.length).toBeGreaterThan(0) + expect(found.some(r => r.url.includes('conversionId=1111111177'))).toBe(true) } finally { await page.close() @@ -241,13 +288,13 @@ export function defineLinkedInInsightSuite(opts: SuiteOptions) { const reloadStart = requests.length await page.reload({ waitUntil: 'networkidle', timeout: 30000 }) await page.waitForSelector('#status:has-text("loaded")', { timeout: 15000 }) - await page.waitForTimeout(3000) - const newRequests = requests.slice(reloadStart) + const newWaPosts = () => requests.slice(reloadStart).filter(r => r.method === 'POST' && /\/wa\/?(?:\?|$)/.test(new URL(r.url).pathname)) + await waitFor(() => newWaPosts().length > 0, { timeoutMs: 15000, message: '/wa/ POST after reload' }) expect(await page.evaluate(() => window.localStorage.getItem('li_hem'))).toBe(EXPECTED_HASHED_EMAIL) - const waPosts = newRequests.filter(r => r.method === 'POST' && /\/wa\/?(?:\?|$)/.test(new URL(r.url).pathname)) - expect(waPosts.length, `expected a /wa/ POST after reload (got: ${JSON.stringify(newRequests.map(r => r.url))})`).toBeGreaterThan(0) + const waPosts = newWaPosts() + expect(waPosts.length, `expected a /wa/ POST after reload (got: ${JSON.stringify(requests.slice(reloadStart).map(r => r.url))})`).toBeGreaterThan(0) const decoded = waPosts.map(decodeWaBody) const hits = decoded.filter(body => body.includes(EXPECTED_HASHED_EMAIL))