diff --git a/.eslintrc.js b/.eslintrc.js index 209de498..592424ad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,25 +1,30 @@ module.exports = { - "extends": [ - "react-app", - "prettier/@typescript-eslint", - "plugin:prettier/recommended" + extends: [ + 'react-app', + 'prettier/@typescript-eslint', + 'plugin:prettier/recommended', ], - "settings": { - "react": { - "version": "detect" - } + settings: { + react: { + version: 'detect', + }, }, - "rules": { - "@typescript-eslint/semi": ["error", "never"], - "@typescript-eslint/member-delimiter-style": ["error", { - "multiline": { - "delimiter": "none", - "requireLast": true + plugins: ['eslint-plugin-consent-manager'], + rules: { + 'consent-manager/do-not-inject-scripts': ['error'], + '@typescript-eslint/semi': ['error', 'never'], + '@typescript-eslint/member-delimiter-style': [ + 'error', + { + multiline: { + delimiter: 'none', + requireLast: true, + }, + singleline: { + delimiter: 'comma', + requireLast: false, + }, }, - "singleline": { - "delimiter": "comma", - "requireLast": false - } - }] - } + ], + }, } diff --git a/cypress/integration/basic.spec.ts b/cypress/integration/basic.spec.ts index eafb0560..6dff360f 100644 --- a/cypress/integration/basic.spec.ts +++ b/cypress/integration/basic.spec.ts @@ -1,6 +1,6 @@ /// -describe('Wrapping Component', () => { +describe('Basics', () => { before(() => { cy.visit('http://localhost:1234/') }) @@ -13,57 +13,4 @@ describe('Wrapping Component', () => { container.should('contain', 'Video Inc.') container.should('contain', 'Red Box Ltd.') }) - - it('does not render wrapping component by default', () => { - cy.get('[data-testid="consent-manager-wrapping-component"]').should( - 'not.exist' - ) - }) - - it('renders wrapping component after making decision', () => { - cy.toggleIntegration('Red Box Ltd.') - - cy.get('[data-testid="consent-manager-wrapping-component"]').should('exist') - }) - - it('removes wrapping component after revoking decision', () => { - cy.toggleIntegration('Red Box Ltd.') - - cy.get('[data-testid="consent-manager-wrapping-component"]').should( - 'not.exist' - ) - }) -}) - -describe('Privacy Shield', () => { - before(() => { - cy.visit('http://localhost:1234/video') - }) - it('renders privacy shield', () => { - cy.get('[data-testid="consent-manager-privacy-shield"]').contains( - 'Video Inc. is a popular service to share clips of cats.' - ) - }) - - it('renders form component', () => { - const container = cy.get('[data-testid="consent-manager-form-container"]') - container.should('contain', 'Video Inc.') - container.should('contain', 'Red Box Ltd.') - }) - - it('renders video component after making decision', () => { - cy.toggleIntegration('Video Inc.') - - cy.get('[data-testid="consent-manager-video-component"]').contains( - 'Video component with id rick-roll' - ) - }) - - it('renders privacy shield after revoking decision', () => { - cy.toggleIntegration('Video Inc.') - - cy.get('[data-testid="consent-manager-privacy-shield"]').contains( - 'Video Inc. is a popular service to share clips of cats.' - ) - }) }) diff --git a/cypress/integration/privacy-shield.spec.ts b/cypress/integration/privacy-shield.spec.ts new file mode 100644 index 00000000..368293f5 --- /dev/null +++ b/cypress/integration/privacy-shield.spec.ts @@ -0,0 +1,34 @@ +/// + +describe('Privacy Shield', () => { + before(() => { + cy.visit('http://localhost:1234/video') + }) + it('renders privacy shield', () => { + cy.get('[data-testid="consent-manager-privacy-shield"]').contains( + 'Video Inc. is a popular service to share clips of cats.' + ) + }) + + it('renders form component', () => { + const container = cy.get('[data-testid="consent-manager-form-container"]') + container.should('contain', 'Video Inc.') + container.should('contain', 'Red Box Ltd.') + }) + + it('renders video component after making decision', () => { + cy.toggleIntegration('Video Inc.') + + cy.get('[data-testid="consent-manager-video-component"]').contains( + 'Video component with id rick-roll' + ) + }) + + it('renders privacy shield after revoking decision', () => { + cy.toggleIntegration('Video Inc.') + + cy.get('[data-testid="consent-manager-privacy-shield"]').contains( + 'Video Inc. is a popular service to share clips of cats.' + ) + }) +}) diff --git a/cypress/integration/script-injectors.spec.ts b/cypress/integration/script-injectors.spec.ts new file mode 100644 index 00000000..e8c2a818 --- /dev/null +++ b/cypress/integration/script-injectors.spec.ts @@ -0,0 +1,91 @@ +/// + +describe('Script Injector: script', () => { + const selector = 'script#red-box-ltd' + + before(() => { + cy.visit('http://localhost:1234/') + }) + it('does not inject script by default', () => { + cy.get(selector).should('not.exist') + }) + + it('injects script after making decision', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.get(selector).should('have.attr', 'async') + cy.get(selector).should('have.attr', 'defer') + cy.get(selector) + .should('have.attr', 'type') + .should('equals', 'text/javascript') + + cy.window() + .its('rbltd.push') + .should('exist') + }) + + it('removes script when revoking decision', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.get(selector).should('not.exist') + }) +}) + +describe('Script Injector: img tag', () => { + const selector = 'img[src="/zero-pixel.png"]' + + before(() => { + cy.visit('http://localhost:1234/') + }) + it('does not inject img by default', () => { + cy.get(selector).should('not.exist') + }) + + it('injects img after making decision', () => { + cy.toggleIntegration('Innocent Pixel') + + cy.get(selector).should('have.attr', 'height') + cy.get(selector).should('have.attr', 'width') + }) + + it('removes img when revoking decision', () => { + cy.toggleIntegration('Innocent Pixel') + + cy.get(selector).should('not.exist') + }) +}) + +describe('Script Injector: image and script tags', () => { + const selectorImg = 'img[src="/zero-pixel.png"]' + const selectorScript = 'script#red-box-ltd' + + before(() => { + cy.visit('http://localhost:1234/') + }) + it('does not inject by default', () => { + cy.get(selectorImg).should('not.exist') + cy.get(selectorScript).should('not.exist') + }) + + it('injects img and script after making decision', () => { + cy.toggleIntegration('Red Box Ltd.') + cy.toggleIntegration('Innocent Pixel') + + cy.get(selectorScript).should('exist') + cy.get(selectorImg).should('exist') + }) + + it('removes script when revoking decision', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.get(selectorScript).should('not.exist') + cy.get(selectorImg).should('exist') + }) + + it('removes img when revoking decision', () => { + cy.toggleIntegration('Innocent Pixel') + + cy.get(selectorImg).should('not.exist') + cy.get(selectorScript).should('not.exist') + }) +}) diff --git a/cypress/integration/tracking-events.spec.ts b/cypress/integration/tracking-events.spec.ts new file mode 100644 index 00000000..a9330fd8 --- /dev/null +++ b/cypress/integration/tracking-events.spec.ts @@ -0,0 +1,80 @@ +describe('Pave View Tracking', () => { + beforeEach(() => { + cy.visit('http://localhost:1234/', { + onBeforeLoad(win) { + cy.spy(win.console, 'log').as('console.log') + }, + }) + }) + + it('does not initialize tracking by default', () => { + cy.get('@console.log') + .should('not.be.calledWith', 'Initializing Red Box Ltd. tracking') + .should('not.be.calledWith', 'page view: /') + .should('not.be.called') + cy.window() + .its('rbltd') + .should('not.exist') + cy.get('[data-testid="consent-manager-wrapping-component"]').should( + 'not.exist' + ) + }) + + it('initializes tracking on user consent and logs initial page view', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.window() + .its('rbltd.push') + .should('exist') + + cy.get('@console.log') + .should('be.calledWith', 'page view: /') + .should('not.be.calledWith', 'page view: /video') + .should('be.calledOnce') + }) + + it('tracks page views when route changes to video and back', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.get('@console.log') + .should('be.calledWith', 'page view: /') + .should('be.calledOnce') + + cy.get('[data-testid=example-nav-video]').click() + + cy.get('@console.log') + .should('be.calledWith', 'page view: /video') + .should('be.calledTwice') + + cy.get('[data-testid=example-nav-home]').click() + + cy.get('@console.log').then(logSpy => { + expect(logSpy.args[0][0]).to.equal(logSpy.args[2][0]) + }) + cy.get('@console.log').should('be.calledThrice') + }) + + it('disables tracking when user revokes consent', () => { + cy.get('[data-testid="consent-manager-wrapping-component"]').should( + 'not.exist' + ) + + cy.toggleIntegration('Red Box Ltd.') + + cy.get('@console.log').should('be.calledOnce') + + cy.get('[data-testid=example-nav-video]').click() + + cy.get('@console.log') + .should('be.calledWith', 'page view: /video') + .should('be.calledTwice') + + cy.toggleIntegration('Red Box Ltd.') + + cy.get('@console.log').should('be.calledTwice') + + cy.get('[data-testid=example-nav-home]').click() + + cy.get('@console.log').should('be.calledTwice') + }) +}) diff --git a/cypress/integration/tracking-pageviews.spec.ts b/cypress/integration/tracking-pageviews.spec.ts new file mode 100644 index 00000000..a9330fd8 --- /dev/null +++ b/cypress/integration/tracking-pageviews.spec.ts @@ -0,0 +1,80 @@ +describe('Pave View Tracking', () => { + beforeEach(() => { + cy.visit('http://localhost:1234/', { + onBeforeLoad(win) { + cy.spy(win.console, 'log').as('console.log') + }, + }) + }) + + it('does not initialize tracking by default', () => { + cy.get('@console.log') + .should('not.be.calledWith', 'Initializing Red Box Ltd. tracking') + .should('not.be.calledWith', 'page view: /') + .should('not.be.called') + cy.window() + .its('rbltd') + .should('not.exist') + cy.get('[data-testid="consent-manager-wrapping-component"]').should( + 'not.exist' + ) + }) + + it('initializes tracking on user consent and logs initial page view', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.window() + .its('rbltd.push') + .should('exist') + + cy.get('@console.log') + .should('be.calledWith', 'page view: /') + .should('not.be.calledWith', 'page view: /video') + .should('be.calledOnce') + }) + + it('tracks page views when route changes to video and back', () => { + cy.toggleIntegration('Red Box Ltd.') + + cy.get('@console.log') + .should('be.calledWith', 'page view: /') + .should('be.calledOnce') + + cy.get('[data-testid=example-nav-video]').click() + + cy.get('@console.log') + .should('be.calledWith', 'page view: /video') + .should('be.calledTwice') + + cy.get('[data-testid=example-nav-home]').click() + + cy.get('@console.log').then(logSpy => { + expect(logSpy.args[0][0]).to.equal(logSpy.args[2][0]) + }) + cy.get('@console.log').should('be.calledThrice') + }) + + it('disables tracking when user revokes consent', () => { + cy.get('[data-testid="consent-manager-wrapping-component"]').should( + 'not.exist' + ) + + cy.toggleIntegration('Red Box Ltd.') + + cy.get('@console.log').should('be.calledOnce') + + cy.get('[data-testid=example-nav-video]').click() + + cy.get('@console.log') + .should('be.calledWith', 'page view: /video') + .should('be.calledTwice') + + cy.toggleIntegration('Red Box Ltd.') + + cy.get('@console.log').should('be.calledTwice') + + cy.get('[data-testid=example-nav-home]').click() + + cy.get('@console.log').should('be.calledTwice') + }) +}) diff --git a/cypress/integration/tracking.spec.ts b/cypress/integration/tracking.spec.ts deleted file mode 100644 index 7cb5ab20..00000000 --- a/cypress/integration/tracking.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -describe('pave view tracking', () => { - beforeEach(() => { - cy.visit('http://localhost:1234/', { - onBeforeLoad(win) { - cy.spy(win.console, 'log').as('console.log') - }, - }) - }) - - it('does not initialize tracking by default', () => { - cy.get('@console.log') - .should('not.be.calledWith', 'Initializing Red Box Ltd. tracking') - .should('not.be.calledWith', 'page view: /') - .should('not.be.called') - cy.window() - .its('rbltd') - .should('not.exist') - cy.get('[data-testid="consent-manager-wrapping-component"]').should( - 'not.exist' - ) - }) - - it('initializes tracking on user consent and logs initial page view', () => { - cy.toggleIntegration('Red Box Ltd.') - - cy.window() - .its('rbltd.trackEvent') - .should('exist') - cy.window() - .its('rbltd.trackPageView') - .should('exist') - - cy.get('[data-testid="consent-manager-wrapping-component"]').should('exist') - - cy.get('@console.log') - .should('be.calledWith', 'Initializing Red Box Ltd. tracking') - .should('be.calledWith', 'page view: /') - .should('not.be.calledWith', 'page view: /video') - .should('be.calledTwice') - }) - - it('tracks page views when route changes to video and back', () => { - cy.toggleIntegration('Red Box Ltd.') - - cy.get('@console.log') - .should('be.calledWith', 'Initializing Red Box Ltd. tracking') - .should('be.calledWith', 'page view: /') - .should('be.calledTwice') - - cy.get('[data-testid=example-nav-video]').click() - - cy.get('@console.log') - .should('be.calledWith', 'page view: /video') - .should('be.calledThrice') - - cy.get('[data-testid=example-nav-home]').click() - - cy.get('@console.log').then(logSpy => { - expect(logSpy.args[1][0]).to.equal(logSpy.args[3][0]) - expect(logSpy).to.have.callCount(4) - }) - }) - - it('disables tracking when user revokes consent', () => { - cy.get('[data-testid="consent-manager-wrapping-component"]').should( - 'not.exist' - ) - - cy.toggleIntegration('Red Box Ltd.') - - cy.get('[data-testid="consent-manager-wrapping-component"]').should('exist') - - cy.get('@console.log').should('be.calledTwice') - - cy.get('[data-testid=example-nav-video]').click() - - cy.get('[data-testid="consent-manager-wrapping-component"]').should('exist') - cy.get('@console.log') - .should('be.calledWith', 'page view: /video') - .should('be.calledThrice') - - cy.toggleIntegration('Red Box Ltd.') - - cy.get('@console.log').should('be.calledThrice') - cy.get('[data-testid="consent-manager-wrapping-component"]').should( - 'not.exist' - ) - - cy.get('[data-testid=example-nav-home]').click() - - cy.get('@console.log').should('be.calledThrice') - cy.get('[data-testid="consent-manager-wrapping-component"]').should( - 'not.exist' - ) - }) -}) - -describe('custom event tracking', () => { - beforeEach(() => { - cy.visit('http://localhost:1234/', { - onBeforeLoad(win) { - cy.spy(win.console, 'log').as('console.log') - cy.spy(win, 'alert').as('alert') - }, - }) - }) - - it('enables event tracking on on user consent', () => { - cy.get('[data-testid=example-button]').should( - 'contain', - 'Do not click here - nothing will happen' - ) - - // wait one tick to mount wrapper components - // @todo consider if we really need to wrap the whole thing on client again! - cy.wait(0) - - cy.get('@console.log').should('not.be.called') - - cy.get('[data-testid=example-button]').click() - - cy.get('@console.log').should('not.be.called') - cy.get('@alert').should('not.be.called') - - cy.toggleIntegration('Red Box Ltd.') - - cy.get('[data-testid=example-button]').should( - 'contain', - 'Do not click here - we will track you' - ) - - cy.get('@console.log').should('be.calledTwice') - - cy.get('[data-testid=example-button]').click() - - cy.get('@console.log') - .should('be.calledThrice') - .should('be.calledWith', 'custom event tracked') - - cy.get('@alert') - .should('be.calledOnce') - .should('be.calledWith', 'told ya! click watching you closely') - - cy.toggleIntegration('Red Box Ltd.') - - cy.get('[data-testid=example-button]').click() - - cy.get('@alert').should('be.calledOnce') - cy.get('@console.log').should('be.calledThrice') - cy.get('[data-testid=example-button]').should( - 'contain', - 'Do not click here - nothing will happen' - ) - }) -}) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 5fc76392..c2c5163f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -25,7 +25,7 @@ // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) Cypress.Commands.add('toggleIntegration', integrationLabel => { - // wait one tick to mount wrapper components + // wait one tick to execute script injectors // @todo consider if we really need to wrap the whole thing on client again! cy.wait(0) diff --git a/example/index.html b/example/index.html index 547e2e04..42cc92c1 100644 --- a/example/index.html +++ b/example/index.html @@ -4,7 +4,7 @@ - Playground + Consent Manager Example diff --git a/example/index.tsx b/example/index.tsx index 076f08fe..f4db6587 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -17,26 +17,30 @@ import { import RouteHome from './routes/home' import RouteVideo from './routes/video' -import { createVideoIncIntegration } from './integrations/social-video-inc' +import { videoIncIntegration } from './integrations/social-video-inc' +import { innocentPixelIntegration } from './integrations/tracker-innocent-pixel' import { - createRedBoxLtdIntegration, + redBoxLtdIntegration, useRedBoxLtd, } from './integrations/tracker-red-box-ltd' const consentManagerConfig: ConsentManagerConfig = { - integrations: [createVideoIncIntegration(), createRedBoxLtdIntegration()], + integrations: [ + videoIncIntegration(), + redBoxLtdIntegration(), + innocentPixelIntegration(), + ], } const PageViewTracker: React.FC = () => { const location = useLocation() - const { trackPageView } = useRedBoxLtd() + const redBoxLtdTracker = useRedBoxLtd() React.useEffect(() => { - // @todo find proper solution to ensure page view is tracked after tracker api is initialized window.setTimeout(() => { - trackPageView(location) + redBoxLtdTracker && redBoxLtdTracker.push(['page', location.pathname]) }, 0) - }, [location, trackPageView]) + }, [location, redBoxLtdTracker]) return null } diff --git a/example/integrations/social-video-inc.tsx b/example/integrations/social-video-inc.tsx index 8e3b77cc..ca47f64f 100644 --- a/example/integrations/social-video-inc.tsx +++ b/example/integrations/social-video-inc.tsx @@ -19,7 +19,7 @@ const Icon: React.FC = () => ( ) -export function createVideoIncIntegration(): IntegrationConfig { +export function videoIncIntegration(): IntegrationConfig { return { id: 'video-platform', title: 'Video Inc.', diff --git a/example/integrations/tracker-innocent-pixel.tsx b/example/integrations/tracker-innocent-pixel.tsx new file mode 100644 index 00000000..54b2cc5f --- /dev/null +++ b/example/integrations/tracker-innocent-pixel.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' + +import { IntegrationConfig } from '@consent-manager/core' + +const Icon: React.FC = () => ( + + + +) + +const ScriptInjector: React.FC = () => { + return ( + In this case, this actually does nothing + ) +} + +export function innocentPixelIntegration(): IntegrationConfig { + return { + id: 'innocent-pixel', + title: 'Innocent Pixel', + category: 'statistics', + description: 'Example integration that injects a pixel tracking technique.', + color: '#2d4876', + contrastColor: '#fff', + Icon, + ScriptInjector, + } +} diff --git a/example/integrations/tracker-red-box-ltd.tsx b/example/integrations/tracker-red-box-ltd.tsx index a82b8f8a..590f58c1 100644 --- a/example/integrations/tracker-red-box-ltd.tsx +++ b/example/integrations/tracker-red-box-ltd.tsx @@ -1,31 +1,22 @@ import * as React from 'react' -import { IntegrationConfig, useDecision, Tracker } from '@consent-manager/core' +import { + IntegrationConfig, + useDecision, + Tracker, + useScript, + locateTracker, +} from '@consent-manager/core' +import { useEffect } from 'react' declare global { interface Window { - rbltd?: RedBoxLtdWindow - } -} - -interface RedBoxLtdWindow extends Tracker {} - -const createRedBoxTracker = () => { - console.log('Initializing Red Box Ltd. tracking') - window.rbltd = { - trackEvent: (...data) => { - console.log('custom event tracked', data) - alert(['told ya!', ...data].join(' ')) - }, - trackPageView: (location: Location) => { - console.log(`page view: ${location.pathname}`, location) - }, + rbltd?: RedBoxLtdTracker } } const Icon: React.FC = () => ( ( ) -// ensure that the tracker script will be initialized once in runtime -let wasInitialized = false +const ScriptInjector: React.FC = () => { + useEffect(() => { + window.rbltd = window.rbltd || [] -const WrapperComponent: React.FC = ({ children }) => { - React.useEffect(() => { - if (!wasInitialized) { - createRedBoxTracker() - wasInitialized = true + return () => { + delete window.rbltd } - }, []) + }) + + useScript('/red-box-ltd.js', { id: 'red-box-ltd' }) - return ( -
- {children} -
- ) + return null } -export function useRedBoxLtd(): Tracker { +interface RedBoxLtdTracker extends Array>, Tracker {} + +// For usage non-react context when tracking page views (Docusaurus, Gatsby, ...) +// @todo we need to save-guard this by checking if integration is actually enabled +export function getRedBoxLtd() { + return window.rbltd +} + +export function useRedBoxLtd(): RedBoxLtdTracker | null { const [isEnabled] = useDecision('red-box-ltd') + const [tracker, setTracker] = React.useState(null) - const redBoxLtdInterface = React.useMemo(() => { - if (!isEnabled) { - return { - trackEvent: () => {}, - trackPageView: () => {}, - } + React.useEffect(() => { + if (isEnabled && !tracker) { + locateTracker('rbltd', setTracker) } + }, [isEnabled, setTracker, tracker]) - return { - trackEvent: (...args) => - window.rbltd && - window.rbltd.trackEvent && - window.rbltd.trackEvent(...args), - trackPageView: (...args) => - window.rbltd && - window.rbltd.trackPageView && - window.rbltd.trackPageView(...args), - } - }, [isEnabled]) + if (!isEnabled) { + return null + } - return redBoxLtdInterface + return tracker } -export function createRedBoxLtdIntegration(): IntegrationConfig { +export function redBoxLtdIntegration(): IntegrationConfig { return { id: 'red-box-ltd', title: 'Red Box Ltd.', category: 'statistics', description: - 'Adds red borders around your content, demonstrates use of components that do e.g. click tracking', + 'Example integration that injects scripts to demonstrate click and page tracking', color: '#C21515', contrastColor: '#fff', Icon, - WrapperComponent, + ScriptInjector, } } diff --git a/example/package.json b/example/package.json index e7e6e06a..d65823d9 100644 --- a/example/package.json +++ b/example/package.json @@ -17,6 +17,7 @@ "@types/react": "^16.9.11", "@types/react-dom": "^16.8.4", "parcel": "1.12.3", + "parcel-plugin-static-files-copy": "^2.6.0", "typescript": "^3.4.5" }, "alias": { diff --git a/example/routes/home.tsx b/example/routes/home.tsx index 42415c02..de6fdddf 100644 --- a/example/routes/home.tsx +++ b/example/routes/home.tsx @@ -3,7 +3,7 @@ import { useRedBoxLtd } from '../integrations/tracker-red-box-ltd' import { useDecision } from '@consent-manager/core' export default function RouteHome() { - const { trackEvent } = useRedBoxLtd() + const redBoxLtdTracker = useRedBoxLtd() const [isEnabled] = useDecision('red-box-ltd') return ( @@ -18,7 +18,16 @@ export default function RouteHome() { action.