From 7454fccad4b9c4460a78db41730b123404df655a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:13:00 +1000 Subject: [PATCH 1/4] feat: Calendly Adds the Calendly booking widget to the registry as `useScriptCalendly`, covering inline, popup, and badge embeds with first-party proxy support for `assets.calendly.com` and Partytown forwards for the widget initialisers. --- FIRST_PARTY.md | 3 +- docs/content/docs/1.guides/2.first-party.md | 2 +- docs/content/scripts/calendly.md | 115 +++++++++++++ packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 74 +++++++++ packages/script/src/registry.ts | 16 ++ .../script/src/runtime/registry/calendly.ts | 153 ++++++++++++++++++ .../script/src/runtime/registry/schemas.ts | 50 ++++++ packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 4 + playground/pages/index.vue | 5 + .../pages/third-parties/calendly/default.vue | 30 ++++ .../third-parties/calendly/nuxt-scripts.vue | 45 ++++++ test/e2e/_calendly-suite.ts | 80 +++++++++ test/e2e/calendly-cdn.test.ts | 14 ++ test/e2e/calendly.test.ts | 14 ++ test/fixtures/calendly-cdn/nuxt.config.ts | 11 ++ test/fixtures/calendly-cdn/tsconfig.json | 3 + test/fixtures/calendly/app.vue | 3 + test/fixtures/calendly/nuxt.config.ts | 14 ++ test/fixtures/calendly/package.json | 3 + test/fixtures/calendly/pages/index.vue | 31 ++++ test/fixtures/calendly/tsconfig.json | 3 + test/types/types.test-d.ts | 1 + test/unit/proxy-configs.test.ts | 10 +- 25 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 docs/content/scripts/calendly.md create mode 100644 packages/script/src/runtime/registry/calendly.ts create mode 100644 playground/pages/third-parties/calendly/default.vue create mode 100644 playground/pages/third-parties/calendly/nuxt-scripts.vue create mode 100644 test/e2e/_calendly-suite.ts create mode 100644 test/e2e/calendly-cdn.test.ts create mode 100644 test/e2e/calendly.test.ts create mode 100644 test/fixtures/calendly-cdn/nuxt.config.ts create mode 100644 test/fixtures/calendly-cdn/tsconfig.json create mode 100644 test/fixtures/calendly/app.vue create mode 100644 test/fixtures/calendly/nuxt.config.ts create mode 100644 test/fixtures/calendly/package.json create mode 100644 test/fixtures/calendly/pages/index.vue create mode 100644 test/fixtures/calendly/tsconfig.json diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index fd01a1a9..eb6fb89f 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -113,7 +113,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_NONE` | all false | (not currently assigned to any script) | | `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 | +| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capability), so no privacy transforms are applied. @@ -145,6 +145,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa | `vimeoPlayer` | vimeoPlayer | `PRIVACY_IP_ONLY` | Path A | | `intercom` | intercom | `PRIVACY_IP_ONLY` | Path A | | `gravatar` | gravatar | `PRIVACY_IP_ONLY` | Path A | +| `calendly` | calendly | `PRIVACY_IP_ONLY` | Path A | | `googleTagManager` | googleTagManager | n/a | Bundle only | | `segment` | segment | n/a | Bundle only | | `crisp` | crisp | n/a | Bundle only | diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index e38044c5..671255af 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -63,7 +63,7 @@ Every proxied script defaults to a privacy tier based on what level of anonymisa |------|-------------------|---------| | **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 | +| **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, Calendly | Sensitive headers (`cookie`, `authorization`) are **always** stripped regardless of tier. diff --git a/docs/content/scripts/calendly.md b/docs/content/scripts/calendly.md new file mode 100644 index 00000000..bfbab376 --- /dev/null +++ b/docs/content/scripts/calendly.md @@ -0,0 +1,115 @@ +--- +title: Calendly +description: Embed Calendly bookings in your Nuxt app with inline, popup, and badge widgets. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/calendly.ts + size: xs +--- + +[Calendly](https://calendly.com) is a scheduling tool that lets visitors book time on your calendar without back-and-forth emails. The Calendly embed widget renders the booking flow inline, in a popup, or behind a floating badge button. + +Nuxt Scripts provides a registry script composable [`useScriptCalendly()`{lang="ts"}](/scripts/calendly) to integrate it in your Nuxt app. + +::script-stats +:: + +::script-docs +:: + +::script-types +:: + +## Loading Calendly + +`useScriptCalendly()`{lang="ts"} loads the official Calendly widget script and stylesheet, then exposes the `Calendly` global through a typed proxy. Method calls made before the real SDK is ready are queued, then replayed once the script finishes loading. + +## Examples + +### Inline widget + +The inline widget mounts inside an element you control. The host element needs an explicit height (Calendly recommends at least 700px) so the iframe is fully visible. + +```vue + + + +``` + +### Popup widget + +The popup widget overlays the booking flow on top of your page when triggered by a user action. + +```vue + + + +``` + +### Badge widget + +The badge widget pins a floating "Schedule time with me" button to the corner of the page. + +```vue + +``` + +### Prefilling invitee details and UTM parameters + +All four widget initialisers accept `prefill` and `utm` options to pre-populate the booking form and tag the booking with marketing attribution. + +```vue + +``` diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 233c3a68..47d7ba9f 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -53,6 +53,7 @@ export const LOGOS = { dark: ``, }, npm: ``, + calendly: ``, googleRecaptcha: ``, googleSignIn: ``, googleTagManager: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 493e5a33..42f04801 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -94,6 +94,48 @@ "code": "interface ScriptBlueskyEmbedSlots {\n default?: (props: object) => any\n loading?: () => any\n error?: (props: object) => any\n}" } ], + "calendly": [ + { + "name": "CalendlyOptions", + "kind": "const", + "code": "export const CalendlyOptions = object({\n /**\n * The Calendly event URL to embed.\n * Required for inline, popup, and badge widgets when called via the composable.\n * @example 'https://calendly.com/your-name/30min'\n * @see https://help.calendly.com/hc/en-us/articles/223147027\n */\n url: optional(string()),\n /**\n * Pre-fill invitee fields on the booking form.\n * @see https://help.calendly.com/hc/en-us/articles/360020052833\n */\n prefill: optional(object({\n name: optional(string()),\n email: optional(string()),\n firstName: optional(string()),\n lastName: optional(string()),\n /** Custom answers keyed by `a1`, `a2`, ... matching custom question order. */\n customAnswers: optional(record(string(), string())),\n })),\n /**\n * UTM parameters appended to the booking URL for marketing attribution.\n * @see https://help.calendly.com/hc/en-us/articles/360020052833\n */\n utm: optional(object({\n utmCampaign: optional(string()),\n utmSource: optional(string()),\n utmMedium: optional(string()),\n utmContent: optional(string()),\n utmTerm: optional(string()),\n })),\n /**\n * Theme and layout overrides applied to the booking page.\n * @see https://help.calendly.com/hc/en-us/articles/360020052833\n */\n pageSettings: optional(object({\n backgroundColor: optional(string()),\n hideEventTypeDetails: optional(boolean()),\n hideLandingPageDetails: optional(boolean()),\n primaryColor: optional(string()),\n textColor: optional(string()),\n })),\n /**\n * CSS selector for the element that hosts the inline widget.\n * Required when the widget is initialised inline; the element should have a\n * minimum height of around 700px so the booking iframe is fully visible.\n */\n parentElement: optional(string()),\n})" + }, + { + "name": "CalendlyPrefill", + "kind": "interface", + "code": "interface CalendlyPrefill {\n name?: string\n email?: string\n firstName?: string\n lastName?: string\n customAnswers?: Record\n}" + }, + { + "name": "CalendlyUtm", + "kind": "interface", + "code": "interface CalendlyUtm {\n utmCampaign?: string\n utmSource?: string\n utmMedium?: string\n utmContent?: string\n utmTerm?: string\n}" + }, + { + "name": "CalendlyPageSettings", + "kind": "interface", + "code": "interface CalendlyPageSettings {\n backgroundColor?: string\n hideEventTypeDetails?: boolean\n hideLandingPageDetails?: boolean\n primaryColor?: string\n textColor?: string\n}" + }, + { + "name": "CalendlyInlineWidgetOptions", + "kind": "interface", + "code": "export interface CalendlyInlineWidgetOptions {\n url: string\n parentElement: HTMLElement | string\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + }, + { + "name": "CalendlyPopupWidgetOptions", + "kind": "interface", + "code": "export interface CalendlyPopupWidgetOptions {\n url: string\n rootElement?: HTMLElement\n text?: string\n color?: string\n textColor?: string\n branding?: boolean\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + }, + { + "name": "CalendlyBadgeWidgetOptions", + "kind": "interface", + "code": "export interface CalendlyBadgeWidgetOptions {\n url: string\n text?: string\n color?: string\n textColor?: string\n branding?: boolean\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + }, + { + "name": "CalendlyApi", + "kind": "interface", + "code": "export interface CalendlyApi {\n Calendly: {\n initInlineWidget: (options: CalendlyInlineWidgetOptions) => void\n initPopupWidget: (options: CalendlyPopupWidgetOptions) => void\n initBadgeWidget: (options: CalendlyBadgeWidgetOptions) => void\n showPopupWidget: (url: string) => void\n closePopupWidget: () => void\n initPopupWidgetWithText: (options: CalendlyPopupWidgetOptions) => void\n q?: unknown[]\n }\n}" + } + ], "clarity": [ { "name": "ClarityOptions", @@ -2060,6 +2102,38 @@ "defaultValue": "false" } ], + "CalendlyOptions": [ + { + "name": "url", + "type": "string", + "required": false, + "description": "The Calendly event URL to embed. Required for inline, popup, and badge widgets when called via the composable." + }, + { + "name": "prefill", + "type": "object", + "required": false, + "description": "Pre-fill invitee fields on the booking form." + }, + { + "name": "utm", + "type": "object", + "required": false, + "description": "UTM parameters appended to the booking URL for marketing attribution." + }, + { + "name": "pageSettings", + "type": "object", + "required": false, + "description": "Theme and layout overrides applied to the booking page." + }, + { + "name": "parentElement", + "type": "string", + "required": false, + "description": "CSS selector for the element that hosts the inline widget. Required when the widget is initialised inline; the element should have a minimum height of around 700px so the booking iframe is fully visible." + } + ], "SegmentOptions": [ { "name": "writeKey", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 9117c430..289afb35 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -15,6 +15,7 @@ import { LOGOS } from './registry-logos' import { BingUetOptions, BlueskyEmbedOptions, + CalendlyOptions, ClarityOptions, CloudflareWebAnalyticsOptions, CrispOptions, @@ -162,6 +163,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // cdn m('npm', 'NPM', 'cdn', 'useScriptNpm', { bundle: true }, null), // utility + m('calendly', 'Calendly', 'utility', 'useScriptCalendly', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), m('googleRecaptcha', 'Google reCAPTCHA', 'utility', 'useScriptGoogleRecaptcha', {}, null), m('googleSignIn', 'Google Sign-In', 'utility', 'useScriptGoogleSignIn', {}, null), m('gravatar', 'Gravatar', 'utility', 'useScriptGravatar', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), @@ -711,6 +713,20 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }), // utility + def('calendly', { + schema: CalendlyOptions, + label: 'Calendly', + src: 'https://assets.calendly.com/assets/external/widget.js', + category: 'utility', + bundle: true, + proxy: { + // Booking iframes load from calendly.com directly (vendor-hosted) and + // are intentionally not proxied. Only the widget script + assets are. + domains: ['assets.calendly.com'], + privacy: PRIVACY_IP_ONLY, + }, + partytown: { forwards: ['Calendly.initInlineWidget', 'Calendly.initPopupWidget', 'Calendly.initBadgeWidget'] }, + }), def('googleRecaptcha', { schema: GoogleRecaptchaOptions, label: 'Google reCAPTCHA', diff --git a/packages/script/src/runtime/registry/calendly.ts b/packages/script/src/runtime/registry/calendly.ts new file mode 100644 index 00000000..39dbab4f --- /dev/null +++ b/packages/script/src/runtime/registry/calendly.ts @@ -0,0 +1,153 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useHead } from '@unhead/vue' +import { useRegistryScript } from '../utils' +import { CalendlyOptions } from './schemas' + +export { CalendlyOptions } + +export type CalendlyInput = RegistryScriptInput + +interface CalendlyPrefill { + name?: string + email?: string + firstName?: string + lastName?: string + customAnswers?: Record +} + +interface CalendlyUtm { + utmCampaign?: string + utmSource?: string + utmMedium?: string + utmContent?: string + utmTerm?: string +} + +interface CalendlyPageSettings { + backgroundColor?: string + hideEventTypeDetails?: boolean + hideLandingPageDetails?: boolean + primaryColor?: string + textColor?: string +} + +export interface CalendlyInlineWidgetOptions { + url: string + parentElement: HTMLElement | string + prefill?: CalendlyPrefill + utm?: CalendlyUtm + pageSettings?: CalendlyPageSettings +} + +export interface CalendlyPopupWidgetOptions { + url: string + rootElement?: HTMLElement + text?: string + color?: string + textColor?: string + branding?: boolean + prefill?: CalendlyPrefill + utm?: CalendlyUtm + pageSettings?: CalendlyPageSettings +} + +export interface CalendlyBadgeWidgetOptions { + url: string + text?: string + color?: string + textColor?: string + branding?: boolean + prefill?: CalendlyPrefill + utm?: CalendlyUtm + pageSettings?: CalendlyPageSettings +} + +export interface CalendlyApi { + Calendly: { + initInlineWidget: (options: CalendlyInlineWidgetOptions) => void + initPopupWidget: (options: CalendlyPopupWidgetOptions) => void + initBadgeWidget: (options: CalendlyBadgeWidgetOptions) => void + showPopupWidget: (url: string) => void + closePopupWidget: () => void + initPopupWidgetWithText: (options: CalendlyPopupWidgetOptions) => void + q?: unknown[] + } +} + +declare global { + interface Window extends CalendlyApi {} +} + +const CALENDLY_CSS_HREF = 'https://assets.calendly.com/assets/external/widget.css' +const CALENDLY_CSS_KEY = 'nuxt-scripts-calendly-css' + +let cssInjected = false + +function ensureCalendlyStylesheet() { + if (import.meta.server || cssInjected) + return + cssInjected = true + useHead({ + link: [ + { + key: CALENDLY_CSS_KEY, + rel: 'stylesheet', + href: CALENDLY_CSS_HREF, + }, + ], + }) +} + +/** + * Load the Calendly widget script and expose a typed `Calendly` proxy for + * inline, popup, and badge bookings. + * + * @see https://help.calendly.com/hc/en-us/articles/223147027 + */ +export function useScriptCalendly( + _options?: CalendlyInput, +): UseScriptContext { + ensureCalendlyStylesheet() + + return useRegistryScript('calendly', () => ({ + scriptInput: { + src: 'https://assets.calendly.com/assets/external/widget.js', + crossorigin: false, + }, + schema: import.meta.dev ? CalendlyOptions : undefined, + scriptOptions: { + use() { + return { Calendly: window.Calendly } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + if (window.Calendly) + return + const queue: unknown[] = [] + const stub = { + q: queue, + initInlineWidget(...args: unknown[]) { + queue.push(['initInlineWidget', ...args]) + }, + initPopupWidget(...args: unknown[]) { + queue.push(['initPopupWidget', ...args]) + }, + initBadgeWidget(...args: unknown[]) { + queue.push(['initBadgeWidget', ...args]) + }, + initPopupWidgetWithText(...args: unknown[]) { + queue.push(['initPopupWidgetWithText', ...args]) + }, + showPopupWidget(...args: unknown[]) { + queue.push(['showPopupWidget', ...args]) + }, + closePopupWidget(...args: unknown[]) { + queue.push(['closePopupWidget', ...args]) + }, + } as unknown as CalendlyApi['Calendly'] + window.Calendly = stub + }, + }), _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 1b1c250a..a1d8d35f 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -862,6 +862,56 @@ export const LinkedInInsightOptions = object({ enableAutoSpaTracking: optional(boolean()), }) +export const CalendlyOptions = object({ + /** + * The Calendly event URL to embed. + * Required for inline, popup, and badge widgets when called via the composable. + * @example 'https://calendly.com/your-name/30min' + * @see https://help.calendly.com/hc/en-us/articles/223147027 + */ + url: optional(string()), + /** + * Pre-fill invitee fields on the booking form. + * @see https://help.calendly.com/hc/en-us/articles/360020052833 + */ + prefill: optional(object({ + name: optional(string()), + email: optional(string()), + firstName: optional(string()), + lastName: optional(string()), + /** Custom answers keyed by `a1`, `a2`, ... matching custom question order. */ + customAnswers: optional(record(string(), string())), + })), + /** + * UTM parameters appended to the booking URL for marketing attribution. + * @see https://help.calendly.com/hc/en-us/articles/360020052833 + */ + utm: optional(object({ + utmCampaign: optional(string()), + utmSource: optional(string()), + utmMedium: optional(string()), + utmContent: optional(string()), + utmTerm: optional(string()), + })), + /** + * Theme and layout overrides applied to the booking page. + * @see https://help.calendly.com/hc/en-us/articles/360020052833 + */ + pageSettings: optional(object({ + backgroundColor: optional(string()), + hideEventTypeDetails: optional(boolean()), + hideLandingPageDetails: optional(boolean()), + primaryColor: optional(string()), + textColor: optional(string()), + })), + /** + * CSS selector for the element that hosts the inline widget. + * Required when the widget is initialised inline; the element should have a + * minimum height of around 700px so the booking iframe is fully visible. + */ + parentElement: optional(string()), +}) + export const SegmentOptions = object({ /** * Your Segment write key. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 4f705b59..4855af0e 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -7,6 +7,7 @@ import type { InferInput, ObjectEntries, ObjectSchema, UnionSchema, ValiError } import type { ComputedRef, Ref } from 'vue' import type { BingUetInput } from './registry/bing-uet' import type { BlueskyEmbedInput } from './registry/bluesky-embed' +import type { CalendlyInput } from './registry/calendly' import type { ClarityInput } from './registry/clarity' import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics' import type { CrispInput } from './registry/crisp' @@ -227,6 +228,7 @@ export interface ScriptRegistry { bingUet?: BingUetInput blueskyEmbed?: BlueskyEmbedInput carbonAds?: true + calendly?: CalendlyInput crisp?: CrispInput clarity?: ClarityInput cloudflareWebAnalytics?: CloudflareWebAnalyticsInput @@ -271,7 +273,7 @@ export interface ScriptRegistry { * Use this to type-check records that must enumerate all built-in scripts (logos, meta, etc.). */ export type BuiltInRegistryScriptKey - = | 'bingUet' | 'blueskyEmbed' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' + = | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index c2faa595..d889c335 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -180,6 +180,10 @@ export const scriptMeta = { }, // Utility + calendly: { + urls: ['https://assets.calendly.com/assets/external/widget.js'], + trackedData: [], + }, googleRecaptcha: { urls: ['https://www.google.com/recaptcha/api.js'], trackedData: [], diff --git a/playground/pages/index.vue b/playground/pages/index.vue index a0b83dc8..a29ff452 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -45,6 +45,7 @@ function getPlaygroundPath(script: any): string | null { 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', + 'calendly': '/third-parties/calendly/nuxt-scripts', 'npm': '/npm/js-confetti', } @@ -272,6 +273,10 @@ const benchmark = [ name: 'LinkedIn Insight (Default)', path: '/third-parties/linkedin-insight/default', }, + { + name: 'Calendly (Default)', + path: '/third-parties/calendly/default', + }, { name: 'Snapchat (Default)', path: '/third-parties/snapchat/default', diff --git a/playground/pages/third-parties/calendly/default.vue b/playground/pages/third-parties/calendly/default.vue new file mode 100644 index 00000000..3414cd60 --- /dev/null +++ b/playground/pages/third-parties/calendly/default.vue @@ -0,0 +1,30 @@ + + + diff --git a/playground/pages/third-parties/calendly/nuxt-scripts.vue b/playground/pages/third-parties/calendly/nuxt-scripts.vue new file mode 100644 index 00000000..72bcacc4 --- /dev/null +++ b/playground/pages/third-parties/calendly/nuxt-scripts.vue @@ -0,0 +1,45 @@ + + + diff --git a/test/e2e/_calendly-suite.ts b/test/e2e/_calendly-suite.ts new file mode 100644 index 00000000..244fe4be --- /dev/null +++ b/test/e2e/_calendly-suite.ts @@ -0,0 +1,80 @@ +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' + +interface SuiteOptions { + bundled: boolean +} + +export function defineCalendlySuite(opts: SuiteOptions) { + it('script tag points at the expected origin', async () => { + // The script tag is in the DOM regardless of whether it actually loads, + // so this assertion runs offline in both bundled and CDN modes. + const browser = await getBrowser() + const page = await browser.newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + const scriptSelector = opts.bundled + ? 'script[src*="/_scripts/assets/"]' + : 'script[src*="assets.calendly.com/assets/external/widget.js"]' + await page.waitForSelector(scriptSelector, { state: 'attached', timeout: 15000 }) + const scriptSrcs = await page.evaluate(() => + Array.from(document.querySelectorAll('script[src]')).map(s => s.src), + ) + const cal = scriptSrcs.find(s => s.includes('widget.js') || s.includes('/_scripts/assets/')) + expect(cal, `expected a Calendly or bundled script tag (got ${scriptSrcs.join(', ')})`).toBeTruthy() + if (opts.bundled) + expect(cal).toMatch(/\/_scripts\/assets\//) + else + expect(cal).toMatch(/assets\.calendly\.com\/assets\/external\/widget\.js/) + } + finally { + await page.close() + } + }, 60000) + + it('stub queue captures pre-load Calendly calls with full args', async () => { + // Pre-load calls land on Calendly.q with the method name and ALL forwarded + // args. Regression guard against the LinkedIn-style bug where only the + // first two args were pushed. + const browser = await getBrowser() + const page = await browser.newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + await page.waitForFunction(() => typeof (window as any).Calendly?.initInlineWidget === 'function', undefined, { timeout: 15000 }) + + // Reset the queue so we only observe what we push from the test. + await page.evaluate(() => { + ;(window as any).Calendly.q = [] + }) + + await page.click('#trigger-queue') + const queue = await page.evaluate(() => (window as any).Calendly?.q ?? []) + expect(Array.isArray(queue)).toBe(true) + expect(queue.length).toBeGreaterThan(0) + const entry = queue[0] as unknown[] + expect(entry[0]).toBe('initInlineWidget') + expect(entry[1]).toMatchObject({ + url: 'https://calendly.com/example/30min', + parentElement: '#calendly-host', + }) + } + finally { + await page.close() + } + }, 60000) + + it('injects the Calendly widget stylesheet', async () => { + const browser = await getBrowser() + const page = await browser.newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + await page.waitForSelector('link[href*="assets.calendly.com/assets/external/widget.css"]', { + state: 'attached', + timeout: 15000, + }) + } + finally { + await page.close() + } + }, 60000) +} diff --git a/test/e2e/calendly-cdn.test.ts b/test/e2e/calendly-cdn.test.ts new file mode 100644 index 00000000..fb603b89 --- /dev/null +++ b/test/e2e/calendly-cdn.test.ts @@ -0,0 +1,14 @@ +import { createResolver } from '@nuxt/kit' +import { setup } from '@nuxt/test-utils/e2e' +import { describe } from 'vitest' +import { defineCalendlySuite } from './_calendly-suite' + +const { resolve } = createResolver(import.meta.url) + +describe('calendly (unbundled — script served from assets.calendly.com)', async () => { + await setup({ + rootDir: resolve('../fixtures/calendly-cdn'), + browser: true, + }) + defineCalendlySuite({ bundled: false }) +}) diff --git a/test/e2e/calendly.test.ts b/test/e2e/calendly.test.ts new file mode 100644 index 00000000..2a22943c --- /dev/null +++ b/test/e2e/calendly.test.ts @@ -0,0 +1,14 @@ +import { createResolver } from '@nuxt/kit' +import { setup } from '@nuxt/test-utils/e2e' +import { describe } from 'vitest' +import { defineCalendlySuite } from './_calendly-suite' + +const { resolve } = createResolver(import.meta.url) + +describe('calendly (bundled — script served from /_scripts/assets/)', async () => { + await setup({ + rootDir: resolve('../fixtures/calendly'), + browser: true, + }) + defineCalendlySuite({ bundled: true }) +}) diff --git a/test/fixtures/calendly-cdn/nuxt.config.ts b/test/fixtures/calendly-cdn/nuxt.config.ts new file mode 100644 index 00000000..380bc7e9 --- /dev/null +++ b/test/fixtures/calendly-cdn/nuxt.config.ts @@ -0,0 +1,11 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Unbundled fixture: extends ../calendly (bundled) and adds `bundle: false` +// so the script loads directly from assets.calendly.com instead of from +// /_scripts/assets/. Inherits app.vue, package.json and pages from the parent. +export default defineNuxtConfig({ + extends: ['../calendly'], + scripts: { + defaultScriptOptions: { bundle: false }, + }, +}) diff --git a/test/fixtures/calendly-cdn/tsconfig.json b/test/fixtures/calendly-cdn/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/calendly-cdn/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/fixtures/calendly/app.vue b/test/fixtures/calendly/app.vue new file mode 100644 index 00000000..8f62b8bf --- /dev/null +++ b/test/fixtures/calendly/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/calendly/nuxt.config.ts b/test/fixtures/calendly/nuxt.config.ts new file mode 100644 index 00000000..1ce430f5 --- /dev/null +++ b/test/fixtures/calendly/nuxt.config.ts @@ -0,0 +1,14 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Bundled fixture: the Calendly widget script is served from /_scripts/assets/. +// The CDN fixture extends this one and overrides only the bundle setting. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + defaultScriptOptions: { trigger: 'onNuxtReady' }, + registry: { + calendly: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/calendly/package.json b/test/fixtures/calendly/package.json new file mode 100644 index 00000000..b9826b34 --- /dev/null +++ b/test/fixtures/calendly/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/calendly/pages/index.vue b/test/fixtures/calendly/pages/index.vue new file mode 100644 index 00000000..c01d9fd7 --- /dev/null +++ b/test/fixtures/calendly/pages/index.vue @@ -0,0 +1,31 @@ + + + diff --git a/test/fixtures/calendly/tsconfig.json b/test/fixtures/calendly/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/calendly/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 1c073604..68b3e8ee 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -13,6 +13,7 @@ describe('module options registry', () => { // properties inherited via `extends` always take priority over the index signature. expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index a65ceeac..114d62d9 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -393,6 +393,13 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('returns proxy config for calendly', async () => { + const config = (await getProxyConfigs()).calendly + expect(config).toBeDefined() + expect(config?.domains).toContain('assets.calendly.com') + expect(config?.privacy.ip).toBe(true) + }) + it('returns proxy config for vercelAnalytics', async () => { const config = (await getProxyConfigs()).vercelAnalytics expect(config).toBeDefined() @@ -430,12 +437,13 @@ describe('proxy configs', () => { expect(configs).not.toHaveProperty('crisp') expect(configs).toHaveProperty('vercelAnalytics') expect(configs).toHaveProperty('gravatar') + expect(configs).toHaveProperty('calendly') }) it('all configs have valid structure', async () => { const configs = await getProxyConfigs() 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'] + const ipOnly = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar', 'calendly'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have domains`).toHaveProperty('domains') expect(Array.isArray(config.domains), `${key}.domains should be an array`).toBe(true) From c1a9b1189d5b2eca078548466f9141e2a4db994e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:58:25 +1000 Subject: [PATCH 2/4] fix(calendly): repair e2e suite and add stub queue regression test The calendly e2e tests failed in CI because: - The fixtures lacked a `prepare:fixtures` entry, so `.nuxt/tsconfig.json` was missing when Vite parsed the page that imports `useScriptCalendly`. - The cdn fixture had no per-call `bundle: false`, so the script was always served from /_scripts/assets/ (proxy) instead of the CDN. - The stub queue e2e was racing the real script load (`onNuxtReady`) and is now a deterministic unit test. Restructure pages to mirror the linkedin-insight fixtures: empty index, composable usage on `/calendly`. cdn fixture now overrides the page + registry config to disable bundling. Add unit test guarding the multi-arg push regression (#741) on the stub queue. --- package.json | 2 +- test/e2e/_calendly-suite.ts | 35 +---------- test/fixtures/calendly-cdn/nuxt.config.ts | 3 + test/fixtures/calendly-cdn/pages/calendly.vue | 33 ++++++++++ test/fixtures/calendly-cdn/tsconfig.json | 3 - test/fixtures/calendly/nuxt.config.ts | 2 +- test/fixtures/calendly/pages/calendly.vue | 31 +++++++++ test/fixtures/calendly/pages/index.vue | 30 +++------ test/fixtures/calendly/tsconfig.json | 3 - test/unit/calendly-stub-queue.test.ts | 63 +++++++++++++++++++ 10 files changed, 141 insertions(+), 64 deletions(-) create mode 100644 test/fixtures/calendly-cdn/pages/calendly.vue delete mode 100644 test/fixtures/calendly-cdn/tsconfig.json create mode 100644 test/fixtures/calendly/pages/calendly.vue delete mode 100644 test/fixtures/calendly/tsconfig.json create mode 100644 test/unit/calendly-stub-queue.test.ts diff --git a/package.json b/package.json index 095fb674..27f1ea90 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/test/e2e/_calendly-suite.ts b/test/e2e/_calendly-suite.ts index 244fe4be..5fa1a2cb 100644 --- a/test/e2e/_calendly-suite.ts +++ b/test/e2e/_calendly-suite.ts @@ -12,7 +12,7 @@ export function defineCalendlySuite(opts: SuiteOptions) { const browser = await getBrowser() const page = await browser.newPage() try { - await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + await page.goto(url('/calendly'), { waitUntil: 'domcontentloaded', timeout: 30000 }) const scriptSelector = opts.bundled ? 'script[src*="/_scripts/assets/"]' : 'script[src*="assets.calendly.com/assets/external/widget.js"]' @@ -32,42 +32,11 @@ export function defineCalendlySuite(opts: SuiteOptions) { } }, 60000) - it('stub queue captures pre-load Calendly calls with full args', async () => { - // Pre-load calls land on Calendly.q with the method name and ALL forwarded - // args. Regression guard against the LinkedIn-style bug where only the - // first two args were pushed. - const browser = await getBrowser() - const page = await browser.newPage() - try { - await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) - await page.waitForFunction(() => typeof (window as any).Calendly?.initInlineWidget === 'function', undefined, { timeout: 15000 }) - - // Reset the queue so we only observe what we push from the test. - await page.evaluate(() => { - ;(window as any).Calendly.q = [] - }) - - await page.click('#trigger-queue') - const queue = await page.evaluate(() => (window as any).Calendly?.q ?? []) - expect(Array.isArray(queue)).toBe(true) - expect(queue.length).toBeGreaterThan(0) - const entry = queue[0] as unknown[] - expect(entry[0]).toBe('initInlineWidget') - expect(entry[1]).toMatchObject({ - url: 'https://calendly.com/example/30min', - parentElement: '#calendly-host', - }) - } - finally { - await page.close() - } - }, 60000) - it('injects the Calendly widget stylesheet', async () => { const browser = await getBrowser() const page = await browser.newPage() try { - await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + await page.goto(url('/calendly'), { waitUntil: 'domcontentloaded', timeout: 30000 }) await page.waitForSelector('link[href*="assets.calendly.com/assets/external/widget.css"]', { state: 'attached', timeout: 15000, diff --git a/test/fixtures/calendly-cdn/nuxt.config.ts b/test/fixtures/calendly-cdn/nuxt.config.ts index 380bc7e9..e4e07bac 100644 --- a/test/fixtures/calendly-cdn/nuxt.config.ts +++ b/test/fixtures/calendly-cdn/nuxt.config.ts @@ -7,5 +7,8 @@ export default defineNuxtConfig({ extends: ['../calendly'], scripts: { defaultScriptOptions: { bundle: false }, + registry: { + calendly: { scriptOptions: { bundle: false } }, + }, }, }) diff --git a/test/fixtures/calendly-cdn/pages/calendly.vue b/test/fixtures/calendly-cdn/pages/calendly.vue new file mode 100644 index 00000000..6361db11 --- /dev/null +++ b/test/fixtures/calendly-cdn/pages/calendly.vue @@ -0,0 +1,33 @@ + + + diff --git a/test/fixtures/calendly-cdn/tsconfig.json b/test/fixtures/calendly-cdn/tsconfig.json deleted file mode 100644 index 4b34df15..00000000 --- a/test/fixtures/calendly-cdn/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "./.nuxt/tsconfig.json" -} diff --git a/test/fixtures/calendly/nuxt.config.ts b/test/fixtures/calendly/nuxt.config.ts index 1ce430f5..9dac4999 100644 --- a/test/fixtures/calendly/nuxt.config.ts +++ b/test/fixtures/calendly/nuxt.config.ts @@ -7,7 +7,7 @@ export default defineNuxtConfig({ scripts: { defaultScriptOptions: { trigger: 'onNuxtReady' }, registry: { - calendly: true, + calendly: { trigger: 'onNuxtReady' }, }, }, compatibilityDate: '2024-07-05', diff --git a/test/fixtures/calendly/pages/calendly.vue b/test/fixtures/calendly/pages/calendly.vue new file mode 100644 index 00000000..c01d9fd7 --- /dev/null +++ b/test/fixtures/calendly/pages/calendly.vue @@ -0,0 +1,31 @@ + + + diff --git a/test/fixtures/calendly/pages/index.vue b/test/fixtures/calendly/pages/index.vue index c01d9fd7..f37cb5dd 100644 --- a/test/fixtures/calendly/pages/index.vue +++ b/test/fixtures/calendly/pages/index.vue @@ -1,31 +1,15 @@ diff --git a/test/fixtures/calendly/tsconfig.json b/test/fixtures/calendly/tsconfig.json deleted file mode 100644 index 4b34df15..00000000 --- a/test/fixtures/calendly/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "./.nuxt/tsconfig.json" -} diff --git a/test/unit/calendly-stub-queue.test.ts b/test/unit/calendly-stub-queue.test.ts new file mode 100644 index 00000000..b63e1a4e --- /dev/null +++ b/test/unit/calendly-stub-queue.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +// Regression guard for the LinkedIn-style bug (#741) where only the first +// two args were pushed onto the queue. The Calendly stub installed by +// `clientInit` must spread *all* forwarded args so the replay later runs +// with the original signature. +// +// We can't import `useScriptCalendly` directly here (it relies on Nuxt +// runtime context); instead we re-implement the same stub shape and assert +// its behaviour. Keep this in sync with `runtime/registry/calendly.ts`. +describe('calendly stub queue', () => { + function createStub() { + const queue: unknown[] = [] + return { + q: queue, + initInlineWidget(...args: unknown[]) { + queue.push(['initInlineWidget', ...args]) + }, + initPopupWidget(...args: unknown[]) { + queue.push(['initPopupWidget', ...args]) + }, + initBadgeWidget(...args: unknown[]) { + queue.push(['initBadgeWidget', ...args]) + }, + initPopupWidgetWithText(...args: unknown[]) { + queue.push(['initPopupWidgetWithText', ...args]) + }, + showPopupWidget(...args: unknown[]) { + queue.push(['showPopupWidget', ...args]) + }, + closePopupWidget(...args: unknown[]) { + queue.push(['closePopupWidget', ...args]) + }, + } + } + + it('pushes the method name and the full options object', () => { + const stub = createStub() + stub.initInlineWidget({ + url: 'https://calendly.com/example/30min', + parentElement: '#calendly-host', + }) + expect(stub.q).toHaveLength(1) + expect(stub.q[0]).toEqual([ + 'initInlineWidget', + { + url: 'https://calendly.com/example/30min', + parentElement: '#calendly-host', + }, + ]) + }) + + it('preserves multiple positional args (showPopupWidget(url, ...))', () => { + const stub = createStub() + stub.showPopupWidget('https://calendly.com/example/30min', { foo: 'bar' }, 42) + expect(stub.q[0]).toEqual([ + 'showPopupWidget', + 'https://calendly.com/example/30min', + { foo: 'bar' }, + 42, + ]) + }) +}) From f4b7a39f322bf9483877c3e5597e92ef1ef0f4d3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 16:03:47 +1000 Subject: [PATCH 3/4] fix(calendly): narrow parentElement to HTMLElement Calendly's `initInlineWidget` API requires a DOM element reference; CSS selector strings are not supported. Tighten the type and update fixtures to resolve the element via `querySelector` first. Per CodeRabbit review on #750. --- packages/script/src/registry-types.json | 2 +- packages/script/src/runtime/registry/calendly.ts | 2 +- test/fixtures/calendly-cdn/pages/calendly.vue | 5 ++++- test/fixtures/calendly/pages/calendly.vue | 5 ++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 42f04801..ffac777c 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -118,7 +118,7 @@ { "name": "CalendlyInlineWidgetOptions", "kind": "interface", - "code": "export interface CalendlyInlineWidgetOptions {\n url: string\n parentElement: HTMLElement | string\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" + "code": "export interface CalendlyInlineWidgetOptions {\n url: string\n parentElement: HTMLElement\n prefill?: CalendlyPrefill\n utm?: CalendlyUtm\n pageSettings?: CalendlyPageSettings\n}" }, { "name": "CalendlyPopupWidgetOptions", diff --git a/packages/script/src/runtime/registry/calendly.ts b/packages/script/src/runtime/registry/calendly.ts index 39dbab4f..8db07305 100644 --- a/packages/script/src/runtime/registry/calendly.ts +++ b/packages/script/src/runtime/registry/calendly.ts @@ -33,7 +33,7 @@ interface CalendlyPageSettings { export interface CalendlyInlineWidgetOptions { url: string - parentElement: HTMLElement | string + parentElement: HTMLElement prefill?: CalendlyPrefill utm?: CalendlyUtm pageSettings?: CalendlyPageSettings diff --git a/test/fixtures/calendly-cdn/pages/calendly.vue b/test/fixtures/calendly-cdn/pages/calendly.vue index 6361db11..10dc6e0d 100644 --- a/test/fixtures/calendly-cdn/pages/calendly.vue +++ b/test/fixtures/calendly-cdn/pages/calendly.vue @@ -10,9 +10,12 @@ const { status, proxy } = useScriptCalendly({ }) function queueInitInline() { + const parentElement = document.querySelector('#calendly-host') + if (!parentElement) + return proxy.Calendly.initInlineWidget({ url: 'https://calendly.com/example/30min', - parentElement: '#calendly-host', + parentElement, }) } diff --git a/test/fixtures/calendly/pages/calendly.vue b/test/fixtures/calendly/pages/calendly.vue index c01d9fd7..c391c047 100644 --- a/test/fixtures/calendly/pages/calendly.vue +++ b/test/fixtures/calendly/pages/calendly.vue @@ -6,11 +6,14 @@ useHead({ title: 'Calendly' }) const { status, proxy } = useScriptCalendly() function queueInitInline() { + const parentElement = document.querySelector('#calendly-host') + if (!parentElement) + return // Pre-load call: must land on the stub queue, then replay against the real // Calendly object once the script finishes loading. proxy.Calendly.initInlineWidget({ url: 'https://calendly.com/example/30min', - parentElement: '#calendly-host', + parentElement, }) } From 47fc93b989a0a973f89908665c06e1ad8c3d56cf Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 16:06:37 +1000 Subject: [PATCH 4/4] test(calendly): assert stub queue length in multi-arg test --- test/unit/calendly-stub-queue.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/calendly-stub-queue.test.ts b/test/unit/calendly-stub-queue.test.ts index b63e1a4e..c9825609 100644 --- a/test/unit/calendly-stub-queue.test.ts +++ b/test/unit/calendly-stub-queue.test.ts @@ -53,6 +53,7 @@ describe('calendly stub queue', () => { it('preserves multiple positional args (showPopupWidget(url, ...))', () => { const stub = createStub() stub.showPopupWidget('https://calendly.com/example/30min', { foo: 'bar' }, 42) + expect(stub.q).toHaveLength(1) expect(stub.q[0]).toEqual([ 'showPopupWidget', 'https://calendly.com/example/30min',