diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index 36fb9fa91..bbd00b425 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -16,6 +16,10 @@ export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; export const BACKEND_FILE_WITH_QUERY_RE = new RegExp( `${BACKEND_FILE_RE.source.slice(0, -1)}(\\?.*)?$`, ); + +/** Vite's `--mode` for `npm run dev:verify`; read via `server.config.mode` since `import.meta.env.MODE` breaks Jest's CommonJS transform. */ +export const DEV_VERIFY_MODE = 'dev-verify'; + export const BACKEND_CODE_EXTENSIONS = [ '.ts', '.tsx', diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index 4588014b8..3679f46ab 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -151,6 +151,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { mockLongPolling, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -193,6 +194,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { mockLongPolling, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -229,6 +231,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { mockLongPolling, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -299,6 +302,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { mockLongPolling, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -343,6 +347,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { mockLongPolling, FIXTURE_ROOT, getMockLogger(), + 'development', ); // The connection-ID collector is under test here, not the preview-async round trip @@ -396,6 +401,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { mockLongPolling, FIXTURE_ROOT, getMockLogger(), + 'development', ); const apiScope = nock('https://api.datadoghq.com') diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 83e9abd76..9c0bc1c84 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -21,7 +21,7 @@ import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; /** Shape of the `$.Actions` dynamic proxy — a nested property path (e.g. `$.Actions.slack.chat.postMessage`) callable at any depth; types `globalThis.$` in tests without an `any` cast. */ @@ -217,6 +217,39 @@ function mockLoadModuleReturning(func: BackendFunction, fn: (...args: never[]) = mockLoadModule.mockImplementation(resolveModule); } +type CreateDevServerMiddlewareArgs = Parameters; + +/** Builds a middleware with the common test defaults, so each test only names the argument(s) it's actually varying. `doAuthenticatedRequest` distinguishes "omitted" from "explicitly undefined" (the no-auth tests) via `in`, since `?? testAuthenticatedRequest` couldn't tell them apart. */ +function createTestMiddleware( + overrides: { + viteBuild?: CreateDevServerMiddlewareArgs[0]; + loadModule?: CreateDevServerMiddlewareArgs[1]; + getBackendFunctions?: CreateDevServerMiddlewareArgs[2]; + getAllowedConnectionIds?: CreateDevServerMiddlewareArgs[3]; + auth?: CreateDevServerMiddlewareArgs[4]; + doAuthenticatedRequest?: CreateDevServerMiddlewareArgs[5]; + longPolling?: CreateDevServerMiddlewareArgs[6]; + projectRoot?: CreateDevServerMiddlewareArgs[7]; + log?: CreateDevServerMiddlewareArgs[8]; + mode?: CreateDevServerMiddlewareArgs[9]; + } = {}, +): ReturnType { + return createDevServerMiddleware( + overrides.viteBuild ?? mockViteBuild, + overrides.loadModule ?? mockLoadModule, + overrides.getBackendFunctions ?? (() => mockFunctions), + overrides.getAllowedConnectionIds ?? (async () => []), + overrides.auth ?? mockAuth, + 'doAuthenticatedRequest' in overrides + ? overrides.doAuthenticatedRequest + : testAuthenticatedRequest, + overrides.longPolling ?? mockLongPolling, + overrides.projectRoot ?? '/project', + overrides.log ?? mockLog, + overrides.mode, + ); +} + describe('Dev Server Middleware', () => { beforeEach(() => { jest.clearAllMocks(); @@ -229,17 +262,7 @@ describe('Dev Server Middleware', () => { }); describe('createDevServerMiddleware routing', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + const middleware = createTestMiddleware(); test('Should call next() for non-POST requests', () => { const req = { method: 'GET', url: '/__dd/debugBundle' } as unknown as IncomingMessage; @@ -338,20 +361,70 @@ describe('Dev Server Middleware', () => { expect(body.result).toEqual({ data: { result: 'hello' } }); expect(apiScope.isDone()).toBe(true); }); + + test('Should route /__dd/executeAction to the cloud path when the dev server was started in dev-verify mode', async () => { + const verifyModeMiddleware = createTestMiddleware({ mode: DEV_VERIFY_MODE }); + + mockBuildWithParsedBackend(); + + const apiScope = nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-456' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-456') + .reply(200, { + data: { + attributes: { + done: true, + outputs: { data: { result: 'via cloud' } }, + }, + }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: ['world'], + }); + const res = createMockResponse(); + const next = jest.fn(); + + verifyModeMiddleware(req, res, next); + expect(next).not.toHaveBeenCalled(); + + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: { result: 'via cloud' } }); + expect(apiScope.isDone()).toBe(true); + expect(mockLoadModule).not.toHaveBeenCalled(); + }); + + test('Should reject /__dd/executeAction with no auth configured upfront when in dev-verify mode, not silently fall through to local execution', async () => { + const noAuthVerifyModeMiddleware = createTestMiddleware({ + mode: DEV_VERIFY_MODE, + doAuthenticatedRequest: undefined, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + noAuthVerifyModeMiddleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(false); + expect(body.error).toContain('Auth credentials not configured'); + expect(mockLoadModule).not.toHaveBeenCalled(); + }); }); describe('debugBundle handler', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + const middleware = createTestMiddleware(); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/debugBundle', {}); @@ -454,17 +527,7 @@ describe('Dev Server Middleware', () => { }); describe('executeActionViaCloud handler', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + const middleware = createTestMiddleware(); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/executeActionViaCloud', {}); @@ -489,17 +552,7 @@ describe('Dev Server Middleware', () => { }); test('Should reject a request with no auth configured upfront, matching the /__dd/executeAction gate', async () => { - const noAuthMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - undefined, - mockLongPolling, - '/project', - mockLog, - ); + const noAuthMiddleware = createTestMiddleware({ doAuthenticatedRequest: undefined }); const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), @@ -612,17 +665,9 @@ describe('Dev Server Middleware', () => { // token must be set in the test body for this live construction. process.env.DD_OAUTH_ACCESS_TOKEN = TEST_OAUTH_TOKEN; - const bearerMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - getAuthenticatedRequest(), - mockLongPolling, - '/project', - mockLog, - ); + const bearerMiddleware = createTestMiddleware({ + doAuthenticatedRequest: getAuthenticatedRequest(), + }); const apiScope = nock(DD_API_ORIGIN, { reqheaders: { @@ -654,17 +699,7 @@ describe('Dev Server Middleware', () => { }); test('Should return 400 with auth guidance when the access token is missing', async () => { - const noKeyMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - undefined, - mockLongPolling, - '/project', - mockLog, - ); + const noKeyMiddleware = createTestMiddleware({ doAuthenticatedRequest: undefined }); const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), @@ -743,17 +778,9 @@ describe('Dev Server Middleware', () => { allowedConnectionIds: ['conn-1', 'conn-2'], }, ]; - const middlewareWithAllowlist = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => functionsWithAllowlist, - async () => [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + const middlewareWithAllowlist = createTestMiddleware({ + getBackendFunctions: () => functionsWithAllowlist, + }); type PreviewAsyncBody = { data: { @@ -907,17 +934,9 @@ describe('Dev Server Middleware', () => { test('Should not retry when maxRetries is 1 (long-polling disabled)', async () => { mockBuildWithParsedBackend(); - const singleAttemptMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - { ...mockLongPolling, maxRetries: 1 }, - '/project', - mockLog, - ); + const singleAttemptMiddleware = createTestMiddleware({ + longPolling: { ...mockLongPolling, maxRetries: 1 }, + }); const apiScope = nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -946,17 +965,9 @@ describe('Dev Server Middleware', () => { // A stalled connection must be abandoned and re-polled, not surfaced // as a failed action: the receipt stays valid across attempts. - const stallingMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - { ...mockLongPolling, timeoutMs: 100 }, - '/project', - mockLog, - ); + const stallingMiddleware = createTestMiddleware({ + longPolling: { ...mockLongPolling, timeoutMs: 100 }, + }); const apiScope = nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -1013,17 +1024,7 @@ describe('Dev Server Middleware', () => { }); describe('executeAction handler (local)', () => { - const middleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + const middleware = createTestMiddleware(); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/executeAction', {}); @@ -1067,17 +1068,7 @@ describe('Dev Server Middleware', () => { }); test('Should reject a request with no auth configured upfront, even for a function that never calls $.Actions — matching production, which authenticates before any backend code runs', async () => { - const noAuthMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - undefined, - mockLongPolling, - '/project', - mockLog, - ); + const noAuthMiddleware = createTestMiddleware({ doAuthenticatedRequest: undefined }); mockLoadModuleReturning(mockFunctions[0], () => 'pure result, no $.Actions call'); const req = createMockRequest('/__dd/executeAction', { @@ -1100,18 +1091,11 @@ describe('Dev Server Middleware', () => { ...mockFunctions[0], allowedConnectionIds: ['conn-1'], }; - const middlewareWithConnection = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => [funcWithConnection, mockFunctions[1]], - async (entryId: string) => + const middlewareWithConnection = createTestMiddleware({ + getBackendFunctions: () => [funcWithConnection, mockFunctions[1]], + getAllowedConnectionIds: async (entryId: string) => entryId === funcWithConnection.absolutePath ? ['conn-1'] : [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + }); mockLoadModuleReturning(funcWithConnection, () => testDollarActions().slack.chat.postMessage({ inputs: { text: 'hi' }, @@ -1178,18 +1162,11 @@ describe('Dev Server Middleware', () => { ...mockFunctions[0], allowedConnectionIds: [''], }; - const middlewareWithEmptyConnection = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => [funcWithEmptyConnection, mockFunctions[1]], - async (entryId: string) => + const middlewareWithEmptyConnection = createTestMiddleware({ + getBackendFunctions: () => [funcWithEmptyConnection, mockFunctions[1]], + getAllowedConnectionIds: async (entryId: string) => entryId === funcWithEmptyConnection.absolutePath ? [''] : [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + }); mockLoadModuleReturning(funcWithEmptyConnection, () => testDollarActions().slack.chat.postMessage({ inputs: { text: 'hi' }, @@ -1426,19 +1403,11 @@ describe('Dev Server Middleware', () => { primingCalls.push(specifier); return { [mockFunctions[0].name]: () => 'done' }; }); - const rejectingMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => { + const rejectingMiddleware = createTestMiddleware({ + getAllowedConnectionIds: async () => { throw new Error('Importing Node built-in module "fs" is not supported'); }, - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + }); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -1460,18 +1429,10 @@ describe('Dev Server Middleware', () => { jest.useFakeTimers(); try { mockLoadModuleReturning(mockFunctions[0], () => 'done'); - const hangingMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, + const hangingMiddleware = createTestMiddleware({ // Never settles. - () => new Promise(() => {}), - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + getAllowedConnectionIds: () => new Promise(() => {}), + }); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -1498,17 +1459,9 @@ describe('Dev Server Middleware', () => { describe('dynamic discovery', () => { test('Should not find stale function after re-transform (HMR)', async () => { let currentFunctions: BackendFunction[] = [...mockFunctions]; - const middleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => currentFunctions, - async () => [], - mockAuth, - testAuthenticatedRequest, - mockLongPolling, - '/project', - mockLog, - ); + const middleware = createTestMiddleware({ + getBackendFunctions: () => currentFunctions, + }); // Simulate HMR: greet is renamed to greetV2 in the same file. currentFunctions = [ diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 0383e1c94..d3819d078 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -15,6 +15,7 @@ import { encodeQueryName } from '../backend/encodeQueryName'; import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol'; import type { BackendFunction, BackendOutputs } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; +import { DEV_VERIFY_MODE } from '../constants'; import type { LongPollingOptions } from '../types'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; @@ -504,11 +505,7 @@ async function handleExecuteAction( } } -/** - * Handles POST /__dd/executeActionViaCloud — bundles a backend function and executes it via - * the production round trip (queue + Deno subprocess), kept as its own endpoint - * (`npm run dev:verify`) for pre-publish parity checks rather than a mode flag. - */ +/** Handle POST /__dd/executeActionViaCloud: bundle and execute via the production round trip (queue + Deno subprocess). */ async function handleExecuteActionViaCloud( req: IncomingMessage, res: ServerResponse, @@ -537,10 +534,36 @@ async function handleExecuteActionViaCloud( sendSuccess(res, result); } catch (error: unknown) { - handleHttpError(res, error, log, 'executeActionViaCloud'); + // Labeled by the actual URL hit, not a fixed name, since dev-verify mode also reaches this via /__dd/executeAction. + handleHttpError(res, error, log, req.url ?? 'executeActionViaCloud'); } } +/** Shared by both routes that reach the cloud round trip, so a fix to auth-checking or error handling can't drift between them. */ +function routeToCloudHandler( + req: IncomingMessage, + res: ServerResponse, + functionsByName: Map, + bundle: BundleFn, + auth: AuthConfig, + doAuthenticatedRequest: DoAuthenticatedRequest | undefined, + longPolling: LongPollingConfig, + log: Logger, +): void { + guardAuthenticated(res, doAuthenticatedRequest, (authedRequest) => + handleExecuteActionViaCloud( + req, + res, + functionsByName, + bundle, + auth, + authedRequest, + longPolling, + log, + ), + ); +} + /** * Build a lookup map from encoded query names to BackendFunction objects. */ @@ -563,9 +586,11 @@ export function createDevServerMiddleware( longPolling: LongPollingConfig, projectRoot: string, log: Logger, + mode: string = 'development', ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { const bundle = (func: BackendFunction) => bundleBackendFunction(viteBuild, func, projectRoot, log); + const isDevVerifyMode = mode === DEV_VERIFY_MODE; const initialFunctions = getBackendFunctions(); if (initialFunctions.length > 0) { @@ -586,6 +611,21 @@ export function createDevServerMiddleware( handleDebugBundle(req, res, functionsByName, bundle).catch(() => { sendError(res, 500, 'Unexpected error'); }); + } else if ( + req.url === '/__dd/executeActionViaCloud' || + (req.url === '/__dd/executeAction' && isDevVerifyMode) + ) { + // The client always POSTs to /__dd/executeAction regardless of mode, so dev-verify is routed here server-side instead. + routeToCloudHandler( + req, + res, + functionsByName, + bundle, + auth, + doAuthenticatedRequest, + longPolling, + log, + ); } else if (req.url === '/__dd/executeAction') { guardAuthenticated(res, doAuthenticatedRequest, (authedRequest) => handleExecuteAction( @@ -601,19 +641,6 @@ export function createDevServerMiddleware( log, ), ); - } else if (req.url === '/__dd/executeActionViaCloud') { - guardAuthenticated(res, doAuthenticatedRequest, (authedRequest) => - handleExecuteActionViaCloud( - req, - res, - functionsByName, - bundle, - auth, - authedRequest, - longPolling, - log, - ), - ); } else { next(); } diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index ee8028800..64d0f9c1e 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -6,7 +6,16 @@ import { getVitePlugin } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; import { InjectPosition } from '@dd/core/types'; -import { getContextMock, getRepositoryDataMock, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +import { cleanEnv } from '@dd/tests/_jest/helpers/env'; +import { + createMockRequest, + createMockResponse, + getContextMock, + getRepositoryDataMock, + mockLogFn, +} from '@dd/tests/_jest/helpers/mocks'; +import type { IncomingMessage, ServerResponse } from 'http'; +import nock from 'nock'; import { parseAst } from 'rollup/parseAst'; import type { PluginContext } from 'rollup'; import type { ViteDevServer } from 'vite'; @@ -14,7 +23,7 @@ import type { ViteDevServer } from 'vite'; import * as auth from '../auth'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import * as buildPackage from './build-package'; @@ -58,6 +67,34 @@ function extractTransformedCode(result: unknown): string | undefined { : undefined; } +type DevServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void; + +// The subset of Vite's real `ViteDevServer` this test's fake server object provides. +type FakeViteDevServer = { + middlewares: { use: (fn: DevServerMiddleware) => void }; + ssrLoadModule: (id: string) => Promise; + config: { mode: string }; +}; + +// Narrows `plugin.configureServer` to its plain-function hook form via a runtime check, then wraps +// it in a signature scoped to the fake server this test passes, since the real hook's `ViteDevServer` +// parameter is far wider than what these tests construct — mirrors `getTransformHandler` above. +function getConfigureServer( + plugin: ReturnType, +): (server: FakeViteDevServer) => void { + const { configureServer } = plugin ?? {}; + if (typeof configureServer !== 'function') { + throw new Error('Expected plugin.configureServer to be the plain function-hook form'); + } + return function callConfigureServer(server: FakeViteDevServer): void { + Reflect.apply(configureServer, undefined, [server]); + }; +} + +function isDevServerMiddleware(value: unknown): value is DevServerMiddleware { + return typeof value === 'function'; +} + const functions: BackendFunction[] = [ { relativePath: 'src/backend/myHandler', @@ -141,6 +178,8 @@ function mockBuildWithParsedBackend() { }); } +const DD_API_ORIGIN = 'https://api.datadoghq.com'; + const defaultOptions = { bundler: mockVite, context: getContextMock({ @@ -174,6 +213,10 @@ describe('Backend Functions - getVitePlugin', () => { jest.spyOn(buildPackage, 'buildAppPackage').mockResolvedValue(undefined); }); + afterEach(() => { + nock.cleanAll(); + }); + test('Should return a vite plugin object with closeBundle', () => { const plugin = getVitePlugin(defaultOptions); expect(plugin).toBeDefined(); @@ -228,6 +271,7 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), + config: { mode: 'development' }, } as unknown as ViteDevServer; // The hooks are typed with Rollup's `this: PluginContext`, but the // plugin closures never read `this`, so a stand-in satisfies the call. @@ -256,6 +300,7 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), + config: { mode: 'development' }, } as unknown as ViteDevServer; plugin.configureServer(server); @@ -283,6 +328,7 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), + config: { mode: 'development' }, } as unknown as ViteDevServer; plugin.configureServer(server); @@ -639,4 +685,87 @@ describe('Backend Functions - getVitePlugin', () => { }, }); }); + + // Uses the real configureServer hook, not createDevServerMiddleware directly, to catch mode-forwarding regressions. + test('Should route /__dd/executeAction to the cloud path when configureServer sees a dev-verify server.config.mode', async () => { + const plugin = getVitePlugin(defaultOptions); + const transformHandler = getTransformHandler(plugin); + + await transformHandler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + ` + export function myHandler() {} + export function otherFunc() {} + `, + '/build/src/backend/myHandler.backend.ts', + ); + + // Unlike closeBundle's default mock (chunk metadata only), the cloud path bundles first and logs code.length, so this needs a real chunk `code`. + mockViteBuild.mockImplementation(async (config) => { + emitModuleParsed( + config, + '/build/src/backend/myHandler.backend.ts', + 'export function myHandler() {} export function otherFunc() {}', + ); + return { + output: [{ type: 'chunk', isEntry: true, name: bundleName1, code: '// bundled' }], + }; + }); + + const use = jest.fn(); + const ssrLoadModule = jest.fn(); + const configureServer = getConfigureServer(plugin); + // configureServer resolves auth from the environment; restored immediately after use. + const restoreEnv = cleanEnv(); + process.env.DD_API_KEY = 'test-api-key'; + process.env.DD_APP_KEY = 'test-app-key'; + configureServer({ + middlewares: { use }, + ssrLoadModule, + config: { mode: DEV_VERIFY_MODE }, + }); + restoreEnv(); + + expect(use).toHaveBeenCalledTimes(1); + const [registeredMiddleware] = use.mock.calls[0]; + if (!isDevServerMiddleware(registeredMiddleware)) { + throw new Error( + 'Expected middlewares.use to have been called with a middleware function', + ); + } + const middleware = registeredMiddleware; + + const apiScope = nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-dev-verify' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-dev-verify') + .reply(200, { + data: { + attributes: { + done: true, + outputs: { data: { result: 'via cloud' } }, + }, + }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: bundleName1, + args: ['world'], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.result).toEqual({ data: { result: 'via cloud' } }); + expect(apiScope.isDone()).toBe(true); + expect(ssrLoadModule).not.toHaveBeenCalled(); + }); }); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 84fd5fb46..b832668c0 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -325,6 +325,7 @@ export const getVitePlugin = ({ options.longPolling, context.buildRoot, log, + server.config.mode, ); server.middlewares.use(middleware); },