From 84ffd4d666d097e4414a4ec204338af0ffd36cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 16 Jan 2025 12:48:25 +0100 Subject: [PATCH] Add sandboxing and refactored smoke test harness (#6621) * Add sandboxing and refactored existing code * Add dep on "undici-types" * Ignore ephemeral files for prettier * Call cleanup functions in sequence and reverse order --- package-lock.json | 2 + packages/compass-e2e-tests/.gitignore | 5 + packages/compass-e2e-tests/.prettierignore | 5 + .../compass-e2e-tests/helpers/buildinfo.ts | 80 ----- .../helpers/smoke-test/build-info.ts | 189 +++++++++++ .../helpers/smoke-test/context.ts | 12 + .../helpers/smoke-test/directories.ts | 32 ++ .../helpers/smoke-test/downloads.ts | 58 ++++ .../helpers/smoke-test/packages.ts | 13 + .../compass-e2e-tests/installers/helpers.ts | 50 +-- .../compass-e2e-tests/installers/mac-dmg.ts | 57 ++-- .../compass-e2e-tests/installers/mac-zip.ts | 43 +++ .../compass-e2e-tests/installers/types.ts | 1 + packages/compass-e2e-tests/package.json | 1 + packages/compass-e2e-tests/smoke-test.ts | 315 ++++++------------ 15 files changed, 503 insertions(+), 360 deletions(-) delete mode 100644 packages/compass-e2e-tests/helpers/buildinfo.ts create mode 100644 packages/compass-e2e-tests/helpers/smoke-test/build-info.ts create mode 100644 packages/compass-e2e-tests/helpers/smoke-test/context.ts create mode 100644 packages/compass-e2e-tests/helpers/smoke-test/directories.ts create mode 100644 packages/compass-e2e-tests/helpers/smoke-test/downloads.ts create mode 100644 packages/compass-e2e-tests/helpers/smoke-test/packages.ts create mode 100644 packages/compass-e2e-tests/installers/mac-zip.ts diff --git a/package-lock.json b/package-lock.json index 03fc0c25d0f..f2ba8d3cf44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43213,6 +43213,7 @@ "semver": "^7.6.2", "tree-kill": "^1.2.2", "ts-node": "^10.9.1", + "undici-types": "^6", "webdriverio": "^9.4.1", "why-is-node-running": "^2.3.0", "xvfb-maybe": "^0.2.1", @@ -66332,6 +66333,7 @@ "semver": "^7.6.2", "tree-kill": "^1.2.2", "ts-node": "^10.9.1", + "undici-types": "^6", "webdriverio": "^9.4.1", "why-is-node-running": "^2.3.0", "xvfb-maybe": "^0.2.1", diff --git a/packages/compass-e2e-tests/.gitignore b/packages/compass-e2e-tests/.gitignore index ae91dfc1056..d271f2c31b1 100644 --- a/packages/compass-e2e-tests/.gitignore +++ b/packages/compass-e2e-tests/.gitignore @@ -3,3 +3,8 @@ fixtures/*.csv fixtures/*.json hadron-build-info.json + +# Ignoring sandboxes (created per test run) +.smoke-sandboxes/ +# Cache of downloaded binaries +.smoke-downloads/ diff --git a/packages/compass-e2e-tests/.prettierignore b/packages/compass-e2e-tests/.prettierignore index 42c9c0cb109..8a1336e37eb 100644 --- a/packages/compass-e2e-tests/.prettierignore +++ b/packages/compass-e2e-tests/.prettierignore @@ -4,3 +4,8 @@ fixtures .nyc_output coverage hadron-build-info.json + +# Ignoring sandboxes (created per test run) +.smoke-sandboxes/ +# Cache of downloaded binaries +.smoke-downloads/ diff --git a/packages/compass-e2e-tests/helpers/buildinfo.ts b/packages/compass-e2e-tests/helpers/buildinfo.ts deleted file mode 100644 index 37d1d37c8ee..00000000000 --- a/packages/compass-e2e-tests/helpers/buildinfo.ts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from 'node:assert/strict'; - -// subsets of the hadron-build info result - -const commonKeys = ['productName']; -type CommonBuildInfo = Record; - -function assertObjectHasKeys( - obj: unknown, - name: string, - keys: readonly string[] -) { - assert( - typeof obj === 'object' && obj !== null, - 'Expected buildInfo to be an object' - ); - - for (const key of keys) { - assert(key in obj, `Expected '${name}' to have '${key}'`); - } -} - -export function assertCommonBuildInfo( - buildInfo: unknown -): asserts buildInfo is CommonBuildInfo { - assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); -} - -const windowsFilenameKeys = [ - 'windows_setup_filename', - 'windows_msi_filename', - 'windows_zip_filename', - 'windows_nupkg_full_filename', -] as const; -type WindowsBuildInfo = CommonBuildInfo & - Record; - -const osxFilenameKeys = ['osx_dmg_filename', 'osx_zip_filename'] as const; -type OSXBuildInfo = CommonBuildInfo & - Record; - -const ubuntuFilenameKeys = [ - 'linux_deb_filename', - 'linux_tar_filename', -] as const; -type UbuntuBuildInfo = CommonBuildInfo & - Record; - -const rhelFilenameKeys = ['linux_rpm_filename', 'rhel_tar_filename'] as const; -type RHELBuildInfo = CommonBuildInfo & - Record; - -export function assertBuildInfoIsWindows( - buildInfo: unknown -): asserts buildInfo is WindowsBuildInfo { - assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); - assertObjectHasKeys(buildInfo, 'buildInfo', windowsFilenameKeys); -} - -export function assertBuildInfoIsOSX( - buildInfo: unknown -): asserts buildInfo is OSXBuildInfo { - assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); - assertObjectHasKeys(buildInfo, 'buildInfo', osxFilenameKeys); -} - -export function assertBuildInfoIsUbuntu( - buildInfo: unknown -): buildInfo is UbuntuBuildInfo { - assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); - assertObjectHasKeys(buildInfo, 'buildInfo', ubuntuFilenameKeys); - return true; -} - -export function assertBuildInfoIsRHEL( - buildInfo: unknown -): asserts buildInfo is RHELBuildInfo { - assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); - assertObjectHasKeys(buildInfo, 'buildInfo', rhelFilenameKeys); -} diff --git a/packages/compass-e2e-tests/helpers/smoke-test/build-info.ts b/packages/compass-e2e-tests/helpers/smoke-test/build-info.ts new file mode 100644 index 00000000000..08b34344924 --- /dev/null +++ b/packages/compass-e2e-tests/helpers/smoke-test/build-info.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { handler as writeBuildInfo } from 'hadron-build/commands/info'; + +import { type PackageKind } from './packages'; +import { type SmokeTestsContext } from './context'; +import { pick } from 'lodash'; + +function assertObjectHasKeys( + obj: unknown, + name: string, + keys: readonly string[] +) { + assert( + typeof obj === 'object' && obj !== null, + 'Expected buildInfo to be an object' + ); + + for (const key of keys) { + assert(key in obj, `Expected '${name}' to have '${key}'`); + } +} + +// subsets of the hadron-build info result + +export const commonKeys = ['productName'] as const; +export type CommonBuildInfo = Record; + +export function assertCommonBuildInfo( + buildInfo: unknown +): asserts buildInfo is CommonBuildInfo { + assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); +} + +export const windowsFilenameKeys = [ + 'windows_setup_filename', + 'windows_msi_filename', + 'windows_zip_filename', + 'windows_nupkg_full_filename', +] as const; +export type WindowsBuildInfo = CommonBuildInfo & + Record; + +export function assertBuildInfoIsWindows( + buildInfo: unknown +): asserts buildInfo is WindowsBuildInfo { + assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); + assertObjectHasKeys(buildInfo, 'buildInfo', windowsFilenameKeys); +} + +export const osxFilenameKeys = [ + 'osx_dmg_filename', + 'osx_zip_filename', +] as const; +export type OSXBuildInfo = CommonBuildInfo & + Record; + +export function assertBuildInfoIsOSX( + buildInfo: unknown +): asserts buildInfo is OSXBuildInfo { + assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); + assertObjectHasKeys(buildInfo, 'buildInfo', osxFilenameKeys); +} + +export const ubuntuFilenameKeys = [ + 'linux_deb_filename', + 'linux_tar_filename', +] as const; +export type UbuntuBuildInfo = CommonBuildInfo & + Record; + +export function assertBuildInfoIsUbuntu( + buildInfo: unknown +): asserts buildInfo is UbuntuBuildInfo { + assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); + assertObjectHasKeys(buildInfo, 'buildInfo', ubuntuFilenameKeys); +} + +const rhelFilenameKeys = ['linux_rpm_filename', 'rhel_tar_filename'] as const; +export type RHELBuildInfo = CommonBuildInfo & + Record; + +export function assertBuildInfoIsRHEL( + buildInfo: unknown +): asserts buildInfo is RHELBuildInfo { + assertObjectHasKeys(buildInfo, 'buildInfo', commonKeys); + assertObjectHasKeys(buildInfo, 'buildInfo', rhelFilenameKeys); +} + +export type PackageDetails = { + kind: PackageKind; + filename: string; +} & ( + | { + kind: 'windows_setup' | 'windows_msi' | 'windows_zip'; + buildInfo: WindowsBuildInfo; + } + | { + kind: 'osx_dmg' | 'osx_zip'; + buildInfo: OSXBuildInfo; + } + | { + kind: 'linux_deb' | 'linux_tar'; + buildInfo: UbuntuBuildInfo; + } + | { + kind: 'linux_rpm' | 'rhel_tar'; + buildInfo: RHELBuildInfo; + } +); + +/** + * Extracts the filename of the packaged app from the build info, specific to a kind of package. + */ +export function getPackageDetails( + kind: PackageKind, + buildInfo: unknown +): PackageDetails { + if ( + kind === 'windows_setup' || + kind === 'windows_msi' || + kind === 'windows_zip' + ) { + assertBuildInfoIsWindows(buildInfo); + return { kind, buildInfo, filename: buildInfo[`${kind}_filename`] }; + } else if (kind === 'osx_dmg' || kind === 'osx_zip') { + assertBuildInfoIsOSX(buildInfo); + return { kind, buildInfo, filename: buildInfo[`${kind}_filename`] }; + } else if (kind === 'linux_deb' || kind === 'linux_tar') { + assertBuildInfoIsUbuntu(buildInfo); + return { kind, buildInfo, filename: buildInfo[`${kind}_filename`] }; + } else if (kind === 'linux_rpm' || kind === 'rhel_tar') { + assertBuildInfoIsRHEL(buildInfo); + return { kind, buildInfo, filename: buildInfo[`${kind}_filename`] }; + } else { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unsupported package kind: ${kind}`); + } +} + +function readJson(...segments: string[]): T { + const result = JSON.parse( + fs.readFileSync(path.join(...segments), 'utf8') + ) as unknown; + assert(typeof result === 'object' && result !== null, 'Expected an object'); + return result as T; +} + +export function readPackageDetails( + kind: PackageKind, + filePath: string +): PackageDetails { + const result = readJson(filePath); + return getPackageDetails(kind, result); +} + +export function writeAndReadPackageDetails( + context: SmokeTestsContext +): PackageDetails { + const compassDir = path.resolve(__dirname, '../../../compass'); + const infoArgs = { + format: 'json', + dir: compassDir, + platform: context.platform, + arch: context.arch, + out: path.resolve(context.sandboxPath, 'target.json'), + }; + console.log({ infoArgs }); + + // These are known environment variables that will affect the way + // writeBuildInfo works. Log them as a reminder and for our own sanity + console.log( + 'info env vars', + pick(process.env, [ + 'HADRON_DISTRIBUTION', + 'HADRON_APP_VERSION', + 'HADRON_PRODUCT', + 'HADRON_PRODUCT_NAME', + 'HADRON_READONLY', + 'HADRON_ISOLATED', + 'DEV_VERSION_IDENTIFIER', + 'IS_RHEL', + ]) + ); + writeBuildInfo(infoArgs); + return readPackageDetails(context.package, infoArgs.out); +} diff --git a/packages/compass-e2e-tests/helpers/smoke-test/context.ts b/packages/compass-e2e-tests/helpers/smoke-test/context.ts new file mode 100644 index 00000000000..77b63580ee7 --- /dev/null +++ b/packages/compass-e2e-tests/helpers/smoke-test/context.ts @@ -0,0 +1,12 @@ +import { type PackageKind } from './packages'; + +export type SmokeTestsContext = { + bucketName?: string; + bucketKeyPrefix?: string; + platform: 'win32' | 'darwin' | 'linux'; + arch: 'x64' | 'arm64'; + package: PackageKind; + forceDownload?: boolean; + localPackage?: boolean; + sandboxPath: string; +}; diff --git a/packages/compass-e2e-tests/helpers/smoke-test/directories.ts b/packages/compass-e2e-tests/helpers/smoke-test/directories.ts new file mode 100644 index 00000000000..2eb8b00f728 --- /dev/null +++ b/packages/compass-e2e-tests/helpers/smoke-test/directories.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +function ensureSandboxesDirectory() { + const sandboxesPath = path.resolve(__dirname, '../../.smoke-sandboxes'); + if (!fs.existsSync(sandboxesPath)) { + fs.mkdirSync(sandboxesPath, { recursive: true }); + } + return sandboxesPath; +} + +export function createSandbox() { + const nonce = crypto.randomBytes(4).toString('hex'); + const sandboxPath = path.resolve(ensureSandboxesDirectory(), nonce); + assert.equal( + fs.existsSync(sandboxPath), + false, + `Failed to create sandbox at '${sandboxPath}' - it already exists` + ); + fs.mkdirSync(sandboxPath); + return sandboxPath; +} + +export function ensureDownloadsDirectory() { + const downloadsPath = path.resolve(__dirname, '../../.smoke-downloads'); + if (!fs.existsSync(downloadsPath)) { + fs.mkdirSync(downloadsPath, { recursive: true }); + } + return downloadsPath; +} diff --git a/packages/compass-e2e-tests/helpers/smoke-test/downloads.ts b/packages/compass-e2e-tests/helpers/smoke-test/downloads.ts new file mode 100644 index 00000000000..4a0a48c33db --- /dev/null +++ b/packages/compass-e2e-tests/helpers/smoke-test/downloads.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import stream from 'node:stream'; +import { type fetch as undiciFetch } from 'undici-types'; + +// Hacking types here because the DOM types doesn't match the fetch implemented by Node.js +const fetch = globalThis.fetch as unknown as typeof undiciFetch; + +import { ensureDownloadsDirectory } from './directories'; + +type DownloadFileOptions = { + url: string; + targetFilename: string; + clearCache?: boolean; +}; + +export async function downloadFile({ + url, + targetFilename, + clearCache, +}: DownloadFileOptions): Promise { + const response = await fetch(url); + + const etag = response.headers.get('etag'); + assert(etag, 'Expected an ETag header'); + const cleanEtag = etag.match(/[0-9a-fA-F]/g)?.join(''); + assert(cleanEtag, 'Expected ETag to be cleanable'); + const cacheDirectoryPath = path.resolve( + ensureDownloadsDirectory(), + cleanEtag + ); + const outputPath = path.resolve(cacheDirectoryPath, targetFilename); + const cacheExists = fs.existsSync(outputPath); + + if (cacheExists) { + if (clearCache) { + fs.rmSync(cacheDirectoryPath, { recursive: true, force: true }); + } else { + console.log('Skipped downloading', url, '(cache existed)'); + return outputPath; + } + } + + if (!fs.existsSync(cacheDirectoryPath)) { + fs.mkdirSync(cacheDirectoryPath); + } + + // Write the response to file + assert(response.body, 'Expected a response body'); + console.log('Downloading', url); + await stream.promises.pipeline( + response.body, + fs.createWriteStream(outputPath) + ); + + return outputPath; +} diff --git a/packages/compass-e2e-tests/helpers/smoke-test/packages.ts b/packages/compass-e2e-tests/helpers/smoke-test/packages.ts new file mode 100644 index 00000000000..232a072df1f --- /dev/null +++ b/packages/compass-e2e-tests/helpers/smoke-test/packages.ts @@ -0,0 +1,13 @@ +export const SUPPORTED_PACKAGES = [ + 'windows_setup', + 'windows_msi', + 'windows_zip', + 'osx_dmg', + 'osx_zip', + 'linux_deb', + 'linux_tar', + 'linux_rpm', + 'rhel_tar', +] as const; + +export type PackageKind = typeof SUPPORTED_PACKAGES[number]; diff --git a/packages/compass-e2e-tests/installers/helpers.ts b/packages/compass-e2e-tests/installers/helpers.ts index b1fb872f97d..1073d99967e 100644 --- a/packages/compass-e2e-tests/installers/helpers.ts +++ b/packages/compass-e2e-tests/installers/helpers.ts @@ -1,45 +1,19 @@ -import { spawn } from 'child_process'; -import type { SpawnOptions } from 'child_process'; +import assert from 'node:assert/strict'; +import { spawnSync, type SpawnOptions } from 'node:child_process'; export function execute( command: string, args: string[], options?: SpawnOptions -): Promise { - return new Promise((resolve, reject) => { - console.log(command, ...args); - const p = spawn(command, args, { - stdio: 'inherit', - ...options, - }); - p.on('error', (err: any) => { - reject(err); - }); - p.on('close', (code: number | null, signal: NodeJS.Signals | null) => { - if (code !== null) { - if (code === 0) { - resolve(); - } else { - reject( - new Error(`${command} ${args.join(' ')} exited with code ${code}`) - ); - } - } else { - if (signal !== null) { - reject( - new Error( - `${command} ${args.join(' ')} exited with signal ${signal}` - ) - ); - } else { - // shouldn't happen - reject( - new Error( - `${command} ${args.join(' ')} exited with no code or signal` - ) - ); - } - } - }); +) { + const { status, signal } = spawnSync(command, args, { + stdio: 'inherit', + ...options, }); + assert( + status === 0 && signal === null, + `${command} ${args.join(' ')} exited with (status = ${ + status || 'null' + }, signal = ${signal || 'null'})` + ); } diff --git a/packages/compass-e2e-tests/installers/mac-dmg.ts b/packages/compass-e2e-tests/installers/mac-dmg.ts index 1f4cc0a7a3c..0d56045d813 100644 --- a/packages/compass-e2e-tests/installers/mac-dmg.ts +++ b/packages/compass-e2e-tests/installers/mac-dmg.ts @@ -1,38 +1,31 @@ -import path from 'path'; -import { existsSync } from 'fs'; +import path from 'node:path'; +import fs from 'node:fs'; + import type { InstalledAppInfo, InstallablePackage } from './types'; import { execute } from './helpers'; -export async function installMacDMG({ +export function installMacDMG({ appName, filepath, -}: InstallablePackage): Promise { - // TODO: rather copy this to a temporary directory - const fullDestinationPath = `/Applications/${appName}.app`; - - if (existsSync(fullDestinationPath)) { - // Would ideally just throw here, but unfortunately in CI the mac - // environments aren't all clean so somewhere we have to remove it anyway. - console.log(`${fullDestinationPath} already exists. Removing.`); - await execute('rm', ['-rf', fullDestinationPath]); - } + destinationPath, +}: InstallablePackage): InstalledAppInfo { + const appFilename = `${appName}.app`; + const appPath = path.resolve(destinationPath, appFilename); + const volumePath = `/Volumes/${appName}`; + + execute('hdiutil', ['attach', filepath]); - await execute('hdiutil', ['attach', filepath]); try { - await execute('cp', [ - '-Rp', - `/Volumes/${appName}/${appName}.app`, - '/Applications', - ]); + fs.cpSync(path.resolve(volumePath, appFilename), appPath, { + recursive: true, + verbatimSymlinks: true, + }); } finally { - await execute('hdiutil', ['detach', `/Volumes/${appName}`]); + execute('hdiutil', ['detach', volumePath]); } - // see if the executable will run without being quarantined or similar - await execute(`/Applications/${appName}.app/Contents/MacOS/${appName}`, [ - '--version', - ]); - + // TODO: Consider instrumenting the app to use a settings directory in the sandbox + // TODO: Move this somewhere shared between mac installers if (process.env.HOME) { const settingsDir = path.resolve( process.env.HOME, @@ -41,16 +34,20 @@ export async function installMacDMG({ appName ); - if (existsSync(settingsDir)) { + if (fs.existsSync(settingsDir)) { console.log(`${settingsDir} already exists. Removing.`); - await execute('rm', ['-rf', settingsDir]); + fs.rmSync(settingsDir, { recursive: true }); } } - return Promise.resolve({ - appPath: fullDestinationPath, + // see if the executable will run without being quarantined or similar + // TODO: Move this somewhere shared between mac installers + execute(path.resolve(appPath, 'Contents/MacOS', appName), ['--version']); + + return { + appPath: appPath, uninstall: async function () { /* TODO */ }, - }); + }; } diff --git a/packages/compass-e2e-tests/installers/mac-zip.ts b/packages/compass-e2e-tests/installers/mac-zip.ts new file mode 100644 index 00000000000..b7e3790e5cc --- /dev/null +++ b/packages/compass-e2e-tests/installers/mac-zip.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import fs from 'node:fs'; + +import type { InstalledAppInfo, InstallablePackage } from './types'; +import { execute } from './helpers'; + +export function installMacZIP({ + appName, + filepath, + destinationPath, +}: InstallablePackage): InstalledAppInfo { + const appFilename = `${appName}.app`; + const appPath = path.resolve(destinationPath, appFilename); + + execute('ditto', ['-xk', filepath, destinationPath]); + + // TODO: Consider instrumenting the app to use a settings directory in the sandbox + // TODO: Move this somewhere shared between mac installers + if (process.env.HOME) { + const settingsDir = path.resolve( + process.env.HOME, + 'Library', + 'Application Support', + appName + ); + + if (fs.existsSync(settingsDir)) { + console.log(`${settingsDir} already exists. Removing.`); + fs.rmSync(settingsDir, { recursive: true }); + } + } + + // see if the executable will run without being quarantined or similar + // TODO: Move this somewhere shared between mac installers + execute(path.resolve(appPath, 'Contents/MacOS', appName), ['--version']); + + return { + appPath: appPath, + uninstall: async function () { + /* TODO */ + }, + }; +} diff --git a/packages/compass-e2e-tests/installers/types.ts b/packages/compass-e2e-tests/installers/types.ts index ec263474202..c9c7d73d06e 100644 --- a/packages/compass-e2e-tests/installers/types.ts +++ b/packages/compass-e2e-tests/installers/types.ts @@ -11,6 +11,7 @@ export type Package = { export type InstallablePackage = { appName: string; filepath: string; + destinationPath: string; }; export type InstalledAppInfo = { diff --git a/packages/compass-e2e-tests/package.json b/packages/compass-e2e-tests/package.json index f5dd8e289a5..52a2a42680d 100644 --- a/packages/compass-e2e-tests/package.json +++ b/packages/compass-e2e-tests/package.json @@ -73,6 +73,7 @@ "semver": "^7.6.2", "tree-kill": "^1.2.2", "ts-node": "^10.9.1", + "undici-types": "^6", "webdriverio": "^9.4.1", "why-is-node-running": "^2.3.0", "xvfb-maybe": "^0.2.1", diff --git a/packages/compass-e2e-tests/smoke-test.ts b/packages/compass-e2e-tests/smoke-test.ts index cf6efdabdf1..d57b14feaa5 100755 --- a/packages/compass-e2e-tests/smoke-test.ts +++ b/packages/compass-e2e-tests/smoke-test.ts @@ -1,23 +1,23 @@ #!/usr/bin/env npx ts-node import assert from 'node:assert/strict'; -import { createWriteStream, existsSync, promises as fs } from 'node:fs'; +import fs from 'node:fs'; import path from 'node:path'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import https from 'https'; import { pick } from 'lodash'; -import { handler as writeBuildInfo } from 'hadron-build/commands/info'; -import type { InstalledAppInfo, Package, Installer } from './installers/types'; import { installMacDMG } from './installers/mac-dmg'; import { execute } from './installers/helpers'; import { - assertBuildInfoIsOSX, - assertBuildInfoIsRHEL, - assertBuildInfoIsUbuntu, - assertBuildInfoIsWindows, - assertCommonBuildInfo, -} from './helpers/buildinfo'; + type PackageDetails, + readPackageDetails, + writeAndReadPackageDetails, +} from './helpers/smoke-test/build-info'; +import { createSandbox } from './helpers/smoke-test/directories'; +import { downloadFile } from './helpers/smoke-test/downloads'; +import { SUPPORTED_PACKAGES } from './helpers/smoke-test/packages'; +import { type SmokeTestsContext } from './helpers/smoke-test/context'; +import { installMacZIP } from './installers/mac-zip'; const SUPPORTED_PLATFORMS = ['win32', 'darwin', 'linux'] as const; const SUPPORTED_ARCHS = ['x64', 'arm64'] as const; @@ -85,69 +85,70 @@ const argv = yargs(hideBin(process.argv)) }) .option('package', { type: 'string', - choices: [ - 'windows_setup', - 'windows_msi', - 'windows_zip', - 'osx_dmg', - 'osx_zip', - 'linux_deb', - 'linux_tar', - 'linux_rpm', - 'rhel_tar', - ] as const, + choices: SUPPORTED_PACKAGES, demandOption: true, description: 'Which package to test', }) - .option('skipDownload', { + .option('forceDownload', { type: 'boolean', - description: "Don't download all assets before starting", + description: 'Force download all assets before starting', }) - .check((argv) => { - if (!argv.skipDownload) { - if (!(argv.bucketName && argv.bucketKeyPrefix)) { - throw new Error( - 'Either supply EVERGREEN_BUCKET_NAME and EVERGREEN_BUCKET_KEY_PREFIX or specify --skip-download' - ); - } - } - - return true; + .option('localPackage', { + type: 'boolean', + description: 'Use the local package instead of downloading', }); -type SmokeTestsContext = { - bucketName?: string; - bucketKeyPrefix?: string; - platform: 'win32' | 'darwin' | 'linux'; - arch: 'x64' | 'arm64'; - package: - | 'windows_setup' - | 'windows_msi' - | 'windows_zip' - | 'osx_dmg' - | 'osx_zip' - | 'linux_deb' - | 'linux_tar' - | 'linux_rpm' - | 'rhel_tar'; - skipDownload?: boolean; -}; +type TestSubject = PackageDetails & { filepath: string }; + +/** + * Either finds the local package or downloads the package + */ +async function getTestSubject( + context: SmokeTestsContext +): Promise { + if (context.localPackage) { + const compassDistPath = path.resolve( + __dirname, + '../../packages/compass/dist' + ); + const buildInfoPath = path.resolve(compassDistPath, 'target.json'); + assert( + fs.existsSync(buildInfoPath), + `Expected '${buildInfoPath}' to exist` + ); + const details = readPackageDetails(context.package, buildInfoPath); + return { + ...details, + filepath: path.resolve(compassDistPath, details.filename), + }; + } else { + assert( + context.bucketName !== undefined && context.bucketKeyPrefix !== undefined, + 'Bucket name and key prefix are needed to download' + ); + const details = writeAndReadPackageDetails(context); + const filepath = await downloadFile({ + url: `https://${context.bucketName}.s3.amazonaws.com/${context.bucketKeyPrefix}/${details.filename}`, + targetFilename: details.filename, + clearCache: context.forceDownload, + }); -async function readJson(...segments: string[]): Promise { - const result = JSON.parse( - await fs.readFile(path.join(...segments), 'utf8') - ) as unknown; - assert(typeof result === 'object' && result !== null, 'Expected an object'); - return result as T; + return { ...details, filepath }; + } } async function run() { - const context: SmokeTestsContext = argv.parseSync(); + const context: SmokeTestsContext = { + ...argv.parseSync(), + sandboxPath: createSandbox(), + }; + + console.log(`Running tests in ${context.sandboxPath}`); console.log( 'context', pick(context, [ - 'skipDownload', + 'forceDownload', 'bucketName', 'bucketKeyPrefix', 'platform', @@ -156,163 +157,53 @@ async function run() { ]) ); - const compassDir = path.resolve(__dirname, '..', '..', 'packages', 'compass'); - const outPath = path.resolve(__dirname, 'hadron-build-info.json'); - - // build-info - const infoArgs = { - format: 'json', - dir: compassDir, - platform: context.platform, - arch: context.arch, - out: outPath, - }; - console.log('infoArgs', infoArgs); - - // These are known environment variables that will affect the way - // writeBuildInfo works. Log them as a reminder and for our own sanity - console.log( - 'info env vars', - pick(process.env, [ - 'HADRON_DISTRIBUTION', - 'HADRON_APP_VERSION', - 'HADRON_PRODUCT', - 'HADRON_PRODUCT_NAME', - 'HADRON_READONLY', - 'HADRON_ISOLATED', - 'DEV_VERSION_IDENTIFIER', - 'IS_RHEL', - ]) - ); - writeBuildInfo(infoArgs); - const buildInfo = await readJson(infoArgs.out); - - assertCommonBuildInfo(buildInfo); - - let match: - | { filename: string; installer: Installer; updatable: boolean } - | undefined = undefined; - - if (context.package === 'windows_setup') { - assertBuildInfoIsWindows(buildInfo); - // TODO - } else if (context.package === 'windows_msi') { - assertBuildInfoIsWindows(buildInfo); - // TODO - } else if (context.package === 'windows_zip') { - assertBuildInfoIsWindows(buildInfo); - // TODO - } else if (context.package === 'osx_dmg') { - assertBuildInfoIsOSX(buildInfo); - - const filename = buildInfo.osx_dmg_filename; - match = { - filename, - installer: installMacDMG, - // The tests need to know whether to expect the path where auto-update - // works automatically or if Compass will only notify the user of an - // update. - updatable: true, - }; - } else if (context.package === 'osx_zip') { - assertBuildInfoIsOSX(buildInfo); - // TODO - } else if (context.package === 'linux_deb') { - assertBuildInfoIsUbuntu(buildInfo); - // TODO - } else if (context.package === 'linux_tar') { - assertBuildInfoIsUbuntu(buildInfo); - // TODO - } else if (context.package === 'linux_rpm') { - assertBuildInfoIsRHEL(buildInfo); - // TODO - } else if (context.package === 'rhel_tar') { - assertBuildInfoIsRHEL(buildInfo); - // TODO - } - - if (match) { - const pkg = { - // we need appName because it is the name of the executable inside the - // package, regardless of what the package filename is named or where it - // gets installed - appName: buildInfo.productName, - packageFilepath: path.join(compassDir, 'dist', match.filename), - // TODO: releaseFilepath once we download the most recent released version too - installer: match.installer, - }; - - if (!context.skipDownload) { - assert( - context.bucketName !== undefined && - context.bucketKeyPrefix !== undefined - ); - await fs.mkdir(path.dirname(pkg.packageFilepath), { recursive: true }); - const url = `https://${context.bucketName}.s3.amazonaws.com/${context.bucketKeyPrefix}/${match.filename}`; - console.log(url); - await downloadFile(url, pkg.packageFilepath); - - // TODO: we need to also download releaseFilepath once we have that - } + const cleanups: (() => void | Promise)[] = [ + () => { + console.log('Cleaning up sandbox'); + fs.rmSync(context.sandboxPath, { recursive: true }); + }, + ]; + const { kind, buildInfo, filepath } = await getTestSubject(context); + + try { + const appName = buildInfo.productName; + if (kind === 'osx_dmg') { + const { appPath, uninstall } = installMacDMG({ + appName, + filepath, + destinationPath: context.sandboxPath, + }); + cleanups.push(uninstall); + + runTest({ appName, appPath }); + } else if (kind === 'osx_zip') { + const { appPath, uninstall } = installMacZIP({ + appName, + filepath, + destinationPath: context.sandboxPath, + }); + cleanups.push(uninstall); - if (!existsSync(pkg.packageFilepath)) { - throw new Error( - `${pkg.packageFilepath} does not exist. Did you forget to download or package?` - ); + runTest({ appName, appPath }); + } else { + throw new Error(`Testing '${kind}' packages is not yet implemented`); } - - // TODO: installing either the packaged file or the released file is better - // done as part of tests so we can also clean up and install one after the - // other, but that's for a separate PR. - console.log('installing', pkg.packageFilepath); - const installedInfo = await pkg.installer({ - appName: pkg.appName, - filepath: pkg.packageFilepath, - }); - console.log('testing', installedInfo.appPath); - await testInstalledApp(pkg, installedInfo); - } else { - throw new Error(`${context.package} not implemented`); + } finally { + // Chain the cleanup functions in reverse order + await cleanups + .slice() + .reverse() + .reduce((previous, cleanup) => previous.then(cleanup), Promise.resolve()); } } -async function downloadFile(url: string, targetFile: string): Promise { - return await new Promise((resolve, reject) => { - https - .get(url, (response) => { - const code = response.statusCode ?? 0; - - if (code >= 400) { - return reject(new Error(response.statusMessage)); - } - - // handle redirects - if (code > 300 && code < 400 && !!response.headers.location) { - return resolve(downloadFile(response.headers.location, targetFile)); - } - - // save the file to disk - const fileWriter = createWriteStream(targetFile) - .on('finish', () => { - resolve(); - }) - .on('error', (error: any) => { - reject(error); - }); - - response.pipe(fileWriter); - }) - .on('error', (error: any) => { - reject(error); - }); - }); -} +type RunTestOptions = { + appName: string; + appPath: string; +}; -function testInstalledApp( - pkg: Package, - appInfo: InstalledAppInfo -): Promise { - return execute( +function runTest({ appName, appPath }: RunTestOptions) { + execute( 'npm', [ 'run', @@ -326,8 +217,8 @@ function testInstalledApp( { env: { ...process.env, - COMPASS_APP_NAME: pkg.appName, - COMPASS_APP_PATH: appInfo.appPath, + COMPASS_APP_NAME: appName, + COMPASS_APP_PATH: appPath, }, } ); @@ -339,5 +230,5 @@ run() }) .catch(function (err) { console.error(err.stack); - process.exit(1); + process.exitCode = 1; });