From 2f65ec05baa34a60ffa02ee26e28c7b7ffccc1e5 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Mon, 16 Mar 2026 14:39:26 -0400 Subject: [PATCH 1/6] Add e2e test for apps plugin --- .eslintrc.js | 1 + .../src/e2e/appsPlugin/appsPlugin.spec.ts | 127 ++++++++++++++++++ .../src/e2e/appsPlugin/project/index.html | 18 +++ .../tests/src/e2e/appsPlugin/project/index.js | 5 + 4 files changed, 151 insertions(+) create mode 100644 packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts create mode 100644 packages/tests/src/e2e/appsPlugin/project/index.html create mode 100644 packages/tests/src/e2e/appsPlugin/project/index.js diff --git a/.eslintrc.js b/.eslintrc.js index 9bbfdfd79..9398b3093 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -406,6 +406,7 @@ module.exports = { 'packages/plugins/**/built/*', 'packages/tests/src/_jest/**/*', 'packages/tests/src/_playwright/**/*', + 'packages/tests/src/e2e/**/*', ], rules: { 'import/no-extraneous-dependencies': 'off', diff --git a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts new file mode 100644 index 000000000..ecf765a89 --- /dev/null +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -0,0 +1,127 @@ +// 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 { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; +import type { TestOptions } from '@dd/tests/_playwright/testParams'; +import { test } from '@dd/tests/_playwright/testParams'; +import { defaultConfig } from '@dd/tools/plugins'; +import type { Page } from '@playwright/test'; +import nock from 'nock'; +import path from 'path'; + +// Have a similar experience to Jest. +const { expect, beforeAll, describe } = test; + +const APP_IDENTIFIER = 'e2e-test-app-id'; +const APP_NAME = 'e2e-test-app'; + +// Capture upload request details during the build. +let uploadRequest: { + path: string; + headers: Record; + body: string; +} | null = null; + +// Mock the apps upload endpoint and capture the request. +nock('https://api.datadoghq.com') + .post(new RegExp(`/api/unstable/app-builder-code/apps/.*/upload`)) + .reply(function (uri, body) { + uploadRequest = { + path: uri, + headers: this.req.headers as Record, + body: typeof body === 'string' ? body : JSON.stringify(body), + }; + return [ + 200, + { + version_id: 'v-test-123', + application_id: 'app-test-123', + app_builder_id: 'builder-test-123', + }, + ]; + }) + .persist(); + +const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { + // Navigate to our page. + await page.goto(`${url}/index.html?context_bundler=${bundler}`); + await page.waitForSelector('body'); +}; + +describe('Apps Plugin', () => { + // Build our fixture project with the apps plugin enabled and upload active. + beforeAll(async ({ publicDir, bundlers, suiteName }) => { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild(source, destination, bundlers, { + ...defaultConfig, + apps: { + enable: true, + dryRun: false, + identifier: APP_IDENTIFIER, + name: APP_NAME, + }, + }); + }); + + test('Should build and load the page without errors', async ({ + page, + bundler, + browserName, + suiteName, + devServerUrl, + }) => { + const errors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', async (response) => { + if (!response.ok()) { + const url = response.request().url(); + const prefix = `[${bundler} ${browserName} ${response.status()}]`; + errors.push(`${prefix} ${url}`); + } + }); + + // Verify that we do log the expected things. + const logs: string[] = []; + page.on('console', async (msg) => { + if (msg.type() !== 'log') { + return; + } + for (const arg of msg.args()) { + // eslint-disable-next-line no-await-in-loop + logs.push(await arg.jsonValue()); + } + }); + + // It should load the correct bundler file. + const bundleRequest = page.waitForResponse(`${testBaseUrl}/dist/${bundler}.js`); + await userFlow(testBaseUrl, page, bundler); + expect((await bundleRequest).ok()).toBe(true); + + expect(logs).toEqual([`Hello from apps plugin, ${bundler}!`]); + expect(errors).toHaveLength(0); + }); + + test('Should have uploaded assets to the apps intake', async () => { + // The upload happens during the build phase in beforeAll. + expect(uploadRequest).not.toBeNull(); + + // Verify the upload URL contains the app identifier. + expect(uploadRequest!.path).toContain( + `/api/unstable/app-builder-code/apps/${APP_IDENTIFIER}/upload`, + ); + + // Verify the origin headers are set. + expect(uploadRequest!.headers['dd-evp-origin']).toMatch(/-build-plugin_apps$/); + expect(uploadRequest!.headers['dd-evp-origin-version']).toBeDefined(); + + // The body is hex-encoded multipart form data. Decode it to verify contents. + const decodedBody = Buffer.from(uploadRequest!.body, 'hex').toString('utf-8'); + expect(decodedBody).toContain(APP_NAME); + expect(decodedBody).toContain('datadog-apps-assets.zip'); + }); +}); diff --git a/packages/tests/src/e2e/appsPlugin/project/index.html b/packages/tests/src/e2e/appsPlugin/project/index.html new file mode 100644 index 000000000..a595fe513 --- /dev/null +++ b/packages/tests/src/e2e/appsPlugin/project/index.html @@ -0,0 +1,18 @@ + + + + + + + + Apps Plugin Test + + + +

Welcome to the {{bundler}} Apps Plugin Test

+

This page verifies the build output works with the apps plugin enabled.

+ + + + + diff --git a/packages/tests/src/e2e/appsPlugin/project/index.js b/packages/tests/src/e2e/appsPlugin/project/index.js new file mode 100644 index 000000000..bbed5708d --- /dev/null +++ b/packages/tests/src/e2e/appsPlugin/project/index.js @@ -0,0 +1,5 @@ +// 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. + +console.log('Hello from apps plugin, {{bundler}}!'); From 09c8c232b7be3d84b39639ecba955681111dae4a Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Mon, 16 Mar 2026 15:05:09 -0400 Subject: [PATCH 2/6] Prefix frontend assets with frontend/ in archive and verify in e2e test --- packages/plugins/apps/src/index.test.ts | 7 ++++++- packages/plugins/apps/src/index.ts | 9 ++++++++- .../tests/src/e2e/appsPlugin/appsPlugin.spec.ts | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index bc3bae60c..13d31f50e 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -129,7 +129,12 @@ describe('Apps Plugin - getPlugins', () => { await plugin.asyncTrueEnd?.(); expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot); - expect(archive.createArchive).toHaveBeenCalledWith(mockedAssets); + expect(archive.createArchive).toHaveBeenCalledWith([ + { + absolutePath: '/project/dist/index.js', + relativePath: path.join('frontend', 'dist/index.js'), + }, + ]); expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 3fc9807e4..98d203ae2 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -8,6 +8,7 @@ import chalk from 'chalk'; import path from 'path'; import { createArchive } from './archive'; +import type { Asset } from './assets'; import { collectAssets } from './assets'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; @@ -65,8 +66,14 @@ Either: return; } + // Prefix all assets into the frontend/ subdirectory. + const allAssets: Asset[] = assets.map((asset) => ({ + ...asset, + relativePath: path.join('frontend', asset.relativePath), + })); + const archiveTimer = log.time('archive assets'); - const archive = await createArchive(assets); + const archive = await createArchive(allAssets); archiveTimer.end(); // Store variable for later disposal of directory. archiveDir = path.dirname(archive.archivePath); diff --git a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts index ecf765a89..24b7fb7fd 100644 --- a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -7,6 +7,7 @@ import type { TestOptions } from '@dd/tests/_playwright/testParams'; import { test } from '@dd/tests/_playwright/testParams'; import { defaultConfig } from '@dd/tools/plugins'; import type { Page } from '@playwright/test'; +import JSZip from 'jszip'; import nock from 'nock'; import path from 'path'; @@ -123,5 +124,18 @@ describe('Apps Plugin', () => { const decodedBody = Buffer.from(uploadRequest!.body, 'hex').toString('utf-8'); expect(decodedBody).toContain(APP_NAME); expect(decodedBody).toContain('datadog-apps-assets.zip'); + + // Extract the zip from the multipart body and verify all files are under frontend/. + const bodyBuffer = Buffer.from(uploadRequest!.body, 'hex'); + const zipMagic = Buffer.from([0x50, 0x4b, 0x03, 0x04]); + const zipStart = bodyBuffer.indexOf(zipMagic); + expect(zipStart).toBeGreaterThanOrEqual(0); + + const zip = await JSZip.loadAsync(bodyBuffer.subarray(zipStart)); + const filePaths = Object.keys(zip.files); + expect(filePaths.length).toBeGreaterThan(0); + for (const filePath of filePaths) { + expect(filePath).toMatch(/^frontend\//); + } }); }); From cd58f1cd779a1ddcf9309dc9e68a28093f48720a Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Mon, 16 Mar 2026 19:19:01 -0400 Subject: [PATCH 3/6] Fix e2e upload test: persist nock capture to file for cross-worker access --- .../src/e2e/appsPlugin/appsPlugin.spec.ts | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts index 24b7fb7fd..104f5aa8b 100644 --- a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -7,6 +7,7 @@ import type { TestOptions } from '@dd/tests/_playwright/testParams'; import { test } from '@dd/tests/_playwright/testParams'; import { defaultConfig } from '@dd/tools/plugins'; import type { Page } from '@playwright/test'; +import fs from 'fs'; import JSZip from 'jszip'; import nock from 'nock'; import path from 'path'; @@ -17,22 +18,25 @@ const { expect, beforeAll, describe } = test; const APP_IDENTIFIER = 'e2e-test-app-id'; const APP_NAME = 'e2e-test-app'; -// Capture upload request details during the build. -let uploadRequest: { - path: string; - headers: Record; - body: string; -} | null = null; - -// Mock the apps upload endpoint and capture the request. +// Mock the apps upload endpoint and write captured request to a file. +// We write to a file because Playwright workers are separate processes — +// only the worker that actually builds captures the nock request. nock('https://api.datadoghq.com') .post(new RegExp(`/api/unstable/app-builder-code/apps/.*/upload`)) - .reply(function (uri, body) { - uploadRequest = { + .reply(function handleUploadMock(uri, body) { + const captured = { path: uri, headers: this.req.headers as Record, body: typeof body === 'string' ? body : JSON.stringify(body), }; + // Write to a known location so all workers can read it. + const outDir = path.resolve(__dirname, '..', '..', '_playwright', 'public', 'appsPlugin'); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.resolve(outDir, 'upload-capture.json'), + JSON.stringify(captured), + 'utf-8', + ); return [ 200, { @@ -50,6 +54,19 @@ const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler'] await page.waitForSelector('body'); }; +// Read captured upload request from the shared file. +const readUploadCapture = (publicDir: string, suiteName: string) => { + const capturePath = path.resolve(publicDir, suiteName, 'upload-capture.json'); + if (!fs.existsSync(capturePath)) { + return null; + } + return JSON.parse(fs.readFileSync(capturePath, 'utf-8')) as { + path: string; + headers: Record; + body: string; + }; +}; + describe('Apps Plugin', () => { // Build our fixture project with the apps plugin enabled and upload active. beforeAll(async ({ publicDir, bundlers, suiteName }) => { @@ -107,7 +124,10 @@ describe('Apps Plugin', () => { expect(errors).toHaveLength(0); }); - test('Should have uploaded assets to the apps intake', async () => { + test('Should have uploaded assets to the apps intake', async ({ publicDir, suiteName }) => { + // Read captured upload from the shared file written by the building worker. + const uploadRequest = readUploadCapture(publicDir, suiteName); + // The upload happens during the build phase in beforeAll. expect(uploadRequest).not.toBeNull(); From 3fed514703fd909ddc74ad31c8ad95e1426d1a92 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Mon, 16 Mar 2026 19:34:41 -0400 Subject: [PATCH 4/6] Bump e2e CI timeout from 10m to 15m --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29d90ba8c..55d0cff9b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,7 +45,7 @@ jobs: DD_ENV: ci e2e: - timeout-minutes: 10 + timeout-minutes: 15 name: End to End runs-on: ubuntu-latest From 153792d62dae76c4b53f1bd855bd5523098c9767 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Mon, 16 Mar 2026 20:00:02 -0400 Subject: [PATCH 5/6] Bump e2e CI timeout to 30m --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 55d0cff9b..30f402439 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,7 +45,7 @@ jobs: DD_ENV: ci e2e: - timeout-minutes: 15 + timeout-minutes: 30 name: End to End runs-on: ubuntu-latest From f0c56f8a1a8631e91d9db5c651656862accd77c2 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Tue, 17 Mar 2026 13:57:10 -0400 Subject: [PATCH 6/6] Use os.tmpdir() for upload capture and @dd/core/helpers/fs --- .../src/e2e/appsPlugin/appsPlugin.spec.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts index 104f5aa8b..c612561b5 100644 --- a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -2,14 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { existsSync, outputJsonSync, readJsonSync } from '@dd/core/helpers/fs'; import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; import type { TestOptions } from '@dd/tests/_playwright/testParams'; import { test } from '@dd/tests/_playwright/testParams'; import { defaultConfig } from '@dd/tools/plugins'; import type { Page } from '@playwright/test'; -import fs from 'fs'; import JSZip from 'jszip'; import nock from 'nock'; +import os from 'os'; import path from 'path'; // Have a similar experience to Jest. @@ -17,6 +18,7 @@ const { expect, beforeAll, describe } = test; const APP_IDENTIFIER = 'e2e-test-app-id'; const APP_NAME = 'e2e-test-app'; +const CAPTURE_DIR = path.join(os.tmpdir(), 'dd-e2e-apps-plugin'); // Mock the apps upload endpoint and write captured request to a file. // We write to a file because Playwright workers are separate processes — @@ -30,13 +32,7 @@ nock('https://api.datadoghq.com') body: typeof body === 'string' ? body : JSON.stringify(body), }; // Write to a known location so all workers can read it. - const outDir = path.resolve(__dirname, '..', '..', '_playwright', 'public', 'appsPlugin'); - fs.mkdirSync(outDir, { recursive: true }); - fs.writeFileSync( - path.resolve(outDir, 'upload-capture.json'), - JSON.stringify(captured), - 'utf-8', - ); + outputJsonSync(path.join(CAPTURE_DIR, 'upload-capture.json'), captured); return [ 200, { @@ -54,13 +50,13 @@ const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler'] await page.waitForSelector('body'); }; -// Read captured upload request from the shared file. -const readUploadCapture = (publicDir: string, suiteName: string) => { - const capturePath = path.resolve(publicDir, suiteName, 'upload-capture.json'); - if (!fs.existsSync(capturePath)) { +// Read captured upload request from the shared temp file. +const readUploadCapture = () => { + const capturePath = path.join(CAPTURE_DIR, 'upload-capture.json'); + if (!existsSync(capturePath)) { return null; } - return JSON.parse(fs.readFileSync(capturePath, 'utf-8')) as { + return readJsonSync(capturePath) as { path: string; headers: Record; body: string; @@ -124,9 +120,9 @@ describe('Apps Plugin', () => { expect(errors).toHaveLength(0); }); - test('Should have uploaded assets to the apps intake', async ({ publicDir, suiteName }) => { + test('Should have uploaded assets to the apps intake', async () => { // Read captured upload from the shared file written by the building worker. - const uploadRequest = readUploadCapture(publicDir, suiteName); + const uploadRequest = readUploadCapture(); // The upload happens during the build phase in beforeAll. expect(uploadRequest).not.toBeNull();