diff --git a/docs/content/docs/1.guides/6.cors.md b/docs/content/docs/1.guides/6.cors.md index 06c9798d..7b857821 100644 --- a/docs/content/docs/1.guides/6.cors.md +++ b/docs/content/docs/1.guides/6.cors.md @@ -118,6 +118,7 @@ Scripts with `crossorigin: false` include: - Calendly - Matomo Analytics - Bing UET +- Deacon If a registry script fails at the network layer, inspect these attributes alongside the response's CORS headers. diff --git a/docs/content/scripts/deacon.md b/docs/content/scripts/deacon.md new file mode 100644 index 00000000..cf2fe539 --- /dev/null +++ b/docs/content/scripts/deacon.md @@ -0,0 +1,95 @@ +--- +title: Deacon +description: Add the Deacon chat widget, an AI support agent that answers from your own content, to your Nuxt app. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/deacon.ts + size: xs +--- + +[Deacon](https://heydeacon.com) is an AI support agent for websites and apps that answers questions from your own content. The same script also counts page views, goals and JavaScript errors for its website analytics. + +[`useScriptDeacon()`{lang="ts"}](/scripts/deacon) loads the widget and types the `window.Deacon` API it sets up. + +::script-stats +:: + +::script-docs +:: + +Your key is the `data-key` value in the snippet on the Install page of the Deacon dashboard, and starts with `pk_`. Deacon only runs on the sites listed on that page. A listed domain covers its subdomains, and once the widget has loaded on `localhost`, Install offers to add it in one click. + +::code-group + +```text [.env] +NUXT_PUBLIC_SCRIPTS_DEACON_KEY=pk_your_widget_key +``` + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + deacon: { trigger: 'onNuxtReady' }, + }, + }, +}) +``` + +:: + +The widget brings its own chat button. To use one of yours, hide Deacon's in Appearance in the dashboard and open the chat from your own button: + +```vue + + + +``` + +## Logged-in visitors + +In an app people log in to, pass Deacon the details of the person using it. The team then sees their name on the conversation, and they don't have to type their email to get a reply. Deacon doesn't verify these details. Pass `null` when they log out. + +```ts +const { proxy } = useScriptDeacon() +const user = useUser() // your own auth state + +watch(user, (current) => { + proxy.identify(current ? { id: current.id, email: current.email, name: current.name } : null) +}, { immediate: true }) +``` + +## Context + +`context` passes details Deacon takes into account when it answers, such as the visitor's plan. Everything in `nuxt.config` is the same for every visitor, so pass details about one visitor at runtime with `setContext()`{lang="ts"}, which merges over `context`. A `null` value removes a key. Every value can be read in the page, so never pass a secret. + +```ts +const { proxy } = useScriptDeacon() + +proxy.setContext({ plan: 'pro', seats: 3 }) +``` + +## Goals + +`track()`{lang="ts"} sends a goal to Analytics › Website in Deacon. + +```ts +const { proxy } = useScriptDeacon() + +proxy.track('Upgraded', { plan: 'pro' }) +``` + +Set `pageViews: false` to send Deacon no page views or JavaScript errors from your app, as you might in an app behind a login. Goals and chat still work. + +::script-types +:: + +::callout +Deacon's loader takes the origin of its own `src` as the address of its chat and API, so this script doesn't support [bundling or first-party mode](/docs/guides/first-party). It loads directly from `heydeacon.com`, with [`crossorigin` turned off](/docs/guides/cors#registry-scripts) because the loader is served without CORS headers. +:: diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 61f53b01..0c63c957 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -61,6 +61,10 @@ export const LOGOS = { dark: ``, }, tawkTo: `https://www.tawk.to/icon.png`, + deacon: { + light: ``, + dark: ``, + }, crisp: { light: ``, dark: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index edfe16f4..78cb442e 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -248,6 +248,23 @@ "code": "export interface DatabuddyAnalyticsApi {\n /**\n * Track a custom event.\n * @param eventName Name of the event (use snake_case)\n * @param properties Optional event properties\n */\n track: (eventName: string, properties?: Record) => Promise | any | void\n\n /**\n * Manually record a page / screen view. Useful for SPA route changes.\n * @param path Optional path to record (defaults to current location)\n * @param properties Optional additional properties for the screen view\n */\n screenView: (path?: string, properties?: Record) => void\n\n /**\n * Set properties that will be attached to all future events (e.g. user_id).\n * @param properties Key/value map of properties to attach globally\n */\n setGlobalProperties: (properties: Record) => void\n\n /**\n * Track a custom event alias (compatibility helper present on the global)\n * @param eventName Name of the event\n * @param properties Optional event properties\n */\n trackCustomEvent: (eventName: string, properties?: Record) => void\n\n /**\n * Clears session and anonymous identifiers (useful on logout).\n */\n clear: () => void\n\n /**\n * Force immediate sending of any queued/batched events.\n */\n flush: () => void\n}" } ], + "deacon": [ + { + "name": "DeaconOptions", + "kind": "const", + "code": "export const DeaconOptions = object({\n /**\n * Your Deacon key, the `data-key` value in the snippet on the Install page of the\n * Deacon dashboard. It starts with `pk_`.\n * @see https://heydeacon.com/resources/how-to-use-deacon\n */\n key: pipe(string(), minLength(1)),\n /**\n * Details about the visitor that Deacon takes into account when it answers, such as\n * their plan. Rendered as the `data-context` attribute. Up to 12 keys. Every value can be\n * read in the page, so never put a secret here. Use `proxy.setContext()` for details the\n * page learns after it loads.\n */\n context: optional(record(string(), union([string(), number(), boolean()]))),\n /**\n * Set to `false` to send Deacon no page views or JavaScript errors from the page. Chat\n * and goals still work. Rendered as the `data-no-page-views` attribute.\n * @default true\n */\n pageViews: optional(boolean()),\n})" + }, + { + "name": "DeaconUser", + "kind": "interface", + "code": "export interface DeaconUser {\n /** Your app's id for them. */\n id?: string | number\n email?: string\n name?: string\n}" + }, + { + "name": "DeaconApi", + "kind": "interface", + "code": "export interface DeaconApi {\n /** Open the chat. */\n open: () => void\n /** Close the chat. */\n close: () => void\n /** Open the chat if it is closed, and close it if it is open. */\n toggle: () => void\n /** Load the chat window in the background, so it opens without a wait. */\n preload: () => void\n /**\n * Tell Deacon what the page has learned about the visitor since it loaded, such as their\n * plan. Merges over `context`. A null value removes that key, and `null` clears it all.\n */\n setContext: (values: Record | null) => void\n /**\n * Tell Deacon who is logged in, so the team sees their name on the conversation and they\n * don't have to type their email to get a reply. Replaces the whole identity, and `null`\n * says nobody is logged in. Deacon does not verify it.\n */\n identify: (user: DeaconUser | null) => void\n /** Send a goal, one of the moments named on Analytics › Website, with optional details. */\n track: (name: string, props?: Record) => void\n}" + } + ], "deskcrew": [ { "name": "DeskCrewOptions", @@ -1930,6 +1947,27 @@ "description": "Enable error tracking via observability." } ], + "DeaconOptions": [ + { + "name": "key", + "type": "string", + "required": true, + "description": "Your Deacon key, the `data-key` value in the snippet on the Install page of the Deacon dashboard. It starts with `pk_`." + }, + { + "name": "context", + "type": "Record", + "required": false, + "description": "Details about the visitor that Deacon takes into account when it answers, such as their plan. Rendered as the `data-context` attribute. Up to 12 keys. Every value can be read in the page, so never put a secret here. Use `proxy.setContext()` for details the page learns after it loads." + }, + { + "name": "pageViews", + "type": "boolean", + "required": false, + "description": "Set to `false` to send Deacon no page views or JavaScript errors from the page. Chat and goals still work. Rendered as the `data-no-page-views` attribute.", + "defaultValue": "true" + } + ], "DeskCrewOptions": [ { "name": "widgetKey", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index ddff5a0f..9582df08 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -21,6 +21,7 @@ import { CloudflareWebAnalyticsOptions, CrispOptions, DatabuddyAnalyticsOptions, + DeaconOptions, DeskCrewOptions, FathomAnalyticsOptions, GoogleAdsenseOptions, @@ -182,6 +183,7 @@ export const registryMeta: RegistryScriptMeta[] = [ m('crisp', 'Crisp', 'support', 'useScriptCrisp', { bundle: true }, null), m('deskcrew', 'DeskCrew', 'support', 'useScriptDeskCrew', {}, null), m('tawkTo', 'Tawk.to', 'support', 'useScriptTawkTo', {}, null), + m('deacon', 'Deacon', 'support', 'useScriptDeacon', {}, null), // cdn m('npm', 'NPM', 'cdn', 'useScriptNpm', { bundle: true }, null), // utility @@ -799,6 +801,16 @@ export async function registry(resolve?: (path: string) => Promise): Pro category: 'support', envDefaults: { propertyId: '', widgetId: '' }, }), + // Deacon's loader takes the origin of its own `src` as the host for its chat frame + // and API, so a copy served from /_scripts/assets would point every request at the + // consuming site, and proxying would put that site's server between visitors and + // the live chat. Bundle and proxy are therefore intentionally absent. + def('deacon', { + schema: DeaconOptions, + label: 'Deacon', + category: 'support', + envDefaults: { key: '' }, + }), // cdn def('npm', { schema: NpmOptions, diff --git a/packages/script/src/runtime/registry/deacon.ts b/packages/script/src/runtime/registry/deacon.ts new file mode 100644 index 00000000..7ee41649 --- /dev/null +++ b/packages/script/src/runtime/registry/deacon.ts @@ -0,0 +1,68 @@ +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { DeaconOptions } from './schemas' + +export { DeaconOptions } + +export type DeaconInput = RegistryScriptInput + +export interface DeaconUser { + /** Your app's id for them. */ + id?: string | number + email?: string + name?: string +} + +export interface DeaconApi { + /** Open the chat. */ + open: () => void + /** Close the chat. */ + close: () => void + /** Open the chat if it is closed, and close it if it is open. */ + toggle: () => void + /** Load the chat window in the background, so it opens without a wait. */ + preload: () => void + /** + * Tell Deacon what the page has learned about the visitor since it loaded, such as their + * plan. Merges over `context`. A null value removes that key, and `null` clears it all. + */ + setContext: (values: Record | null) => void + /** + * Tell Deacon who is logged in, so the team sees their name on the conversation and they + * don't have to type their email to get a reply. Replaces the whole identity, and `null` + * says nobody is logged in. Deacon does not verify it. + */ + identify: (user: DeaconUser | null) => void + /** Send a goal, one of the moments named on Analytics › Website, with optional details. */ + track: (name: string, props?: Record) => void +} + +declare global { + interface Window { + Deacon: DeaconApi + } +} + +export function useScriptDeacon(_options?: DeaconInput) { + return useRegistryScript('deacon', options => ({ + scriptInput: { + 'src': 'https://heydeacon.com/widget/v1/loader.js', + 'data-key': options.key, + 'data-context': options.context ? JSON.stringify(options.context) : undefined, + // The loader checks hasAttribute(), and unhead renders `false` as "false", so the + // opt-out is written as '' or left out, never as a boolean. + 'data-no-page-views': options.pageViews === false ? '' : undefined, + // loader.js is served without an Access-Control-Allow-Origin header, so the + // crossorigin="anonymous" unhead adds by default would have the browser block it. + 'crossorigin': false, + }, + schema: import.meta.dev ? DeaconOptions : undefined, + scriptOptions: { + // The loader assigns window.Deacon synchronously as it runs, so the API exists + // by the time the load event fires. + use() { + return window.Deacon + }, + }, + }), _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index ff839da5..20f22ba3 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -290,6 +290,28 @@ export const DatabuddyAnalyticsOptions = object({ enableErrorTracking: optional(boolean()), }) +export const DeaconOptions = object({ + /** + * Your Deacon key, the `data-key` value in the snippet on the Install page of the + * Deacon dashboard. It starts with `pk_`. + * @see https://heydeacon.com/resources/how-to-use-deacon + */ + key: pipe(string(), minLength(1)), + /** + * Details about the visitor that Deacon takes into account when it answers, such as + * their plan. Rendered as the `data-context` attribute. Up to 12 keys. Every value can be + * read in the page, so never put a secret here. Use `proxy.setContext()` for details the + * page learns after it loads. + */ + context: optional(record(string(), union([string(), number(), boolean()]))), + /** + * Set to `false` to send Deacon no page views or JavaScript errors from the page. Chat + * and goals still work. Rendered as the `data-no-page-views` attribute. + * @default true + */ + pageViews: optional(boolean()), +}) + export const DeskCrewOptions = object({ /** * The DeskCrew public widget key, for example `pub_xxxxxxxx`. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 24793ba5..8fabb0d3 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -13,6 +13,7 @@ import type { ClarityInput } from './registry/clarity' import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics' import type { CrispInput } from './registry/crisp' import type { DatabuddyAnalyticsInput } from './registry/databuddy-analytics' +import type { DeaconInput } from './registry/deacon' import type { DeskCrewInput } from './registry/deskcrew' import type { FathomAnalyticsInput } from './registry/fathom-analytics' import type { GoogleAdsenseInput } from './registry/google-adsense' @@ -281,6 +282,7 @@ export interface ScriptRegistry { carbonAds?: true calendly?: CalendlyInput crisp?: CrispInput + deacon?: DeaconInput deskcrew?: DeskCrewInput clarity?: ClarityInput cloudflareWebAnalytics?: CloudflareWebAnalyticsInput @@ -333,7 +335,7 @@ export interface ScriptRegistry { */ export type BuiltInRegistryScriptKey = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'calendly' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' - | 'databuddyAnalytics' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' + | 'databuddyAnalytics' | 'deacon' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'pulseAnalytics' | 'matomoAnalytics' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index f20c2fed..aef23127 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -160,6 +160,10 @@ export const scriptMeta = { trackedData: ['user-identity', 'events'], testId: '68496650ddf9cd19094b4530', }, + deacon: { + urls: ['https://heydeacon.com/widget/v1/loader.js'], + trackedData: ['page-views', 'events', 'conversions', 'user-identity', 'errors'], + }, crisp: { urls: ['https://client.crisp.chat/l.js'], diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 919a5593..9a47ffd0 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -43,6 +43,7 @@ export default defineNuxtConfig({ crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, deskcrew: { widgetKey: 'pub_deskcrewdemo', board: 'demo' }, tawkTo: { propertyId: '68496650ddf9cd19094b4530', widgetId: '1itfbfagd' }, + deacon: { key: 'pk_your_widget_key' }, }, }, }, @@ -115,6 +116,7 @@ export default defineNuxtConfig({ crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417', trigger: 'manual' }, deskcrew: { widgetKey: 'pub_deskcrewdemo', board: 'demo', trigger: 'manual' }, tawkTo: { propertyId: '68496650ddf9cd19094b4530', widgetId: '1itfbfagd', trigger: 'manual' }, + deacon: { key: 'pk_your_widget_key', trigger: 'manual' }, // Media youtubePlayer: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 30ce9ec0..9560e501 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -43,6 +43,7 @@ function getPlaygroundPath(script: any): string | null { 'crisp': '/third-parties/crisp/facade', 'deskcrew': '/third-parties/deskcrew/facade', 'tawk.to': '/third-parties/tawk-to/use-script', + 'deacon': '/third-parties/deacon', 'stripe': '/third-parties/stripe/nuxt-scripts', 'paypal': '/third-parties/paypal/nuxt-scripts', 'lemon-squeezy': '/third-parties/lemon-squeezy/component', diff --git a/playground/pages/third-parties/deacon.vue b/playground/pages/third-parties/deacon.vue new file mode 100644 index 00000000..bcb0020a --- /dev/null +++ b/playground/pages/third-parties/deacon.vue @@ -0,0 +1,69 @@ + + + diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 4dfb4539..9062d4c8 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -1,5 +1,6 @@ import type { ModuleOptions } from '../../packages/script/src/module' import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp' +import type { DeaconApi } from '../../packages/script/src/runtime/registry/deacon' import type { DeskCrewApi, DeskCrewEmbedOptions } from '../../packages/script/src/runtime/registry/deskcrew' import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics' import type { TawkToApi, TawkToProxyApi, TawkToVisitor, TawkToWindowType } from '../../packages/script/src/runtime/registry/tawk-to' @@ -20,6 +21,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() @@ -101,6 +103,18 @@ describe('module options registry', () => { }) describe('registry api types', () => { + it('DeaconApi matches the loader\'s window.Deacon', () => { + expectTypeOf().toEqualTypeOf<() => void>() + expectTypeOf().toEqualTypeOf<() => void>() + expectTypeOf().toEqualTypeOf<() => void>() + expectTypeOf().toEqualTypeOf<() => void>() + // null clears the context, and a null value removes that one key. + expectTypeOf().parameter(0).toEqualTypeOf | null>() + // null says nobody is logged in. + expectTypeOf().parameter(0).toEqualTypeOf<{ id?: string | number, email?: string, name?: string } | null>() + expectTypeOf().toEqualTypeOf<(name: string, props?: Record) => void>() + }) + it('DeskCrewApi exposes the documented surface with usable signatures', () => { // The registry hands this object straight to callers, so a regression to `any` // here would compile fine and silently remove every bit of autocomplete. diff --git a/test/unit/deacon.test.ts b/test/unit/deacon.test.ts new file mode 100644 index 00000000..0b321a56 --- /dev/null +++ b/test/unit/deacon.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { registry } from '../../packages/script/src/registry' +import { useScriptDeacon } from '../../packages/script/src/runtime/registry/deacon' + +const mocks = vi.hoisted(() => ({ + definition: undefined as any, + useRegistryScript: vi.fn(), +})) + +vi.mock('@unhead/vue', () => ({ useHead: vi.fn() })) + +vi.mock('../../packages/script/src/runtime/utils', () => ({ + useRegistryScript: mocks.useRegistryScript, +})) + +function setup(options: Record = { key: 'pk_test123' }) { + mocks.useRegistryScript.mockImplementation((_key: string, factory: (o: any) => any) => { + mocks.definition = factory(options) + return { status: ref('awaitingLoad'), signal: new AbortController().signal, load: vi.fn() } + }) + useScriptDeacon(options as any) + return { + scriptInput: mocks.definition.scriptInput as Record, + use: mocks.definition.scriptOptions.use as () => { open: () => void }, + } +} + +describe('deacon', () => { + beforeEach(() => { + vi.clearAllMocks() + delete (window as any).Deacon + }) + + it('renders the snippet from the Install page', () => { + const { scriptInput } = setup() + expect(scriptInput.src).toBe('https://heydeacon.com/widget/v1/loader.js') + expect(scriptInput['data-key']).toBe('pk_test123') + }) + + it('loads the loader without CORS mode, which heydeacon.com does not serve it for', () => { + // unhead adds crossorigin="anonymous" to cross-origin scripts. loader.js comes without + // an Access-Control-Allow-Origin header, so the browser would block it. + const { scriptInput } = setup() + expect(scriptInput.crossorigin).toBe(false) + }) + + it('passes context to the loader as JSON in data-context', () => { + const { scriptInput } = setup({ key: 'pk_test123', context: { plan: 'pro', seats: 3, trial: false } }) + expect(scriptInput['data-context']).toBe('{"plan":"pro","seats":3,"trial":false}') + }) + + // The loader only checks whether data-no-page-views is present, and unhead renders a + // `false` value as data-no-page-views="false", so the attribute is '' or left out. + it.each([ + [false, ''], + [true, undefined], + [undefined, undefined], + ])('maps pageViews: %s onto data-no-page-views', (pageViews, attribute) => { + const { scriptInput } = setup({ key: 'pk_test123', pageViews }) + expect(scriptInput['data-no-page-views']).toBe(attribute) + }) + + it('hands back the loader\'s window.Deacon once loaded', () => { + const deacon = { open: vi.fn() } + ;(window as any).Deacon = deacon + const { use } = setup() + expect(use()).toBe(deacon) + }) + + it('is registered as a support script with useScriptDeacon', async () => { + const script = (await registry()).find(s => s.registryKey === 'deacon') + expect(script?.category).toBe('support') + expect(script?.import?.name).toBe('useScriptDeacon') + }) + + it('is never bundled, since the loader takes its own src\'s origin as Deacon\'s host', async () => { + const script = (await registry()).find(s => s.registryKey === 'deacon') + expect(script?.bundle).toBeUndefined() + }) +}) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 8c9306e9..f0b22494 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -414,6 +414,13 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('does not return proxy config for deacon (script derives its API origin from its own src)', async () => { + // The loader takes its own src's origin as the host for its chat frame and API, + // and proxying would put the consuming site's server between visitors and the chat. + const config = (await getProxyConfigs()).deacon + expect(config).toBeUndefined() + }) + it('returns proxy config for calendly', async () => { const config = (await getProxyConfigs()).calendly expect(config).toBeDefined() @@ -469,6 +476,7 @@ describe('proxy configs', () => { expect(configs).not.toHaveProperty('crisp') expect(configs).not.toHaveProperty('deskcrew') expect(configs).not.toHaveProperty('tawkTo') + expect(configs).not.toHaveProperty('deacon') expect(configs).toHaveProperty('vercelAnalytics') expect(configs).toHaveProperty('gravatar') expect(configs).toHaveProperty('calendly')