Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions packages/plugins/apps/src/backend/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// 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 * as fsHelpers from '@dd/core/helpers/fs';
import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers';
import fsp from 'fs/promises';
import path from 'path';
import { pathToFileURL } from 'url';

const BACKEND_OUT_DIR_PREFIX = 'dd-apps-backend-';
const BUNDLE_FILENAME_RE = /^[0-9a-f]{64}\.(.+)\.js$/;

type BackendMainModule = {
main: (context: unknown) => Promise<unknown>;
};

function isBackendMainModule(mod: unknown): mod is BackendMainModule {
if (typeof mod !== 'object' || mod === null) {
return false;
}
if (!('main' in mod)) {
return false;
}
return typeof mod.main === 'function';
}

describe('apps backend runtime — real @datadog/apps-backend integration', () => {
let backendOutDir: string | undefined;
let bundlesByFunctionName: Map<string, string>;

beforeAll(async () => {
const realRm = fsHelpers.rm;
const rmSpy = jest.spyOn(fsHelpers, 'rm').mockImplementation(async (dir: string) => {
if (dir.includes(BACKEND_OUT_DIR_PREFIX)) {
backendOutDir = dir;
return;
}
await realRm(dir);
});

const { errors } = await runBundlers(
{ apps: { identifier: 'app-id', name: 'test-app', dryRun: true } },
{ entry: { main: './apps_backend_project/main.ts' } },
['vite'],
);
rmSpy.mockRestore();

if (errors.length > 0) {
throw new Error(`Expected no build errors, got: ${errors.join(', ')}`);
}
if (!backendOutDir) {
throw new Error('Expected the apps plugin to build backend function bundles.');
}

const files = await fsp.readdir(backendOutDir);
bundlesByFunctionName = new Map();
for (const file of files) {
const match = BUNDLE_FILENAME_RE.exec(file);
if (match) {
bundlesByFunctionName.set(match[1], path.join(backendOutDir, file));
}
}
}, 30000);

afterAll(async () => {
if (backendOutDir) {
await fsp.rm(backendOutDir, { recursive: true, force: true });
}
});

const importBackendMain = async (functionName: string) => {
const bundlePath = bundlesByFunctionName.get(functionName);
if (!bundlePath) {
throw new Error(`No emitted bundle found for backend function "${functionName}".`);
}
const mod: unknown = await import(pathToFileURL(bundlePath).href);
if (!isBackendMainModule(mod)) {
throw new Error(`Expected ${bundlePath} to export a "main" function.`);
}
return mod.main;
};

const validSource = () => ({
initiator: { id: 'initiator-id', orgId: 'org-1' },
runAsUser: { id: 'run-as-id', orgId: 'org-1' },
});

test('resolves execution and initiating users from the real SDK', async () => {
const main = await importBackendMain('getRuntimeUsers');
const { initiator, runAsUser } = validSource();

const result = await main({
Source: { initiator, runAsUser },
backendFunctionArgs: ['integration-test'],
});

expect(result).toEqual({
label: 'integration-test',
executionUser: runAsUser,
initiatingUser: initiator,
});
});

test('forwards arguments to the backend function', async () => {
const main = await importBackendMain('plainEcho');

const result = await main({
Source: validSource(),
backendFunctionArgs: ['hello'],
});

expect(result).toEqual({ value: 'hello' });
});

test('a backend that does not use the runtime SDK still works', async () => {
const main = await importBackendMain('noSdkFunction');

const result = await main({
Source: validSource(),
backendFunctionArgs: [],
});

expect(result).toEqual({ ok: true });
});

test('rejects an invalid context', async () => {
const main = await importBackendMain('getRuntimeUsers');

await expect(
main({
Source: {
initiator: { orgId: 'org-1' },
runAsUser: { id: 'run-as-id', orgId: 'org-1' },
},
backendFunctionArgs: ['integration-test'],
}),
).rejects.toThrow(/is missing the required "id" property/);
});
});
38 changes: 37 additions & 1 deletion packages/plugins/apps/src/backend/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,43 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { SET_EXECUTE_ACTION_SNIPPET } from '@dd/apps-plugin/backend/shared';
import {
SET_BACKEND_CONTEXT_SNIPPET,
SET_EXECUTE_ACTION_SNIPPET,
} from '@dd/apps-plugin/backend/shared';

