Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
"preserve": "npm run build:plugins",
"serve": "docusaurus serve --dir dist",
"test": "cypress run",
"test:plugins": "npm --prefix ./plugins/cypressRemarkPlugins run test && npm --prefix ./plugins/llm run test && npm run test:faq-plugin && npm run test:og-plugin && npm run test:video-structured-data",
"test:plugins": "npm --prefix ./plugins/cypressRemarkPlugins run test && npm --prefix ./plugins/llm run test && npm run test:faq-plugin && npm run test:fullstory-plugin && npm run test:og-plugin && npm run test:video-structured-data",
"test:faq-plugin": "vitest run plugins/faq-structured-data.test.js",
"test:fullstory-plugin": "vitest run plugins/fullstory.test.js",
"test:og-plugin": "vitest run plugins/og-image-cards.test.js",
"test:video-structured-data": "vitest run src/components/video-youtube/structured-data.test.ts",
"test:search": "vitest run src/theme/SearchBar/searchRanking.test.js scripts/search/algolia-client.test.mjs scripts/search/scrape-and-compare-algolia-index.test.mjs",
Expand Down
3 changes: 2 additions & 1 deletion plugins/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ npm run build:plugins # tsc for both sub-packages
## Verifying a change

- `npm run test:plugins` runs the vitest suites in both sub-packages plus the
FAQ plugin test, the OG card sizing test (`og-image-cards.test.js`), and the
FAQ plugin test, the Fullstory consent gate test (`fullstory.test.js`), the
OG card sizing test (`og-image-cards.test.js`), and the
video structured-data test in `src/components/video-youtube/`.
- `npm run build` is the real check for `cypressRemarkPlugins`: it backs the
`:::cypress-config-example`, `:::cypress-config-plugin-example`, and
Expand Down
40 changes: 40 additions & 0 deletions plugins/fullstory.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ const FULLSTORY_HOST = 'fullstory.com'
const FULLSTORY_SCRIPT = 'edge.fullstory.com/s/fs.js'
const FULLSTORY_NAMESPACE = 'FS'

// Capture follows the visitor's Osano Analytics choice, including a change
// mid-session: Osano's own script blocking only takes effect on the next page
// load, so without this, fs.js keeps recording after a visitor declines. When
// osano.js is blocked (by an ad blocker, for example) there's no consent to
// read, so capture never starts.
const consentGate = `
(function () {
var fs = window[${JSON.stringify(FULLSTORY_NAMESPACE)}];
// _v is only set when the snippet installed itself rather than backing off
// from another script's global of the same name.
if (!fs || !fs._v) return;
fs('init', { env: { captureOnStartup: false } });
var cm = window.Osano && window.Osano.cm;
if (!cm || !cm.addEventListener || !cm.getConsent) return;
var state = 'waiting';
function apply(consent) {
var granted = !!consent && consent.ANALYTICS === 'ACCEPT';
if (granted && state === 'waiting') {
fs('start');
state = 'capturing';
} else if (granted && state === 'stopped') {
fs('restart');
state = 'capturing';
} else if (!granted && state === 'capturing') {
fs('shutdown');
state = 'stopped';
}
}
cm.addEventListener('osano-cm-consent-saved', apply);
apply(cm.getConsent());
})();
`

