import { parse } from '@babel/parser'
import { FileType, ProjectPluginStructure } from '@teleporthq/teleport-types'
import { NextAnalyticsProjectPlugin } from '../src/analytics/project-plugin'
const APP_CONTENT = `import { GlobalProvider } from '../global-context'
const MyApp = ({ Component, pageProps }) => {
return (
)
}
export default MyApp
`
// The script assets the GUI's project mapper injects from a merchant's own
// Google ids. Either one makes `window.dataLayer` a real array on the page.
const GTAG_ASSET = {
type: 'script',
path: 'https://www.googletagmanager.com/gtag/js?id=G-ABC123',
options: { target: 'body', async: true },
}
const GTM_ASSET = {
type: 'script',
content:
"(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime()});})(window,document,'script','dataLayer','GTM-XYZ')",
options: { target: 'body' },
}
function makeStructure(analyticsEnabled: boolean, assets: unknown[] = []): ProjectPluginStructure {
const files = new Map()
files.set('_app', {
path: ['pages'],
files: [{ name: '_app', fileType: FileType.JS, content: APP_CONTENT }],
})
return {
uidl: {
name: 'test-project',
globals: { settings: { title: 'Test', language: 'en' }, assets },
root: {} as never,
...(analyticsEnabled ? { analytics: { enabled: true } } : {}),
},
files,
dependencies: {},
devDependencies: {},
template: { files: [], subFolders: [] },
} as unknown as ProjectPluginStructure
}
describe('NextAnalyticsProjectPlugin', () => {
it('does nothing when analytics is not enabled', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(false)
await plugin.runAfter(structure)
expect(structure.files.has('teleport-analytics-lib')).toBe(false)
expect(structure.files.has('teleport-analytics-tracker')).toBe(false)
const appFile = structure.files.get('_app').files[0]
expect(appFile.content).not.toContain('AnalyticsTracker')
})
it('⛔ still emits it when the merchant has GTM but our analytics is off', async () => {
// The commerce funnel is fired from serialized workflow handlers that can
// only reach a global, and this component is what publishes
// `window.tpTrackCommerce`. Without it a store with a Tag Manager container
// records no add_to_cart, begin_checkout or purchase at all.
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(false, [GTM_ASSET])
await plugin.runAfter(structure)
expect(structure.files.has('teleport-analytics-tracker')).toBe(true)
expect(structure.files.get('_app').files[0].content).toContain('AnalyticsTracker')
})
it('does the same for a plain gtag tag', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(false, [GTAG_ASSET])
await plugin.runAfter(structure)
expect(structure.files.has('teleport-analytics-tracker')).toBe(true)
})
it('⛔ writes NO analytics env placeholders on the GTM-only branch', async () => {
// They would be resolved against project secrets at deploy time, which is
// how a store with analytics switched OFF would quietly start reporting.
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(false, [GTM_ASSET])
await plugin.runAfter(structure)
const env = (structure.uidl.globals as { env?: Record }).env ?? {}
expect(env.NEXT_PUBLIC_TELEPORT_ANALYTICS_URL).toBeUndefined()
expect(env.NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY).toBeUndefined()
})
it('ignores an unrelated third-party script', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(false, [
{ type: 'script', path: 'https://example.com/widget.js', options: { target: 'body' } },
])
await plugin.runAfter(structure)
expect(structure.files.has('teleport-analytics-tracker')).toBe(false)
})
it('emits the tracker lib + component and wires the env placeholders', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(true)
await plugin.runAfter(structure)
const lib = structure.files.get('teleport-analytics-lib')
expect(lib.path).toEqual(['lib'])
expect(lib.files[0].name).toBe('teleport-analytics')
expect(lib.files[0].content).toContain('NEXT_PUBLIC_TELEPORT_ANALYTICS_URL')
expect(lib.files[0].content).toContain('initTeleportAnalytics')
expect(lib.files[0].content).toContain('sendBeacon')
expect(lib.files[0].content).toContain("localStorage.getItem('cookieConsent')")
// Beacons/batches must use the CORS-safelisted text/plain content type so
// unload sends are preflight-free and survive a closing tab.
expect(lib.files[0].content).toContain('text/plain;charset=UTF-8')
expect(lib.files[0].content).not.toContain("type: 'application/json'")
expect(lib.files[0].content).not.toContain("'Content-Type': 'application/json'")
// Fail safe: if the deploy leaves an unresolved placeholder / non-absolute
// URL in the build-time vars, the tracker must self-disable rather than
// beacon the host site's own origin.
expect(lib.files[0].content).toContain("SERVER_URL.indexOf('http') !== 0")
expect(lib.files[0].content).toContain("PUBLIC_KEY.indexOf('teleporthq.secrets.') === 0")
// Heartbeat cadence is 30s (kept in sync with the analytics-worker's 90s
// realtime window). Don't regress to a chattier interval.
expect(lib.files[0].content).toContain('HEARTBEAT_INTERVAL_MS = 30000')
const tracker = structure.files.get('teleport-analytics-tracker')
expect(tracker.path).toEqual(['components', 'analytics'])
expect(tracker.files[0].content).toContain('routeChangeComplete')
expect(structure.uidl.globals.env).toEqual({
NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'teleporthq.secrets.NEXT_PUBLIC_TELEPORT_ANALYTICS_URL',
NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'teleporthq.secrets.NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY',
})
})
it('injects into _app as a fragment sibling', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(true)
await plugin.runAfter(structure)
const appFile = structure.files.get('_app').files[0]
expect(appFile.content).toContain(
"import AnalyticsTracker from '../components/analytics/AnalyticsTracker'"
)
expect(appFile.content).toContain('>')
expect(appFile.content).toContain('<>')
// Idempotent: a second run must not double-inject
await plugin.runAfter(structure)
const occurrences = appFile.content.split('').length - 1
expect(occurrences).toBe(1)
})
it('produces a still-parseable _app after the string surgery', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(true)
await plugin.runAfter(structure)
const appFile = structure.files.get('_app').files[0]
// The fragment wrap is raw string manipulation — guarantee it never emits
// invalid JSX that would silently blank the deployed app.
expect(() =>
parse(appFile.content, {
sourceType: 'module',
plugins: ['jsx'],
})
).not.toThrow()
})
it('preserves env values already set by the GUI mapper', async () => {
const plugin = new NextAnalyticsProjectPlugin()
const structure = makeStructure(true)
structure.uidl.globals.env = {
NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'teleporthq.secrets.NEXT_PUBLIC_TELEPORT_ANALYTICS_URL',
NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'teleporthq.secrets.NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY',
OTHER_KEY: 'value',
}
await plugin.runAfter(structure)
expect(Object.keys(structure.uidl.globals.env)).toHaveLength(3)
expect(structure.uidl.globals.env.OTHER_KEY).toBe('value')
})
})