describe('SET_BACKEND_CONTEXT_SNIPPET', () => {
test('registers the backend runtime context', () => {
const runtimeHandle = { opaque: true };
const buildRuntimeFromJsFunctionWithActions = jest.fn().mockReturnValue(runtimeHandle);
const setBackend = jest.fn();
const context = { Source: { initiator: { id: 'user-id' } } };

// eslint-disable-next-line no-new-func
const run = new Function(
'buildRuntimeFromJsFunctionWithActions',
'setBackend',
'$',
SET_BACKEND_CONTEXT_SNIPPET,
);
run(buildRuntimeFromJsFunctionWithActions, setBackend, context);

expect(buildRuntimeFromJsFunctionWithActions).toHaveBeenCalledWith(context);
expect(setBackend).toHaveBeenCalledWith(runtimeHandle);
});

test('does not throw when buildRuntimeFromJsFunctionWithActions/setBackend are unavailable', () => {
// eslint-disable-next-line no-new-func
const run = new Function(
'buildRuntimeFromJsFunctionWithActions',
'setBackend',
'$',
SET_BACKEND_CONTEXT_SNIPPET,
);

expect(() => run(undefined, undefined, {})).not.toThrow();
});
});

/**
* Evaluate SET_EXECUTE_ACTION_SNIPPET in a controlled scope and return the
Expand Down
34 changes: 27 additions & 7 deletions packages/plugins/apps/src/backend/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,44 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

/**
* Check if @datadog/action-catalog is installed using Node's module resolution.
* Resolves from the given directory (defaults to cwd) so the check works
* even when the plugin itself is loaded from a different location (e.g. linked).
*/
export function isActionCatalogInstalled(fromDir: string): boolean {
function isPackageExportInstalled(packageExport: string, fromDir: string): boolean {
try {
require.resolve('@datadog/action-catalog/action-execution', { paths: [fromDir] });
require.resolve(packageExport, { paths: [fromDir] });
return true;
} catch {
return false;
}
}

/**
* Check if @datadog/action-catalog is installed using Node's module resolution.
* Resolves from the given directory so the check works even when the plugin
* itself is loaded from a different location (e.g. linked).
*/
export function isActionCatalogInstalled(fromDir: string): boolean {
return isPackageExportInstalled('@datadog/action-catalog/action-execution', fromDir);
}

/** Check if the @datadog/apps-backend "JS Function with Actions" runtime factory is installed. */
export function isDatadogAppsBackendInstalled(fromDir: string): boolean {
return isPackageExportInstalled('@datadog/apps-backend/runtime/jsFunctionWithActions', fromDir);
}

/** The import line to pull action-catalog's setExecuteActionImplementation into bundles. */
export const ACTION_CATALOG_IMPORT =
"import { setExecuteActionImplementation } from '@datadog/action-catalog/action-execution';";

/** The import line that exposes @datadog/apps-backend backend context initialization. */
export const DATADOG_APPS_BACKEND_IMPORT = `\
import { buildRuntimeFromJsFunctionWithActions } from '@datadog/apps-backend/runtime/jsFunctionWithActions';
import { setBackend } from '@datadog/apps-backend/runtime';`;

/** Script snippet that supplies the backend runtime context to @datadog/apps. */
export const SET_BACKEND_CONTEXT_SNIPPET = `\
if (typeof buildRuntimeFromJsFunctionWithActions === 'function' && typeof setBackend === 'function') {
setBackend(buildRuntimeFromJsFunctionWithActions($));
}`;

/** Script snippet that registers the $.Actions-based executeAction implementation at runtime. */
export const SET_EXECUTE_ACTION_SNIPPET = `\
if (typeof setExecuteActionImplementation === 'function') {
Expand Down
41 changes: 41 additions & 0 deletions packages/plugins/apps/src/backend/virtual-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('Backend Functions - generateVirtualEntryContent', () => {
describe('without action-catalog', () => {
beforeEach(() => {
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false);
});

test('Should import the function by name from the entry path', () => {
Expand Down Expand Up @@ -89,11 +90,22 @@ describe('Backend Functions - generateVirtualEntryContent', () => {
);
expect(result).not.toContain('@datadog/action-catalog');
});

test('Should not include Datadog Apps backend context setup', () => {
const result = generateVirtualEntryContent(
'myHandler',
'/src/handler.ts',
PROJECT_ROOT,
);
expect(result).not.toContain('@datadog/apps-backend/runtime');
expect(result).not.toContain('setBackend(buildRuntimeFromJsFunctionWithActions($))');
});
});

describe('with action-catalog', () => {
beforeEach(() => {
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true);
jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false);
});

test('Should include action-catalog import', () => {
Expand All @@ -117,8 +129,35 @@ describe('Backend Functions - generateVirtualEntryContent', () => {
});
});

describe('with @datadog/apps-backend', () => {
beforeEach(() => {
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true);
});

test('Should import and set the backend context before calling the handler', () => {
const result = generateVirtualEntryContent(
'myHandler',
'/src/handler.ts',
PROJECT_ROOT,
);

expect(result).toContain(
"import { buildRuntimeFromJsFunctionWithActions } from '@datadog/apps-backend/runtime/jsFunctionWithActions'",
);
expect(result).toContain("import { setBackend } from '@datadog/apps-backend/runtime'");
expect(
result.indexOf('setBackend(buildRuntimeFromJsFunctionWithActions($))'),
).toBeGreaterThan(result.indexOf('globalThis.$ = $'));
expect(result.indexOf('await myHandler(...args)')).toBeGreaterThan(
result.indexOf('setBackend(buildRuntimeFromJsFunctionWithActions($))'),
);
});
});

test('Should escape entry paths with special characters', () => {
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false);
const result = generateVirtualEntryContent(
'handler',
'/path/with "quotes"/handler.ts',
Expand All @@ -132,6 +171,7 @@ describe('Backend Functions - generateDevVirtualEntryContent', () => {
beforeEach(() => {
jest.restoreAllMocks();
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false);
});

test('Should produce identical output to generateVirtualEntryContent', () => {
Expand Down Expand Up @@ -162,6 +202,7 @@ describe('Backend Functions - args round-trip via $.backendFunctionArgs', () =>
beforeEach(() => {
jest.restoreAllMocks();
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false);
});

// Extract the body of the generated `main($)` function so we can eval it
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/apps/src/backend/virtual-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

import {
ACTION_CATALOG_IMPORT,
DATADOG_APPS_BACKEND_IMPORT,
SET_BACKEND_CONTEXT_SNIPPET,
SET_EXECUTE_ACTION_SNIPPET,
isActionCatalogInstalled,
isDatadogAppsBackendInstalled,
} from './shared';

/**
Expand All @@ -18,9 +21,14 @@ export function generateVirtualEntryContent(
projectRoot: string,
): string {
const lines: string[] = [];
const hasDatadogAppsBackendRuntime = isDatadogAppsBackendInstalled(projectRoot);

lines.push(`import { ${functionName} } from ${JSON.stringify(entryPath)};`);

if (hasDatadogAppsBackendRuntime) {
lines.push(DATADOG_APPS_BACKEND_IMPORT);
}

if (isActionCatalogInstalled(projectRoot)) {
lines.push(ACTION_CATALOG_IMPORT);
}
Expand All @@ -30,6 +38,11 @@ export function generateVirtualEntryContent(
lines.push('export async function main($) {');
lines.push(' globalThis.$ = $;');
lines.push('');
if (hasDatadogAppsBackendRuntime) {
lines.push(' // Supply the backend runtime context');
lines.push(SET_BACKEND_CONTEXT_SNIPPET);
lines.push('');
}
lines.push(` // Register the $.Actions-based implementation for executeAction`);
lines.push(SET_EXECUTE_ACTION_SNIPPET);
lines.push('');
Expand Down
12 changes: 8 additions & 4 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const getVitePlugin = ({
options,
}: VitePluginOptions): PluginOptions['vite'] => {
const log = context.getLogger(PLUGIN_NAME);
const { auth, buildRoot } = context;
const { auth } = context;

context.inject({
type: 'file',
Expand Down Expand Up @@ -142,7 +142,11 @@ export const getVitePlugin = ({
return { code: '', map: null };
}

const { functions, proxyCode } = buildProxyModule(exportNames, id, buildRoot);
const { functions, proxyCode } = buildProxyModule(
exportNames,
id,
context.buildRoot,
);
setBackendFunctions(id, functions);
log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`);

Expand All @@ -157,7 +161,7 @@ export const getVitePlugin = ({
const result = await buildBackendFunctions(
bundler.build,
backendFunctions,
buildRoot,
context.buildRoot,
log,
);
backendOutDir = result.outDir;
Expand Down Expand Up @@ -202,7 +206,7 @@ export const getVitePlugin = ({
getBackendFunctions,
auth,
doAuthenticatedRequest,
buildRoot,
context.buildRoot,
log,
),
);
Expand Down
Binary file not shown.
Loading