From 96cdd45758a4a4ca496e14675c5d1c49acf39372 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 13 May 2026 09:11:21 -0400 Subject: [PATCH 1/3] Traverse backend module graph for connection IDs --- ...t-connection-ids-from-module-graph.test.ts | 323 ++++++++++++++++++ ...xtract-connection-ids-from-module-graph.ts | 30 ++ packages/plugins/apps/src/index.test.ts | 162 +++++++-- .../vite/backend-connection-id-collector.ts | 32 ++ .../apps/src/vite/build-backend-functions.ts | 26 +- .../plugins/apps/src/vite/dev-server.test.ts | 108 +++++- packages/plugins/apps/src/vite/dev-server.ts | 26 +- packages/plugins/apps/src/vite/index.test.ts | 61 +++- packages/plugins/apps/src/vite/index.ts | 21 +- 9 files changed, 716 insertions(+), 73 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts create mode 100644 packages/plugins/apps/src/vite/backend-connection-id-collector.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts new file mode 100644 index 000000000..9002d90c5 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts @@ -0,0 +1,323 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Program } from 'estree'; +import { parseAst } from 'rollup/parseAst'; + +import { extractConnectionIdsFromModuleGraph } from './extract-connection-ids-from-module-graph'; +import { createParsedModuleRecord, type ParsedModuleRecord } from './module-graph'; + +const buildRoot = '/project'; +const entryId = '/project/src/backend/actions.backend.js'; + +function parse(code: string): Program { + return parseAst(code) as Program; +} + +function createRecord( + id: string, + code: string, + staticDependencies: string[] = [], +): ParsedModuleRecord { + const record = createParsedModuleRecord(id, buildRoot, parse(code), staticDependencies); + if (!record) { + throw new Error(`Expected ${id} to create a parsed module record`); + } + return record; +} + +function extract(records: ParsedModuleRecord[]): string[] { + return extractConnectionIdsFromModuleGraph( + entryId, + new Map(records.map((record) => [record.id, record])), + buildRoot, + ); +} + +describe('Backend Functions - extractConnectionIdsFromModuleGraph', () => { + test('Should return null when creating records for modules outside the backend graph', () => { + expect( + createParsedModuleRecord( + '/project/node_modules/package/index.js', + buildRoot, + parse('export const value = true;'), + ), + ).toBeNull(); + }); + + test('Should extract inline connection IDs from statically reachable helper modules', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + + export function getEcho() { + return request({ connectionId: 'conn-helper', inputs: {} }); + } + `, + ); + + expect(extract([entry, helper])).toEqual(['conn-helper']); + }); + + test('Should resolve same-module connection ID values inside reachable helpers', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + + const HTTP_CONNECTION_ID = 'conn-const'; + const CONNECTIONS = { HTTP: { PROD: 'conn-object' } }; + + export function getEcho() { + request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + request({ connectionId: CONNECTIONS.HTTP.PROD, inputs: {} }); + } + `, + ); + + expect(extract([entry, helper])).toEqual(['conn-const', 'conn-object']); + }); + + test('Should traverse named re-exports and export star declarations', () => { + const barrelId = '/project/src/backend/helpers/index.js'; + const namedId = '/project/src/backend/helpers/named.js'; + const starId = '/project/src/backend/helpers/star.js'; + const entry = createRecord( + entryId, + ` + import './helpers/index.js'; + + export function run() {} + `, + [barrelId], + ); + const barrel = createRecord( + barrelId, + ` + export { getNamed } from './named.js'; + export * from './star.js'; + `, + [namedId, starId], + ); + const named = createRecord( + namedId, + ` + import { request } from '@datadog/action-catalog/http/http'; + export function getNamed() { + return request({ connectionId: 'conn-named', inputs: {} }); + } + `, + ); + const star = createRecord( + starId, + ` + import { request } from '@datadog/action-catalog/http/http'; + export function getStar() { + return request({ connectionId: 'conn-star', inputs: {} }); + } + `, + ); + + expect(extract([entry, barrel, named, star])).toEqual(['conn-named', 'conn-star']); + }); + + test('Should ignore package imports while traversing collected records', () => { + const entry = createRecord( + entryId, + ` + import { helper } from 'some-package'; + + export function run() {} + `, + ['/project/node_modules/some-package/index.js'], + ); + + expect(extract([entry])).toEqual([]); + }); + + test.each([ + { description: 'outside buildRoot', resolvedId: '/external/helper.js' }, + { description: 'virtual modules', resolvedId: '\0virtual-helper.js' }, + { description: 'dist output', resolvedId: '/project/dist/helper.js' }, + { description: 'build output', resolvedId: '/project/build/helper.js' }, + { description: 'Vite cache output', resolvedId: '/project/.vite/helper.js' }, + ])('Should skip $description', ({ resolvedId }) => { + const entry = createRecord( + entryId, + ` + import './helper.js'; + + export function run() {} + `, + [resolvedId], + ); + + expect(extract([entry])).toEqual([]); + }); + + test('Should protect against local graph cycles', () => { + const aId = '/project/src/backend/a.js'; + const bId = '/project/src/backend/b.js'; + const entry = createRecord( + entryId, + ` + import './a.js'; + + export function run() {} + `, + [aId], + ); + const a = createRecord( + aId, + ` + import './b.js'; + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: 'conn-a', inputs: {} }); + `, + [bId], + ); + const b = createRecord( + bId, + ` + import './a.js'; + import { request } from '@datadog/action-catalog/http/http'; + request({ connectionId: 'conn-b', inputs: {} }); + `, + [aId], + ); + + expect(extract([entry, a, b])).toEqual(['conn-a', 'conn-b']); + }); + + test.each([ + { + description: 'dynamic local imports', + code: "import('./helper.js');", + message: 'dynamic-import ./helper.js', + }, + { + description: 'non-literal dynamic imports', + code: 'import(helperPath);', + message: 'dynamic-import non-literal dynamic import', + }, + { + description: 'local require calls', + code: "require('./helper.js');", + message: 'require ./helper.js', + }, + ])('Should fail closed for $description', ({ code, message }) => { + const entry = createRecord( + entryId, + ` + ${code} + + export function run() {} + `, + ); + + expect(() => extract([entry])).toThrow(message); + }); + + test('Should fail closed for uncollected local static imports', () => { + const missingId = '/project/src/backend/missing.js'; + const entry = createRecord( + entryId, + ` + import './missing.js'; + + export function run() {} + `, + [missingId], + ); + + expect(() => extract([entry])).toThrow(`uncollected local import ${missingId}`); + }); + + test('Should keep imported connection ID values unsupported in reachable helpers', () => { + const helperId = '/project/src/backend/helpers/http.js'; + const idsId = '/project/src/backend/helpers/ids.js'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http.js'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + import { HTTP_CONNECTION_ID } from './ids.js'; + + export function getEcho() { + return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + } + `, + [idsId], + ); + + expect(() => extract([entry, helper])).toThrow( + 'imported connectionId binding HTTP_CONNECTION_ID', + ); + }); + + test('Should read transformed local TypeScript helpers as collected records', () => { + const helperId = '/project/src/backend/helpers/http.ts'; + const entry = createRecord( + entryId, + ` + import { getEcho } from './helpers/http'; + + export function run() { + return getEcho(); + } + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + + const HTTP_CONNECTION_ID = 'conn-ts'; + + export function getEcho() { + request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + return { ok: true }; + } + `, + ); + + expect(extract([entry, helper])).toEqual(['conn-ts']); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts new file mode 100644 index 000000000..6c46c1ee9 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts @@ -0,0 +1,30 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { extractConnectionIds } from './extract-connection-ids'; +import type { ParsedModuleRecord } from './module-graph'; +import { walkModuleGraph } from './walk-module-graph'; + +/** + * Extracts the conservative backend-file connection ID union from module records + * collected while the backend bundler walked the real execution graph. + */ +export function extractConnectionIdsFromModuleGraph( + entryId: string, + modules: ReadonlyMap, + buildRoot: string, +): string[] { + const connectionIds = new Set(); + + walkModuleGraph(entryId, modules, buildRoot, ({ moduleId, record }) => { + // Resolve connection IDs while visiting the reachable graph so this + // step can later receive graph-aware value-resolution context. + const moduleConnectionIds = extractConnectionIds(record.ast, moduleId); + for (const connectionId of moduleConnectionIds) { + connectionIds.add(connectionId); + } + }); + + return [...connectionIds].sort(); +} diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index ee6230426..fb55b32a3 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -31,6 +31,28 @@ function extractCloseBundle(plugins: PluginOptions[]) { return plugin.vite!.closeBundle as () => Promise; } +/** Extract and assert the Vite transform hook from the first plugin's vite hooks. */ +function extractViteTransform(plugins: PluginOptions[]) { + const transform = plugins[0].vite?.transform; + expect(transform).toEqual(expect.objectContaining({ handler: expect.any(Function) })); + return (transform as { handler: (code: string, id: string) => Promise }).handler; +} + +function emitModuleParsed( + config: { plugins?: Array<{ moduleParsed?: (moduleInfo: unknown) => void }> }, + id: string, + code: string, + importedIds: string[] = [], +) { + for (const plugin of config.plugins ?? []) { + plugin.moduleParsed?.({ + id, + ast: parseAst(code), + importedIds, + }); + } +} + describe('Apps Plugin - getPlugins', () => { const buildRoot = '/project'; const outDir = '/project/dist'; @@ -235,37 +257,42 @@ describe('Apps Plugin - getPlugins', () => { }; }); - const viteBuild = jest.fn().mockResolvedValue({ - output: [ - { - type: 'chunk', - isEntry: true, - name: expect.any(String), - fileName: 'unused.greet.js', - }, - ], + const backendCode = ` + import { request } from '@datadog/action-catalog/http/http'; + + export function greet() { + request({ connectionId: 'conn-b', inputs: {} }); + } + + export function salute() { + request({ connectionId: 'conn-a', inputs: {} }); + } + `; + const viteBuild = jest.fn().mockImplementation(async (config) => { + emitModuleParsed(config, '/project/src/backend/greet.backend.js', backendCode); + return { + output: [ + { + type: 'chunk', + isEntry: true, + name: expect.any(String), + fileName: 'unused.greet.js', + }, + ], + }; }); const args = getArgs(); args.bundler = { build: viteBuild }; const plugins = getPlugins(args); - const transform = plugins[0].vite?.transform as { - handler: (code: string, id: string) => unknown; - }; - transform.handler.call( + const transform = extractViteTransform(plugins); + await transform.call( { parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), }, - ` - import { request } from '@datadog/action-catalog/http/http'; - - export function greet() { - request({ connectionId: 'conn-b', inputs: {} }); - } - - export function salute() { - request({ connectionId: 'conn-a', inputs: {} }); - } - `, + backendCode, '/project/src/backend/greet.backend.js', ); @@ -298,6 +325,93 @@ describe('Apps Plugin - getPlugins', () => { ]); }); + test('Should include reachable helper module connection allowlists in manifest.json', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ + identifier: 'repo:app', + name: 'test-app', + }); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, + ]); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [], + warnings: [], + }); + + let manifest: unknown; + jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => { + const manifestAsset = archiveAssets.find( + (asset) => asset.relativePath === 'manifest.json', + ); + expect(manifestAsset).toBeDefined(); + manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8')); + return { + archivePath: '/tmp/dd-apps-790/datadog-apps-assets.zip', + assets: archiveAssets, + size: 30, + }; + }); + + const entryCode = ` + import { getEcho } from './helpers/http.js'; + + export function greet() { + return getEcho(); + } + `; + const helperCode = ` + import { request } from '@datadog/action-catalog/http/http'; + + const HTTP_CONNECTION_ID = 'conn-helper'; + + export function getEcho() { + return request({ connectionId: HTTP_CONNECTION_ID, inputs: {} }); + } + `; + const helperId = '/project/src/backend/helpers/http.js'; + const viteBuild = jest.fn().mockImplementation(async (config) => { + emitModuleParsed(config, '/project/src/backend/greet.backend.js', entryCode, [ + helperId, + ]); + emitModuleParsed(config, helperId, helperCode); + return { + output: [ + { + type: 'chunk', + isEntry: true, + name: expect.any(String), + fileName: 'unused.greet.js', + }, + ], + }; + }); + const args = getArgs(); + args.bundler = { build: viteBuild }; + const plugins = getPlugins(args); + const transform = extractViteTransform(plugins); + await transform.call( + { + parse: parseAst, + resolve: jest.fn(async (specifier: string) => + specifier === './helpers/http.js' ? { id: helperId } : null, + ), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + entryCode, + '/project/src/backend/greet.backend.js', + ); + + await extractCloseBundle(plugins)(); + + expect( + Object.values( + (manifest as { backend: { functions: Record } }).backend.functions, + ), + ).toEqual([{ allowedConnectionIds: ['conn-helper'] }]); + }); + test('Should surface upload errors', async () => { jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ identifier: 'repo:app', diff --git a/packages/plugins/apps/src/vite/backend-connection-id-collector.ts b/packages/plugins/apps/src/vite/backend-connection-id-collector.ts new file mode 100644 index 000000000..9bdc7675d --- /dev/null +++ b/packages/plugins/apps/src/vite/backend-connection-id-collector.ts @@ -0,0 +1,32 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Plugin } from 'vite'; + +import { extractConnectionIdsFromModuleGraph } from '../backend/ast-parsing/extract-connection-ids-from-module-graph'; + +import { createBackendModuleGraphCollector } from './backend-module-graph-collector'; + +export interface BackendConnectionIdCollector { + plugin: Plugin; + getAllowedConnectionIds: () => string[]; +} + +export function createBackendConnectionIdCollector( + entryId: string, + buildRoot: string, +): BackendConnectionIdCollector { + const moduleGraphCollector = createBackendModuleGraphCollector(buildRoot); + + return { + plugin: moduleGraphCollector.plugin, + getAllowedConnectionIds() { + return extractConnectionIdsFromModuleGraph( + entryId, + moduleGraphCollector.getModuleRecords(), + buildRoot, + ); + }, + }; +} diff --git a/packages/plugins/apps/src/vite/build-backend-functions.ts b/packages/plugins/apps/src/vite/build-backend-functions.ts index 835bebf62..c11056f2a 100644 --- a/packages/plugins/apps/src/vite/build-backend-functions.ts +++ b/packages/plugins/apps/src/vite/build-backend-functions.ts @@ -12,7 +12,7 @@ import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; import { generateVirtualEntryContent } from '../backend/virtual-entry'; -import { createBackendModuleGraphCollector } from './backend-module-graph-collector'; +import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; const VIRTUAL_PREFIX = '\0dd-backend:'; @@ -29,9 +29,10 @@ export async function buildBackendFunctions( functions: BackendFunction[], buildRoot: string, log: Logger, -): Promise<{ outDir: string; outputs: Map }> { +): Promise<{ outDir: string; outputs: Map; functions: BackendFunction[] }> { const outDir = await mkdtemp(path.join(tmpdir(), 'dd-apps-backend-')); const outputs = new Map(); + const allowedConnectionIdsByEntryPath = new Map(); log.debug(`Building ${functions.length} backend function(s) via vite.build()`); @@ -41,10 +42,13 @@ export async function buildBackendFunctions( const bundleName = encodeQueryName(func); const virtualId = `${VIRTUAL_PREFIX}${bundleName}`; const virtualContent = generateVirtualEntryContent(func.name, func.absolutePath, buildRoot); - const moduleGraphCollector = createBackendModuleGraphCollector(buildRoot); + const connectionIdCollector = createBackendConnectionIdCollector( + func.absolutePath, + buildRoot, + ); const baseConfig = getBaseBackendBuildConfig(buildRoot, { [virtualId]: virtualContent }, [ - moduleGraphCollector.plugin, + connectionIdCollector.plugin, ]); // eslint-disable-next-line no-await-in-loop @@ -78,7 +82,19 @@ export async function buildBackendFunctions( log.debug(`Backend function "${bundleName}" output: ${absolutePath}`); } } + + allowedConnectionIdsByEntryPath.set( + func.absolutePath, + connectionIdCollector.getAllowedConnectionIds(), + ); } - return { outDir, outputs }; + return { + outDir, + outputs, + functions: functions.map((func) => ({ + ...func, + allowedConnectionIds: allowedConnectionIdsByEntryPath.get(func.absolutePath)!, + })), + }; } diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index b171c4b87..033c71eb3 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -7,6 +7,7 @@ import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import { EventEmitter } from 'events'; import type { IncomingMessage, ServerResponse } from 'http'; import nock from 'nock'; +import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; @@ -90,6 +91,34 @@ function mockBuildResult(code: string) { }; } +function emitModuleParsed( + config: { plugins?: Array<{ moduleParsed?: (moduleInfo: unknown) => void }> }, + id: string, + code: string, + importedIds: string[] = [], +) { + for (const plugin of config.plugins ?? []) { + plugin.moduleParsed?.({ + id, + ast: parseAst(code), + importedIds, + }); + } +} + +function mockBuildWithParsedBackend(code = '// code') { + mockViteBuild.mockImplementation(async (config) => { + for (const func of mockFunctions) { + emitModuleParsed( + config, + func.absolutePath, + `export function ${func.name}() { return null; }`, + ); + } + return mockBuildResult(code); + }); +} + describe('Dev Server Middleware', () => { afterEach(() => { nock.cleanAll(); @@ -128,7 +157,7 @@ describe('Dev Server Middleware', () => { }); test('Should handle /__dd/debugBundle POST', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// bundled code')); + mockBuildWithParsedBackend(); const req = createMockRequest('/__dd/debugBundle', { functionName: encodeQueryName(mockFunctions[0]), @@ -146,7 +175,7 @@ describe('Dev Server Middleware', () => { }); test('Should handle /__dd/executeAction POST', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// bundled code')); + mockBuildWithParsedBackend(); // Mock the Datadog API via nock. const apiScope = nock(DD_API_ORIGIN) @@ -216,7 +245,7 @@ describe('Dev Server Middleware', () => { }); test('Should return bundled code as text/plain', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('export function main($) {}')); + mockBuildWithParsedBackend('export function main($) {}'); const req = createMockRequest('/__dd/debugBundle', { functionName: encodeQueryName(mockFunctions[0]), @@ -232,7 +261,7 @@ describe('Dev Server Middleware', () => { }); test('Should call vite.build with configFile: false and write: false', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + mockBuildWithParsedBackend(); const req = createMockRequest('/__dd/debugBundle', { functionName: encodeQueryName(mockFunctions[0]), @@ -298,7 +327,7 @@ describe('Dev Server Middleware', () => { * unknown function). */ test('Should return 500 when Datadog API fails', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + mockBuildWithParsedBackend(); nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -320,7 +349,7 @@ describe('Dev Server Middleware', () => { }); test('Should call Datadog API with correct endpoint and return result', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + mockBuildWithParsedBackend(); type PreviewAsyncBody = { data: { @@ -369,8 +398,8 @@ describe('Dev Server Middleware', () => { ).toEqual([]); }); - test('Should forward the selected backend function allowedConnectionIds', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + test('Should use collector output instead of registered backend function allowedConnectionIds', async () => { + mockBuildWithParsedBackend(); const functionsWithAllowlist: BackendFunction[] = [ mockFunctions[0], @@ -423,11 +452,66 @@ describe('Dev Server Middleware', () => { expect(apiScope.isDone()).toBe(true); expect( capturedBody?.data.attributes.query.properties.spec.inputs.allowedConnectionIds, - ).toEqual(['conn-1', 'conn-2']); + ).toEqual([]); + }); + + test('Should compute allowedConnectionIds from the backend build collector', async () => { + mockViteBuild.mockImplementation(async (config) => { + emitModuleParsed( + config, + mockFunctions[0].absolutePath, + ` + import { request } from '@datadog/action-catalog/http/http'; + + export function greet() { + request({ connectionId: 'conn-build', inputs: {} }); + } + `, + ); + return mockBuildResult('// code'); + }); + + type PreviewAsyncBody = { + data: { + attributes: { + query: { + properties: { + spec: { inputs: { allowedConnectionIds: string[] } }; + }; + }; + }; + }; + }; + let capturedBody: PreviewAsyncBody | undefined; + const apiScope = nock(`https://${DD_SITE}`) + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedBody = body as PreviewAsyncBody; + return true; + }) + .reply(200, { data: { id: 'receipt-build-allowlist' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-build-allowlist') + .reply(200, { + data: { attributes: { done: true, outputs: { data: { ok: true } } } }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + expect(apiScope.isDone()).toBe(true); + expect( + capturedBody?.data.attributes.query.properties.spec.inputs.allowedConnectionIds, + ).toEqual(['conn-build']); }); test('Should handle errors array from long-polling endpoint', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + mockBuildWithParsedBackend(); nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -453,7 +537,7 @@ describe('Dev Server Middleware', () => { }); test('Should retry when long-poll returns done: false', async () => { - mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + mockBuildWithParsedBackend(); const apiScope = nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -516,7 +600,7 @@ describe('Dev Server Middleware', () => { expect(oldRes.statusCode).toBe(404); // New name should resolve. - mockViteBuild.mockResolvedValue(mockBuildResult('// greetV2 code')); + mockBuildWithParsedBackend('// greetV2 code'); const newReq = createMockRequest('/__dd/debugBundle', { functionName: encodeQueryName({ relativePath: 'backend/greet', name: 'greetV2' }), diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index c87453a2b..2151ac94f 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -15,10 +15,15 @@ import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/pro import type { BackendFunction } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; -import { createBackendModuleGraphCollector } from './backend-module-graph-collector'; +import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; -type BundleFn = (func: BackendFunction, args: unknown[]) => Promise; +interface BundleResult { + func: BackendFunction; + code: string; +} + +type BundleFn = (func: BackendFunction, args: unknown[]) => Promise; const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; @@ -66,7 +71,7 @@ async function bundleBackendFunction( args: unknown[], projectRoot: string, log: Logger, -): Promise { +): Promise { const displayName = formatRef(func); const virtualId = `${DEV_VIRTUAL_PREFIX}${displayName}`; const virtualContent = generateDevVirtualEntryContent( @@ -75,12 +80,15 @@ async function bundleBackendFunction( args, projectRoot, ); - const moduleGraphCollector = createBackendModuleGraphCollector(projectRoot); + const connectionIdCollector = createBackendConnectionIdCollector( + func.absolutePath, + projectRoot, + ); log.debug(`Bundling backend function "${displayName}" from ${func.absolutePath}`); const baseConfig = getBaseBackendBuildConfig(projectRoot, { [virtualId]: virtualContent }, [ - moduleGraphCollector.plugin, + connectionIdCollector.plugin, ]); // Dev: build a single function in-memory per request so we can send the @@ -107,10 +115,14 @@ async function bundleBackendFunction( } const code = output.output[0].type === 'chunk' ? output.output[0].code : ''; + const enrichedFunc = { + ...func, + allowedConnectionIds: connectionIdCollector.getAllowedConnectionIds(), + }; log.debug(`Bundled "${displayName}" (${code.length} bytes)`); - return code; + return { func: enrichedFunc, code }; } /** @@ -268,7 +280,7 @@ async function validateAndBundle( throw new HttpError(404, `Backend function "${functionName}" not found`); } - return { func, code: await bundle(func, args) }; + return bundle(func, args); } /** diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 523f3417a..3127fad85 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -5,6 +5,7 @@ import * as assets from '@dd/apps-plugin/assets'; import * as identifier from '@dd/apps-plugin/identifier'; import { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { InjectPosition } from '@dd/core/types'; import { getContextMock, getRepositoryDataMock } from '@dd/tests/_jest/helpers/mocks'; import { parseAst } from 'rollup/parseAst'; @@ -30,18 +31,52 @@ const functions: BackendFunction[] = [ const bundleName1 = encodeQueryName(functions[0]); const bundleName2 = encodeQueryName(functions[1]); -const mockViteBuild = jest.fn().mockResolvedValue({ - output: [ - { type: 'chunk', isEntry: true, name: bundleName1, fileName: `${bundleName1}.js` }, - { type: 'chunk', isEntry: true, name: bundleName2, fileName: `${bundleName2}.js` }, - ], -}); +const mockViteBuild = jest.fn(); +const mockVite = { + build: mockViteBuild, + transformWithEsbuild: jest.fn(), +} as unknown as ViteBundler; const mockInject = jest.fn(); +function mockBuildResult() { + return { + output: [ + { type: 'chunk', isEntry: true, name: bundleName1, fileName: `${bundleName1}.js` }, + { type: 'chunk', isEntry: true, name: bundleName2, fileName: `${bundleName2}.js` }, + ], + }; +} + +function emitModuleParsed( + config: { plugins?: Array<{ moduleParsed?: (moduleInfo: unknown) => void }> }, + id: string, + code: string, +) { + for (const plugin of config.plugins ?? []) { + plugin.moduleParsed?.({ + id, + ast: parseAst(code), + importedIds: [], + }); + } +} + +function mockBuildWithParsedBackend() { + mockViteBuild.mockImplementation(async (config) => { + emitModuleParsed( + config, + '/build/src/backend/myHandler.backend.ts', + ` + export function myHandler() {} + export function otherFunc() {} + `, + ); + return mockBuildResult(); + }); +} + const defaultOptions = { - bundler: { - build: mockViteBuild, - }, + bundler: mockVite, context: getContextMock({ buildRoot: '/build', bundler: { @@ -63,7 +98,8 @@ const defaultOptions = { describe('Backend Functions - getVitePlugin', () => { beforeEach(() => { jest.restoreAllMocks(); - mockViteBuild.mockClear(); + mockViteBuild.mockReset(); + mockBuildWithParsedBackend(); mockInject.mockClear(); jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ identifier: 'repo:app', @@ -85,9 +121,12 @@ describe('Backend Functions - getVitePlugin', () => { handler: (code: string, id: string) => unknown; }; - transform.handler.call( + await transform.handler.call( { parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), }, ` export function myHandler() {} diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index a52e86057..fbf2b9626 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -9,7 +9,6 @@ import path from 'path'; import type { build } from 'vite'; import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend-functions'; -import { extractConnectionIds } from '../backend/ast-parsing/extract-connection-ids'; import { encodeQueryName } from '../backend/encodeQueryName'; import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; @@ -38,7 +37,6 @@ function buildProxyModule( exportNames: string[], id: string, buildRoot: string, - allowedConnectionIds: string[], ): { functions: BackendFunction[]; proxyCode: string } { const relativePath = path.relative(buildRoot, id); const refPath = relativePath.replace(BACKEND_FILE_RE, ''); @@ -51,7 +49,7 @@ function buildProxyModule( relativePath: refPath, name: exportName, absolutePath: id, - allowedConnectionIds, + allowedConnectionIds: [], }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); @@ -135,13 +133,7 @@ export const getVitePlugin = ({ return { code: '', map: null }; } - const allowedConnectionIds = extractConnectionIds(ast, id); - const { functions, proxyCode } = buildProxyModule( - exportNames, - id, - buildRoot, - allowedConnectionIds, - ); + const { functions, proxyCode } = buildProxyModule(exportNames, id, buildRoot); setBackendFunctions(id, functions); log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`); @@ -151,21 +143,22 @@ export const getVitePlugin = ({ async closeBundle() { let backendOutDir: string | undefined; let backendOutputs = new Map(); - const functions = getBackendFunctions(); - if (functions.length > 0) { + let backendFunctions = getBackendFunctions(); + if (backendFunctions.length > 0) { const result = await buildBackendFunctions( bundler.build, - functions, + backendFunctions, buildRoot, log, ); backendOutDir = result.outDir; backendOutputs = result.outputs; + backendFunctions = result.functions; } try { await handleUpload({ backendOutputs, - backendFunctions: getBackendFunctions(), + backendFunctions, context, options, }); From c2cc6afffcf16ce2fc68555e65e8fbf4fa78080c Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 13 May 2026 16:33:07 -0400 Subject: [PATCH 2/3] Address module graph extraction review feedback --- ...t-connection-ids-from-module-graph.test.ts | 36 +++++++++++++++++-- ...xtract-connection-ids-from-module-graph.ts | 5 +-- packages/plugins/apps/src/vite/index.test.ts | 3 +- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts index 9002d90c5..040f1ee48 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts @@ -164,9 +164,12 @@ describe('Backend Functions - extractConnectionIdsFromModuleGraph', () => { test.each([ { description: 'outside buildRoot', resolvedId: '/external/helper.js' }, { description: 'virtual modules', resolvedId: '\0virtual-helper.js' }, - { description: 'dist output', resolvedId: '/project/dist/helper.js' }, - { description: 'build output', resolvedId: '/project/build/helper.js' }, - { description: 'Vite cache output', resolvedId: '/project/.vite/helper.js' }, + { description: 'package modules', resolvedId: '/project/node_modules/package/index.js' }, + { + description: 'Yarn package cache modules', + resolvedId: '/project/.yarn/cache/package/index.js', + }, + { description: 'non-JavaScript files', resolvedId: '/project/src/backend/data.json' }, ])('Should skip $description', ({ resolvedId }) => { const entry = createRecord( entryId, @@ -181,6 +184,33 @@ describe('Backend Functions - extractConnectionIdsFromModuleGraph', () => { expect(extract([entry])).toEqual([]); }); + test.each([ + { folder: 'dist', connectionId: 'conn-dist' }, + { folder: 'build', connectionId: 'conn-build' }, + { folder: '.vite', connectionId: 'conn-vite' }, + ])('Should traverse supported app-local folder name $folder', ({ folder, connectionId }) => { + const helperId = `/project/${folder}/helper.js`; + const entry = createRecord( + entryId, + ` + import '../${folder}/helper.js'; + + export function run() {} + `, + [helperId], + ); + const helper = createRecord( + helperId, + ` + import { request } from '@datadog/action-catalog/http/http'; + + request({ connectionId: '${connectionId}', inputs: {} }); + `, + ); + + expect(extract([entry, helper])).toEqual([connectionId]); + }); + test('Should protect against local graph cycles', () => { const aId = '/project/src/backend/a.js'; const bId = '/project/src/backend/b.js'; diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts index 6c46c1ee9..00adee490 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts @@ -17,9 +17,10 @@ export function extractConnectionIdsFromModuleGraph( ): string[] { const connectionIds = new Set(); + // Walk the already-parsed records from this backend entry's build. The + // extraction cost is linear in reachable app-local modules, without + // reparsing source files here. walkModuleGraph(entryId, modules, buildRoot, ({ moduleId, record }) => { - // Resolve connection IDs while visiting the reachable graph so this - // step can later receive graph-aware value-resolution context. const moduleConnectionIds = extractConnectionIds(record.ast, moduleId); for (const connectionId of moduleConnectionIds) { connectionIds.add(connectionId); diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 3127fad85..d53ed20c1 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -98,9 +98,8 @@ const defaultOptions = { describe('Backend Functions - getVitePlugin', () => { beforeEach(() => { jest.restoreAllMocks(); - mockViteBuild.mockReset(); + jest.clearAllMocks(); mockBuildWithParsedBackend(); - mockInject.mockClear(); jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ identifier: 'repo:app', name: 'test-app', From 938eea6fade41d61626917d1c98e4b61072539a8 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 13 May 2026 16:48:18 -0400 Subject: [PATCH 3/3] Use structured module graph dependencies --- .../backend/ast-parsing/module-graph.test.ts | 39 ++++++++++++++++++- .../src/backend/ast-parsing/module-graph.ts | 37 +++++++++++++++++- .../ast-parsing/walk-module-graph.test.ts | 8 +++- .../backend/ast-parsing/walk-module-graph.ts | 3 +- .../backend-module-graph-collector.test.ts | 11 +++++- .../vite/backend-module-graph-collector.ts | 6 ++- .../plugins/apps/src/vite/dev-server.test.ts | 2 +- 7 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts index f62114795..aaee20224 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts @@ -24,12 +24,49 @@ describe('Backend Functions - module graph records', () => { expect(record).toMatchObject({ id: '/project/src/backend/actions.backend.js', - staticDependencies: ['/project/src/backend/helpers/http.js'], + staticDependencies: [ + { + source: './helpers/http.js', + resolvedId: '/project/src/backend/helpers/http.js', + }, + ], unsupportedDependencies: [], }); expect(record?.ast.type).toBe('Program'); }); + test('Should pair resolved dependency IDs with static import and export sources', () => { + const record = createParsedModuleRecord( + '/project/src/backend/actions.backend.js', + buildRoot, + parseAst(` + import { getEcho } from './helpers/http.js'; + export { CONNECTION_ID } from './connections.js'; + export * from './shared.js'; + `), + [ + '/project/src/backend/helpers/http.js', + '/project/src/backend/connections.js', + '/project/src/backend/shared.js', + ], + ); + + expect(record?.staticDependencies).toEqual([ + { + source: './helpers/http.js', + resolvedId: '/project/src/backend/helpers/http.js', + }, + { + source: './connections.js', + resolvedId: '/project/src/backend/connections.js', + }, + { + source: './shared.js', + resolvedId: '/project/src/backend/shared.js', + }, + ]); + }); + test.each([ { description: 'package modules', id: '/project/node_modules/package/index.js' }, { description: 'Yarn package cache modules', id: '/project/.yarn/cache/package/index.js' }, diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts index 1b4df6738..9269bcb05 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -13,10 +13,15 @@ import { walkAst } from './walk-ast'; export interface ParsedModuleRecord { id: string; ast: Program; - staticDependencies: string[]; + staticDependencies: StaticModuleDependency[]; unsupportedDependencies: ModuleDependency[]; } +export interface StaticModuleDependency { + source: string; + resolvedId: string; +} + export interface ModuleDependency { specifier: string; kind: 'dynamic-import' | 'require'; @@ -50,11 +55,39 @@ export function createParsedModuleRecord( return { id: moduleId, ast: program, - staticDependencies, + staticDependencies: collectStaticModuleDependencies(program, staticDependencies), unsupportedDependencies: collectUnsupportedModuleDependencies(program), }; } +function collectStaticModuleDependencies( + ast: Program, + staticDependencyIds: string[], +): StaticModuleDependency[] { + const staticModuleSources = getStaticModuleSources(ast); + + return staticDependencyIds.map((resolvedId, index) => ({ + source: staticModuleSources[index] ?? resolvedId, + resolvedId, + })); +} + +function getStaticModuleSources(ast: Program): string[] { + return ast.body.flatMap((node) => { + if ( + (node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration') && + node.source && + isStringLiteral(node.source) + ) { + return [node.source.value]; + } + + return []; + }); +} + /** * Finds dependency forms that cannot be represented by the static dependency * IDs supplied by the backend build collector. diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts index e120f4991..1e941baea 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts @@ -4,7 +4,7 @@ import { parseAst } from 'rollup/parseAst'; -import type { ModuleDependency, ParsedModuleRecord } from './module-graph'; +import type { ModuleDependency, ParsedModuleRecord, StaticModuleDependency } from './module-graph'; import { ensureProgram } from './type-guards'; import { walkModuleGraph } from './walk-module-graph'; @@ -18,11 +18,15 @@ function createRecord( return { id, ast: ensureProgram(parseAst('export const value = true;'), id), - staticDependencies, + staticDependencies: staticDependencies.map(toStaticDependency), unsupportedDependencies, }; } +function toStaticDependency(resolvedId: string): StaticModuleDependency { + return { source: resolvedId, resolvedId }; +} + describe('Backend Functions - module graph walk', () => { test('Should visit each reachable local module once', () => { const modules = new Map([ diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts index c3f66114a..7c74fc484 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts @@ -66,7 +66,8 @@ export function walkModuleGraph( // Follow only collected local source modules. Package imports, virtual // entries, generated files, and files outside buildRoot are ignored by // design because they are outside the app-local backend graph. - for (const dependencyId of record.staticDependencies) { + for (const dependency of record.staticDependencies) { + const dependencyId = dependency.resolvedId; if (!shouldTraverseCollectedModule(dependencyId, buildRoot)) { continue; } diff --git a/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts b/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts index f8f9ef823..2d984175a 100644 --- a/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts +++ b/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts @@ -20,21 +20,25 @@ describe('Backend Functions - backend module graph collector', () => { } `), importedIds: ['/project/src/backend/helpers/http.js?import'], + importedIdResolutions: [{ id: '/project/src/backend/helpers/http.js?import' }], }); moduleParsed({ id: '/project/node_modules/package/index.js', ast: parseAst('export const value = true;'), importedIds: [], + importedIdResolutions: [], }); moduleParsed({ id: '\0virtual-helper.js', ast: parseAst('export const value = true;'), importedIds: [], + importedIdResolutions: [], }); moduleParsed({ id: 'virtual:dd-backend-dev:example.js', ast: parseAst('export const value = true;'), importedIds: [], + importedIdResolutions: [], }); expect([...collector.getModuleRecords().keys()]).toEqual([ @@ -43,7 +47,12 @@ describe('Backend Functions - backend module graph collector', () => { expect( collector.getModuleRecords().get('/project/src/backend/actions.backend.js'), ).toMatchObject({ - staticDependencies: ['/project/src/backend/helpers/http.js'], + staticDependencies: [ + { + source: './helpers/http.js', + resolvedId: '/project/src/backend/helpers/http.js', + }, + ], }); }); }); diff --git a/packages/plugins/apps/src/vite/backend-module-graph-collector.ts b/packages/plugins/apps/src/vite/backend-module-graph-collector.ts index 27acdf524..c7cecedac 100644 --- a/packages/plugins/apps/src/vite/backend-module-graph-collector.ts +++ b/packages/plugins/apps/src/vite/backend-module-graph-collector.ts @@ -34,7 +34,7 @@ export function createBackendModuleGraphCollector(buildRoot: string): BackendMod moduleId, buildRoot, moduleInfo.ast as BaseNode, - moduleInfo.importedIds.map(normalizeViteModuleId), + getStaticDependencyIds(moduleInfo).map(normalizeViteModuleId), ); if (!record) { return; @@ -53,6 +53,10 @@ function normalizeViteModuleId(id: string): string { return id.split('?')[0]; } +function getStaticDependencyIds(moduleInfo: ModuleInfo): string[] { + return moduleInfo.importedIdResolutions?.map(({ id }) => id) ?? [...moduleInfo.importedIds]; +} + function isViteVirtualModuleId(id: string): boolean { return VIRTUAL_MODULE_ID_RE.test(id); } diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 033c71eb3..4b4b2c3fa 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -483,7 +483,7 @@ describe('Dev Server Middleware', () => { }; }; let capturedBody: PreviewAsyncBody | undefined; - const apiScope = nock(`https://${DD_SITE}`) + const apiScope = nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async', (body) => { capturedBody = body as PreviewAsyncBody; return true;