module.exports = async function fullStory({ context, options }) {
const isProd = process.env.NODE_ENV === 'production'
const orgId = process.env.FULLSTORY_ORG_ID
Expand Down Expand Up @@ -42,8 +75,15 @@ return g._w[u].apply(this,arguments)}),g("init",{env:{orgId:o,host:e,script:t}})
}(window,document,${JSON.stringify(FULLSTORY_HOST)},${JSON.stringify(FULLSTORY_SCRIPT)},${JSON.stringify(FULLSTORY_NAMESPACE)},${JSON.stringify(orgId)});
`,
},
{
tagName: 'script',
innerHTML: consentGate,
},
],
}
},
}
}

// Exported for standalone testing.
module.exports.consentGate = consentGate
123 changes: 123 additions & 0 deletions plugins/fullstory.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, test } from 'vitest'
import fullStory from './fullstory.js'

const { consentGate } = fullStory

const ACCEPT = { ANALYTICS: 'ACCEPT', MARKETING: 'ACCEPT' }
const DENY = { ANALYTICS: 'DENY', MARKETING: 'ACCEPT' }

function fakeFs() {
const calls = []
const fs = (method, args) => {
calls.push(args === undefined ? [method] : [method, args])
}
fs._v = '2.1.0'
return { fs, calls }
}

function fakeOsano(initialConsent) {
let consent = initialConsent
const listeners = []
const cm = {
getConsent: () => consent,
addEventListener: (name, listener) => {
if (name === 'osano-cm-consent-saved') listeners.push(listener)
},
}
const save = (next) => {
consent = next
listeners.forEach((listener) => listener(next))
}
return { Osano: { cm }, save }
}

function runGate(window) {
new Function('window', consentGate)(window)
}

describe('consentGate', () => {
test('delays capture on startup', () => {
const { fs, calls } = fakeFs()
runGate({ FS: fs, Osano: fakeOsano(DENY).Osano })
expect(calls[0]).toEqual(['init', { env: { captureOnStartup: false } }])
})

test('starts capture when Analytics is already accepted', () => {
const { fs, calls } = fakeFs()
runGate({ FS: fs, Osano: fakeOsano(ACCEPT).Osano })
expect(calls.slice(1)).toEqual([['start']])
})

test('waits while Analytics is denied, then starts once accepted', () => {
const { fs, calls } = fakeFs()
const osano = fakeOsano(DENY)
runGate({ FS: fs, Osano: osano.Osano })
expect(calls.slice(1)).toEqual([])

osano.save(ACCEPT)
expect(calls.slice(1)).toEqual([['start']])
})

test('shuts down when consent is withdrawn and restarts when it returns', () => {
const { fs, calls } = fakeFs()
const osano = fakeOsano(ACCEPT)
runGate({ FS: fs, Osano: osano.Osano })

osano.save(DENY)
osano.save(DENY)
osano.save(ACCEPT)
osano.save(ACCEPT)
expect(calls.slice(1)).toEqual([['start'], ['shutdown'], ['restart']])
})

test('never starts capture without Osano', () => {
const { fs, calls } = fakeFs()
runGate({ FS: fs })
expect(calls).toEqual([['init', { env: { captureOnStartup: false } }]])
})

test('leaves another script’s FS global alone', () => {
const calls = []
const otherFs = (...args) => calls.push(args)
runGate({ FS: otherFs, Osano: fakeOsano(ACCEPT).Osano })
expect(calls).toEqual([])
})
})

describe('fullStory plugin', () => {
const originalEnv = { ...process.env }

afterEach(() => {
process.env = { ...originalEnv }
})

async function headTags(env) {
process.env = { ...originalEnv, ...env }
if (env.FULLSTORY_ORG_ID === undefined) delete process.env.FULLSTORY_ORG_ID
const plugin = await fullStory({})
return plugin.injectHtmlTags({}).headTags
}

test('adds nothing without an org ID', async () => {
expect(await headTags({ NODE_ENV: 'production' })).toBeUndefined()
expect(
await headTags({ NODE_ENV: 'production', FULLSTORY_ORG_ID: '' })
).toBeUndefined()
})

test('adds nothing outside production', async () => {
expect(
await headTags({ NODE_ENV: 'development', FULLSTORY_ORG_ID: 'test-org' })
).toBeUndefined()
})

test('adds the snippet, then the consent gate', async () => {
const tags = await headTags({
NODE_ENV: 'production',
FULLSTORY_ORG_ID: 'test-org',
})
expect(tags).toHaveLength(2)
expect(tags[0].innerHTML).toContain('"FS","test-org")')
expect(tags[1].innerHTML).toBe(consentGate)
})
})
Loading