Skip to content

Commit 179104c

Browse files
bdibonclaude
andcommitted
✅ Add a pre-init script injection point to E2E page setups
The page setup built the SDK bundle load and the init() call together, with no way to run application script in between. That window — SDK loaded and instrumenting, but not configured yet — is exactly what the buffered data sources serve, and it was untestable end to end. `createTest().withPreInitScript(js)` now emits that script as `window.DD_PRE_INIT`, called from the init site of the bundle, npm and async setups. The script may return a promise, in which case init() waits for it to settle, so an asynchronous exchange can complete entirely before the SDK starts. The bundle setup now emits every SDK script tag before any init call, so the pre-init script runs with all of them instrumenting the page. Setups that don't own their init call site reject the option rather than silently ignoring it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0386e91 commit 179104c

4 files changed

Lines changed: 134 additions & 19 deletions

File tree

test/apps/vanilla/app.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ declare global {
77
LOGS_INIT?: () => void
88
RUM_INIT?: () => void
99
DEBUGGER_INIT?: () => void
10+
DD_PRE_INIT?: () => unknown
1011
}
1112
}
1213

13-
if (typeof window !== 'undefined') {
14+
function runInits() {
1415
if (window.LOGS_INIT) {
1516
window.LOGS_INIT()
1617
}
@@ -22,6 +23,16 @@ if (typeof window !== 'undefined') {
2223
if (window.DEBUGGER_INIT) {
2324
window.DEBUGGER_INIT()
2425
}
26+
}
27+
28+
if (typeof window !== 'undefined') {
29+
if (window.DD_PRE_INIT) {
30+
// Application code running once the SDK modules are evaluated — and therefore instrumenting
31+
// the page — but before init(). It may return a promise to hold init() back until it settles.
32+
void Promise.resolve(window.DD_PRE_INIT()).then(runInits)
33+
} else {
34+
runInits()
35+
}
2536
} else {
2637
// compat test
2738
datadogLogs.init({ clientToken: 'xxx', beforeSend: undefined })

test/e2e/AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ test.describe('feature name', () => {
125125

126126
- `.withHead(html)` - Add content to `<head>`
127127
- `.withBody(html)` - Add content to `<body>`
128+
- `.withPreInitScript(js)` - Run application code after the SDK loads but before `init()`, to test
129+
what the SDK buffers before being started. The script is a function body and may return a promise
130+
that `init()` waits on.
128131
- `.withReactApp(name)` - Use a React test app
129132
- `.withExtension(ext)` - Test with browser extension
130133

test/e2e/lib/framework/createTest.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class TestBuilder {
105105
private remoteConfiguration?: RemoteConfiguration = undefined
106106
private head = ''
107107
private body = ''
108+
private preInitScript = ''
108109
private baseUrlHooks: UrlHook[] = []
109110
private eventBridge: EventBridgeOptions | undefined
110111
private setups: Array<{ factory: SetupFactory; name?: string }> = DEFAULT_SETUPS
@@ -162,6 +163,24 @@ class TestBuilder {
162163
return this
163164
}
164165

166+
/**
167+
* Runs application code in the window between the SDK load and `init()`: the SDK is already
168+
* instrumenting the page, but is not configured yet. Useful to test what the SDK buffers before
169+
* being started.
170+
*
171+
* `script` is raw JavaScript, evaluated as a function body. It may return a promise, in which
172+
* case `init()` waits for it to settle — so an asynchronous exchange can complete entirely
173+
* before the SDK starts.
174+
*
175+
* Supported by the bundle, npm and async setups. With the async setup the script runs from the
176+
* first bundle to become ready, so a test configuring several products can't assume the others
177+
* are loaded yet.
178+
*/
179+
withPreInitScript(script: string) {
180+
this.preInitScript = script
181+
return this
182+
}
183+
165184
withEventBridge(options: EventBridgeOptions = {}) {
166185
this.eventBridge = options
167186
return this
@@ -294,6 +313,7 @@ class TestBuilder {
294313
const setupOptions: SetupOptions = {
295314
body: this.body,
296315
head: this.head,
316+
preInitScript: this.preInitScript,
297317
logs: this.logsConfiguration,
298318
rum: this.rumConfiguration,
299319
debugger: this.debuggerConfiguration,

test/e2e/lib/framework/pageSetups.ts

Lines changed: 99 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface SetupOptions {
2323
eventBridge?: EventBridgeOptions
2424
head?: string
2525
body?: string
26+
preInitScript?: string
2627
baseUrlHooks: UrlHook[]
2728
context: {
2829
run_id: string
@@ -70,6 +71,58 @@ export const DEFAULT_SETUPS =
7071
{ name: 'bundle', factory: bundleSetup },
7172
]
7273

74+
/**
75+
* Defines `window.DD_PRE_INIT`, the application script that runs once the SDK is loaded (and
76+
* therefore instrumenting the page) but before `init()` is called. The script body may return a
77+
* promise: `init()` then waits for it to settle, which lets a scenario complete asynchronous work
78+
* — a request, a WebSocket exchange — entirely within the pre-init window.
79+
*
80+
* Setups call it from their init site through {@link afterPreInitScript}, so the definition itself
81+
* can be emitted anywhere in the head. It runs at most once, on the first init site to reach it.
82+
*/
83+
function preInitDefinition(options: SetupOptions) {
84+
if (!options.preInitScript) {
85+
return ''
86+
}
87+
88+
return html`<script type="text/javascript">
89+
window.DD_PRE_INIT = (function () {
90+
var result
91+
var called = false
92+
return function () {
93+
if (!called) {
94+
called = true
95+
result = (function () {
96+
${options.preInitScript}
97+
})()
98+
}
99+
return result
100+
}
101+
})()
102+
</script>`
103+
}
104+
105+
/**
106+
* Setups that don't control the init call site can't offer the pre-init window. Fail loudly rather
107+
* than silently ignoring the script and letting the scenario pass for the wrong reason.
108+
*/
109+
function rejectPreInitScript(options: SetupOptions, setupName: string) {
110+
if (options.preInitScript) {
111+
throw new Error(`withPreInitScript() is not supported by the ${setupName} setup`)
112+
}
113+
}
114+
115+
/** Wraps init code so that it runs after the pre-init script (see {@link preInitDefinition}). */
116+
function afterPreInitScript(options: SetupOptions, initCode: string) {
117+
if (!options.preInitScript) {
118+
return initCode
119+
}
120+
121+
return js`Promise.resolve(window.DD_PRE_INIT()).then(function () {
122+
${initCode}
123+
})`
124+
}
125+
73126
export function asyncSetup(options: SetupOptions, servers: Servers) {
74127
let header = options.head || ''
75128
let footer = ''
@@ -82,6 +135,8 @@ export function asyncSetup(options: SetupOptions, servers: Servers) {
82135
header += setupExtension(options, servers)
83136
}
84137

138+
header += preInitDefinition(options)
139+
85140
function formatSnippet(url: string, globalName: string) {
86141
return `(function(h,o,u,n,d) {
87142
h=h[d]=h[d]||{q:[],onReady:function(c){h.q.push(c)}}
@@ -96,8 +151,11 @@ n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n)
96151
footer += html`<script>
97152
${formatSnippet(logsScriptUrl, 'DD_LOGS')}
98153
DD_LOGS.onReady(function () {
99-
DD_LOGS.setGlobalContext(${JSON.stringify(options.context)})
100-
;(${options.logsInit.toString()})(${formatConfiguration(options.logs, servers)})
154+
${afterPreInitScript(
155+
options,
156+
js`DD_LOGS.setGlobalContext(${JSON.stringify(options.context)})
157+
;(${options.logsInit.toString()})(${formatConfiguration(options.logs, servers)})`
158+
)}
101159
})
102160
</script>`
103161
}
@@ -106,8 +164,11 @@ n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n)
106164
footer += html`<script type="text/javascript">
107165
${formatSnippet(rumScriptUrl, 'DD_RUM')}
108166
DD_RUM.onReady(function () {
109-
DD_RUM.setGlobalContext(${JSON.stringify(options.context)})
110-
;(${options.rumInit.toString()})(${formatConfiguration(options.rum, servers)})
167+
${afterPreInitScript(
168+
options,
169+
js`DD_RUM.setGlobalContext(${JSON.stringify(options.context)})
170+
;(${options.rumInit.toString()})(${formatConfiguration(options.rum, servers)})`
171+
)}
111172
})
112173
</script>`
113174
}
@@ -141,31 +202,42 @@ export function bundleSetup(options: SetupOptions, servers: Servers) {
141202

142203
const { logsScriptUrl, rumScriptUrl, debuggerScriptUrl } = createCrossOriginScriptUrls(servers, options)
143204

205+
// Every SDK bundle is loaded before any init() call, so the pre-init script runs with all of
206+
// them instrumenting the page.
207+
let sdkScripts = ''
208+
let initScripts = ''
209+
144210
if (options.logs) {
145-
header += html`<script type="text/javascript" src="${logsScriptUrl}" crossorigin></script>`
146-
header += html`<script type="text/javascript">
147-
DD_LOGS.setGlobalContext(${JSON.stringify(options.context)})
148-
;(${options.logsInit.toString()})(${formatConfiguration(options.logs, servers)})
211+
sdkScripts += html`<script type="text/javascript" src="${logsScriptUrl}" crossorigin></script>`
212+
initScripts += html`<script type="text/javascript">
213+
${afterPreInitScript(
214+
options,
215+
js`DD_LOGS.setGlobalContext(${JSON.stringify(options.context)})
216+
;(${options.logsInit.toString()})(${formatConfiguration(options.logs, servers)})`
217+
)}
149218
</script>`
150219
}
151220

152221
if (options.rum) {
153-
header += html`<script type="text/javascript" src="${rumScriptUrl}" crossorigin></script>`
154-
header += html`<script type="text/javascript">
155-
DD_RUM.setGlobalContext(${JSON.stringify(options.context)})
156-
;(${options.rumInit.toString()})(${formatConfiguration(options.rum, servers)})
222+
sdkScripts += html`<script type="text/javascript" src="${rumScriptUrl}" crossorigin></script>`
223+
initScripts += html`<script type="text/javascript">
224+
${afterPreInitScript(
225+
options,
226+
js`DD_RUM.setGlobalContext(${JSON.stringify(options.context)})
227+
;(${options.rumInit.toString()})(${formatConfiguration(options.rum, servers)})`
228+
)}
157229
</script>`
158230
}
159231

160232
if (options.debugger) {
161-
header += html`
162-
<script type="text/javascript" src="${debuggerScriptUrl}"></script>
163-
<script type="text/javascript">
164-
DD_DEBUGGER.init(${formatConfiguration(options.debugger, servers)})
165-
</script>
166-
`
233+
sdkScripts += html`<script type="text/javascript" src="${debuggerScriptUrl}"></script>`
234+
initScripts += html`<script type="text/javascript">
235+
${afterPreInitScript(options, js`DD_DEBUGGER.init(${formatConfiguration(options.debugger, servers)})`)}
236+
</script>`
167237
}
168238

239+
header += preInitDefinition(options) + sdkScripts + initScripts
240+
169241
return basePage({
170242
header,
171243
body: options.body,
@@ -209,6 +281,9 @@ export function npmSetup(options: SetupOptions, servers: Servers) {
209281
</script>`
210282
}
211283

284+
// The app bundle imports the SDK and then calls the *_INIT globals, so it is the one calling
285+
// DD_PRE_INIT in between.
286+
header += preInitDefinition(options)
212287
header += html`<script type="text/javascript" src="./app.js"></script>`
213288

214289
return basePage({
@@ -218,6 +293,8 @@ export function npmSetup(options: SetupOptions, servers: Servers) {
218293
}
219294

220295
export function appSetup(options: SetupOptions, servers: Servers, appName: string) {
296+
rejectPreInitScript(options, 'app')
297+
221298
let header = options.head || ''
222299

223300
if (options.eventBridge) {
@@ -276,6 +353,8 @@ export function workerSetup(setupOptions: SetupOptions, servers: Servers) {
276353
}
277354

278355
export function microfrontendSetup(options: SetupOptions, servers: Servers) {
356+
rejectPreInitScript(options, 'microfrontend')
357+
279358
let header = options.head || ''
280359

281360
if (options.eventBridge) {
@@ -307,6 +386,8 @@ export function microfrontendSetup(options: SetupOptions, servers: Servers) {
307386
// Salesforce apps don't serve a locally-generated page body; this factory only drives the
308387
// page-side setup needed to init RUM on the remote Salesforce page.
309388
export async function salesforceSetup(options: SetupOptions, servers: Servers, page: Page): Promise<string> {
389+
rejectPreInitScript(options, 'salesforce')
390+
310391
const salesforceAppDirectory = options.salesforceApp === 'experience-cloud' ? 'sf-experience-app' : 'sf-lwc-app'
311392
const salesforceBundlePath = resolve(
312393
__dirname,

0 commit comments

Comments
 (0)