diff --git a/.eslintrc.js b/.eslintrc.js index 9bbfdfd7..9398b309 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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29d90ba8..30f40243 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: 30 name: End to End runs-on: ubuntu-latest diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index bc3bae60..13d31f50 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 3fc9807e..98d203ae 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 new file mode 100644 index 00000000..c612561b --- /dev/null +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -0,0 +1,157 @@ +// 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 { 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 JSZip from 'jszip'; +import nock from 'nock'; +import os from 'os'; +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'; +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 — +// 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 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. + outputJsonSync(path.join(CAPTURE_DIR, 'upload-capture.json'), captured); + 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'); +}; + +// 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 readJsonSync(capturePath) 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 }) => { + 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 () => { + // Read captured upload from the shared file written by the building worker. + const uploadRequest = readUploadCapture(); + + // 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'); + + // 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\//); + } + }); +}); 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 00000000..a595fe51 --- /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 00000000..bbed5708 --- /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}}!');