diff --git a/.evergreen/create-sbom.sh b/.evergreen/create-sbom.sh index 724491ead6a..8afe3c9f0c9 100755 --- a/.evergreen/create-sbom.sh +++ b/.evergreen/create-sbom.sh @@ -18,8 +18,8 @@ trap_handler() { } trap trap_handler ERR EXIT -scp -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -P "$SIGNING_SERVER_PORT" .sbom/dependencies.json /tmp/silkbomb.env /tmp/artifactory_password "$SIGNING_SERVER_USERNAME"@"$SIGNING_SERVER_HOSTNAME":/tmp/ -ssh -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -p "$SIGNING_SERVER_PORT" "$SIGNING_SERVER_USERNAME"@"$SIGNING_SERVER_HOSTNAME" \ +scp -v -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -P "$SIGNING_SERVER_PORT" .sbom/dependencies.json /tmp/silkbomb.env /tmp/artifactory_password "$SIGNING_SERVER_USERNAME"@"$SIGNING_SERVER_HOSTNAME":/tmp/ +ssh -v -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -p "$SIGNING_SERVER_PORT" "$SIGNING_SERVER_USERNAME"@"$SIGNING_SERVER_HOSTNAME" \ "(cat /tmp/dependencies.json | jq -r '.[] | "'"pkg:npm/" + .name + "@" + .version'"' > /tmp/purls.txt) && \ echo "pkg:generic/mongo_crypt_shared@${CRYPT_SHARED_VERSION}" >> /tmp/purls.txt && \ (cat /tmp/artifactory_password | docker login artifactory.corp.mongodb.com --username '${ARTIFACTORY_USERNAME}' --password-stdin ; rm -f /tmp/artifactor_password ) && \ @@ -30,4 +30,4 @@ ssh -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -p "$SIGNING_SERVER_PORT" "$SIGNING --silk-asset-group "${SILK_ASSET_GROUP}" --sbom-in /tmp/sbom-lite.json && \ docker run --env-file /tmp/silkbomb.env --rm -v /tmp:/tmp artifactory.corp.mongodb.com/release-tools-container-registry-public-local/silkbomb:1.0 download \ --silk-asset-group "${SILK_ASSET_GROUP}" --sbom-out /tmp/sbom.json" -scp -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -P "$SIGNING_SERVER_PORT" "$SIGNING_SERVER_USERNAME"@"$SIGNING_SERVER_HOSTNAME":/tmp/{sbom-lite.json,sbom.json,purls.txt} .sbom/ +scp -v -i "$SIGNING_SERVER_PRIVATE_KEY_CYGPATH" -P "$SIGNING_SERVER_PORT" "$SIGNING_SERVER_USERNAME"@"$SIGNING_SERVER_HOSTNAME":/tmp/{sbom-lite.json,sbom.json,purls.txt} .sbom/ diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 77a469d34f0..ace74a10ac3 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,5 +1,5 @@ The following third-party software is used by and included in **Mongodb Compass**. -This document was automatically generated on Wed Jan 15 2025. +This document was automatically generated on Thu Jan 16 2025. ## List of dependencies diff --git a/docs/tracking-plan.md b/docs/tracking-plan.md index 536d157f104..ef3dd0ffec0 100644 --- a/docs/tracking-plan.md +++ b/docs/tracking-plan.md @@ -1,7 +1,7 @@ # Compass Tracking Plan -Generated on Wed, Jan 15, 2025 +Generated on Thu, Jan 16, 2025 ## Table of Contents @@ -936,8 +936,6 @@ This event is fired when a collection is created. **Properties**: -- **is_capped** (required): `boolean` - - Indicates whether the collection is capped. - **has_collation** (required): `boolean` - Indicates whether the collection has a custom collation. - **is_timeseries** (required): `boolean` @@ -960,8 +958,6 @@ This event is fired when a database is created. **Properties**: -- **is_capped** (required): `boolean` - - Indicates whether the first collection in the database is capped. - **has_collation** (required): `boolean` - Indicates whether the first collection in the database has a custom collation. - **is_timeseries** (required): `boolean` 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/collection-model/lib/collection-properties.js b/packages/collection-model/lib/collection-properties.js index 97382c2f4ec..befb5aacda8 100644 --- a/packages/collection-model/lib/collection-properties.js +++ b/packages/collection-model/lib/collection-properties.js @@ -7,6 +7,9 @@ const PROPERTIES_FLE2 = 'fle2'; const PROPERTIES_VIEW = 'view'; const PROPERTIES_READ_ONLY = 'read-only'; +/** + * @param {import('../').CollectionProps} coll + */ function getProperties(coll) { const properties = []; @@ -29,7 +32,7 @@ function getProperties(coll) { }); } - if (coll.capped) { + if (coll.is_capped) { properties.push({ id: PROPERTIES_CAPPED, }); diff --git a/packages/collection-model/lib/model.js b/packages/collection-model/lib/model.js index 25f4357cfbd..8b7ab68fd55 100644 --- a/packages/collection-model/lib/model.js +++ b/packages/collection-model/lib/model.js @@ -222,7 +222,7 @@ const CollectionModel = AmpersandModel.extend(debounceActions(['fetch']), { }, }, properties: { - deps: ['collation', 'type', 'capped', 'clustered', 'readonly', 'fle2'], + deps: ['collation', 'type', 'is_capped', 'clustered', 'readonly', 'fle2'], fn() { return getProperties(this); }, 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/commands/add-collection.ts b/packages/compass-e2e-tests/helpers/commands/add-collection.ts index 409a1f5da63..373ba061d35 100644 --- a/packages/compass-e2e-tests/helpers/commands/add-collection.ts +++ b/packages/compass-e2e-tests/helpers/commands/add-collection.ts @@ -2,9 +2,6 @@ import type { CompassBrowser } from '../compass-browser'; import * as Selectors from '../selectors'; export type AddCollectionOptions = { - capped?: { - size: number; - }; customCollation?: { locale: string; strength: number; @@ -51,15 +48,6 @@ export async function addCollection( ); } - if (collectionOptions && collectionOptions.capped) { - await browser.clickVisible(Selectors.CreateCollectionCappedCheckboxLabel); - - await browser.setValueVisible( - Selectors.CreateCollectionCappedSizeInput, - collectionOptions.capped.size.toString() - ); - } - if (collectionOptions && collectionOptions.customCollation) { await browser.clickVisible( Selectors.CreateCollectionCustomCollationCheckboxLabel diff --git a/packages/compass-e2e-tests/helpers/compass.ts b/packages/compass-e2e-tests/helpers/compass.ts index 923fdbd0382..1f17ed7748b 100644 --- a/packages/compass-e2e-tests/helpers/compass.ts +++ b/packages/compass-e2e-tests/helpers/compass.ts @@ -949,18 +949,20 @@ async function getCompassBuildMetadata(): Promise { } export async function buildCompass( - force = false, compassPath = COMPASS_DESKTOP_PATH ): Promise { - if (!force) { - try { - await getCompassBuildMetadata(); - return; - } catch (e) { - // No compass build found, let's build it - } + try { + await getCompassBuildMetadata(); + return; + } catch (e) { + /* ignore */ + } + + if (process.env.COMPASS_APP_PATH && process.env.COMPASS_APP_NAME) { + throw new Error('We did not expect to have to build Compass'); } + debug("No Compass build found, let's build it"); await packageCompassAsync({ dir: compassPath, skip_installer: true, diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 2f4dadd4d67..8dab150319a 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -458,10 +458,6 @@ export const CreateCollectionCreateButton = '[data-testid="create-collection-modal"] [data-testid="submit-button"]'; export const CreateCollectionCancelButton = '[data-testid="create-collection-modal"] [data-testid="cancel-button"]'; -export const CreateCollectionCappedCheckboxLabel = - '[data-testid="capped-collection-fields"] [data-testid="capped-collection-fields-label"]'; -export const CreateCollectionCappedSizeInput = - '[data-testid="capped-collection-fields"] [data-testid="capped-size"]'; export const CreateCollectionCollectionOptionsAccordion = '[data-testid="create-collection-modal"] [data-testid="additional-collection-preferences"]'; export const CreateCollectionCustomCollationCheckboxLabel = 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/helpers/test-runner-global-fixtures.ts b/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts index 9c163d61b60..a2bd3e751e2 100644 --- a/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts +++ b/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts @@ -139,7 +139,7 @@ export async function mochaGlobalSetup(this: Mocha.Runner) { if (isTestingDesktop(context)) { if (context.testPackagedApp) { - debug('Building Compass before running the tests ...'); + debug('Maybe building Compass before running the tests ...'); await buildCompass(); } else { debug('Preparing Compass before running the tests'); 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; }); diff --git a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts index fd325a63cdc..1bd2be6dea7 100644 --- a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts @@ -17,16 +17,11 @@ import { createNumbersCollection, } from '../helpers/insert-data'; import { saveAggregationPipeline } from '../helpers/commands/save-aggregation-pipeline'; -import { Key } from 'webdriverio'; import type { ChainablePromiseElement } from 'webdriverio'; import { switchPipelineMode } from '../helpers/commands/switch-pipeline-mode'; const { expect } = chai; -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - const OUT_STAGE_PREVIEW_TEXT = 'The $out operator will cause the pipeline to persist the results to the specified location (collection, S3, or Atlas). If the collection exists it will be replaced.'; const MERGE_STAGE_PREVIEW_TEXT = @@ -1332,13 +1327,7 @@ describe('Collection aggregations tab', function () { }); await browser.waitForAriaDisabled(previousButton, true); - // previousButton has a tooltip, to close it we press Escape - // and wait a bit (for the debounced close to kick in) - await browser.keys([Key.Escape]); - await sleep(50); - - // the next Escape is for the modal to close - await browser.keys([Key.Escape]); + await browser.clickVisible(Selectors.FocusModeCloseModalButton); await modal.waitForDisplayed({ reverse: true }); }); diff --git a/packages/compass-e2e-tests/tests/database-collections-tab.test.ts b/packages/compass-e2e-tests/tests/database-collections-tab.test.ts index a4f2c91962f..86181e19d6c 100644 --- a/packages/compass-e2e-tests/tests/database-collections-tab.test.ts +++ b/packages/compass-e2e-tests/tests/database-collections-tab.test.ts @@ -174,39 +174,6 @@ describe('Database collections tab', function () { await browser.waitUntilActiveDatabaseTab(DEFAULT_CONNECTION_NAME_1, 'test'); }); - it('can create a capped collection', async function () { - const collectionName = 'my-capped-collection'; - - // open the create collection modal from the button at the top - await browser.clickVisible(Selectors.DatabaseCreateCollectionButton); - - await browser.addCollection( - collectionName, - { - capped: { - size: 1000, - }, - }, - 'add-collection-modal-capped.png' - ); - - await browser.navigateToDatabaseCollectionsTab( - DEFAULT_CONNECTION_NAME_1, - 'test' - ); - - const selector = Selectors.collectionCard('test', collectionName); - await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, - selector, - 'grid' - ); - const collectionCard = browser.$(selector); - await collectionCard.waitForDisplayed(); - - // TODO: how do we make sure this is really a capped collection? - }); - it('can create a collection with custom collation', async function () { const collectionName = 'my-custom-collation-collection'; diff --git a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx index dac88d84deb..6203a9a984f 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx @@ -1,5 +1,11 @@ import toNS from 'mongodb-ns'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, +} from 'react'; import { connect } from 'react-redux'; import { ChevronCollapse, @@ -91,6 +97,12 @@ const noDeploymentStyles = css({ gap: spacing[200], }); +/** + * Indicates only Atlas cluster connections are supported, and the user cannot navigate + * to other types of connections from this UI. + */ +export const AtlasClusterConnectionsOnly = createContext(false); + function findCollection(ns: string, databases: Database[]) { const { database, collection } = toNS(ns); @@ -477,6 +489,8 @@ const ConnectionsNavigation: React.FC = ({ } }, [activeWorkspace, onDatabaseToggle, onConnectionToggle]); + const isAtlasConnectionStorage = useContext(AtlasClusterConnectionsOnly); + return (
= ({ data-testid="connections-header" > - Connections + {isAtlasConnectionStorage ? 'Clusters' : 'Connections'} {connections.length !== 0 && ( ({connections.length}) @@ -503,7 +517,11 @@ const ConnectionsNavigation: React.FC = ({ {connections.length > 0 && ( <> diff --git a/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx b/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx index fa37ac4ec5f..e0f388a0e2e 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx @@ -17,7 +17,10 @@ import type { WorkspacesService } from '@mongodb-js/compass-workspaces/provider' import { WorkspacesServiceProvider } from '@mongodb-js/compass-workspaces/provider'; import { TestMongoDBInstanceManager } from '@mongodb-js/compass-app-stores/provider'; import { ConnectionImportExportProvider } from '@mongodb-js/compass-connection-import-export'; -import { CompassSidebarPlugin } from '../../index'; +import { + AtlasClusterConnectionsOnlyProvider, + CompassSidebarPlugin, +} from '../../index'; import type { ConnectionInfo } from '@mongodb-js/compass-connections/provider'; import type AppRegistry from '../../../../hadron-app-registry/dist'; @@ -91,14 +94,16 @@ describe('Multiple Connections Sidebar Component', function () { function doRender( activeWorkspace: WorkspaceTab | null = null, - connections: ConnectionInfo[] = [savedFavoriteConnection] + connections: ConnectionInfo[] = [savedFavoriteConnection], + atlasClusterConnectionsOnly: boolean | undefined = undefined ) { workspace = sinon.spy({ openMyQueriesWorkspace: () => undefined, openShellWorkspace: () => undefined, openPerformanceWorkspace: () => undefined, }) as any; - const result = renderWithConnections( + + let component = ( - , - { - preferences: { enableMultipleConnectionSystem: true }, - connections, - connectFn() { - return { - currentOp() { - return {}; - }, - top() { - return {}; - }, - getConnectionOptions() { - return {}; - }, - } as any; - }, - } + ); + + if (atlasClusterConnectionsOnly !== undefined) { + component = ( + + {component} + + ); + } + + const result = renderWithConnections(component, { + preferences: { enableMultipleConnectionSystem: true }, + connections, + connectFn() { + return { + currentOp() { + return {}; + }, + top() { + return {}; + }, + getConnectionOptions() { + return {}; + }, + } as any; + }, + }); track = result.track; appRegistry = sinon.spy(result.globalAppRegistry); connectionsStoreActions = sinon.spy(result.connectionsStore.actions); @@ -188,6 +204,37 @@ describe('Multiple Connections Sidebar Component', function () { }); }); + describe("'Connections ' header", function () { + context('by default', () => { + it("shows 'Connections' in header and search bar", () => { + doRender(undefined, [savedFavoriteConnection, savedRecentConnection]); + expect(screen.getByTestId('connections-header').textContent).to.equal( + 'Connections(2)' + ); + expect( + screen.getByTestId('sidebar-filter-input') + .placeholder + ).to.equal('Search connections'); + }); + }); + context('when is atlas clusters only', () => { + it("shows 'Clusters' in header and search bar", () => { + doRender( + undefined, + [savedFavoriteConnection, savedRecentConnection], + true + ); + expect(screen.getByTestId('connections-header').textContent).to.equal( + 'Clusters(2)' + ); + expect( + screen.getByTestId('sidebar-filter-input') + .placeholder + ).to.equal('Search clusters'); + }); + }); + }); + describe('connections list', function () { context('when there are no connections', function () { it('should display an empty state with a CTA to add new connection', function () { diff --git a/packages/compass-sidebar/src/index.ts b/packages/compass-sidebar/src/index.ts index f6db8837655..dfa27cb08ab 100644 --- a/packages/compass-sidebar/src/index.ts +++ b/packages/compass-sidebar/src/index.ts @@ -11,6 +11,7 @@ import type { ConnectionsService } from '@mongodb-js/compass-connections/provide import { connectionsLocator } from '@mongodb-js/compass-connections/provider'; import type { Logger } from '@mongodb-js/compass-logging/provider'; import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; +import { AtlasClusterConnectionsOnly } from './components/multiple-connections/connections-navigation'; export const CompassSidebarPlugin = registerHadronPlugin( { @@ -52,3 +53,6 @@ export const CompassSidebarPlugin = registerHadronPlugin( logger: createLoggerLocator('COMPASS-SIDEBAR-UI'), } ); + +export const AtlasClusterConnectionsOnlyProvider = + AtlasClusterConnectionsOnly.Provider; diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index 7f6eec34f65..2ce87fb0fa8 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -2253,11 +2253,6 @@ type SwitchViewTypeEvent = ConnectionScopedEvent<{ type CollectionCreatedEvent = ConnectionScopedEvent<{ name: 'Collection Created'; payload: { - /** - * Indicates whether the collection is capped. - */ - is_capped: boolean; - /** * Indicates whether the collection has a custom collation. */ @@ -2293,11 +2288,6 @@ type CollectionCreatedEvent = ConnectionScopedEvent<{ type DatabaseCreatedEvent = ConnectionScopedEvent<{ name: 'Database Created'; payload: { - /** - * Indicates whether the first collection in the database is capped. - */ - is_capped: boolean; - /** * Indicates whether the first collection in the database has a custom collation. */ diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 482038fba6c..5ef4dab512b 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -19,7 +19,10 @@ import { WorkspaceTab as CollectionWorkspace, CollectionTabsProvider, } from '@mongodb-js/compass-collection'; -import { CompassSidebarPlugin } from '@mongodb-js/compass-sidebar'; +import { + CompassSidebarPlugin, + AtlasClusterConnectionsOnlyProvider, +} from '@mongodb-js/compass-sidebar'; import CompassQueryBarPlugin from '@mongodb-js/compass-query-bar'; import { CompassDocumentsPlugin } from '@mongodb-js/compass-crud'; import { @@ -61,11 +64,13 @@ import { useCompassWebPreferences } from './preferences'; const WithAtlasProviders: React.FC = ({ children }) => { return ( - - - {children} - - + + + + {children} + + + ); }; diff --git a/packages/databases-collections/src/components/collection-fields/capped-collection-fields.jsx b/packages/databases-collections/src/components/collection-fields/capped-collection-fields.jsx deleted file mode 100644 index 858f91858f5..00000000000 --- a/packages/databases-collections/src/components/collection-fields/capped-collection-fields.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - CollapsibleFieldSet, - FormFieldContainer, - TextInput, -} from '@mongodb-js/compass-components'; - -const HELP_URL_CAPPED = - 'https://docs.mongodb.com/manual/core/capped-collections/'; - -function CappedCollectionFields({ - cappedSize, - isCapped, - isClustered, - isTimeSeries, - isFLE2, - onChangeCappedSize, - onChangeIsCapped, -}) { - return ( - onChangeIsCapped(checked)} - label="Capped Collection" - data-testid="capped-collection-fields" - helpUrl={HELP_URL_CAPPED} - description="Fixed-size collections that support high-throughput operations that insert and retrieve documents based on insertion order." - > - - onChangeCappedSize(e.target.value)} - spellCheck={false} - /> - - - ); -} - -CappedCollectionFields.propTypes = { - cappedSize: PropTypes.string.isRequired, - isCapped: PropTypes.bool.isRequired, - isTimeSeries: PropTypes.bool.isRequired, - isClustered: PropTypes.bool.isRequired, - isFLE2: PropTypes.bool.isRequired, - onChangeCappedSize: PropTypes.func.isRequired, - onChangeIsCapped: PropTypes.func.isRequired, -}; - -export default CappedCollectionFields; diff --git a/packages/databases-collections/src/components/collection-fields/capped-collection-fields.spec.jsx b/packages/databases-collections/src/components/collection-fields/capped-collection-fields.spec.jsx deleted file mode 100644 index d69f94e226b..00000000000 --- a/packages/databases-collections/src/components/collection-fields/capped-collection-fields.spec.jsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { expect } from 'chai'; -import { TextInput } from '@mongodb-js/compass-components'; - -import CappedCollectionFields from './capped-collection-fields'; - -describe('CappedCollectionFields [Component]', function () { - context('when isTimeSeries prop is true', function () { - let component; - - beforeEach(function () { - component = mount( - {}} - onChangeIsCapped={() => {}} - cappedSize={'0'} - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('renders the checkbox disabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(true); - }); - }); - - context('when isCapped prop is true', function () { - let component; - - beforeEach(function () { - component = mount( - {}} - onChangeIsCapped={() => {}} - cappedSize={'0'} - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('renders the inputs ', function () { - expect(component.find(TextInput).length).to.equal(1); - }); - }); - - context('when isTimeSeries prop is false', function () { - let component; - - beforeEach(function () { - component = mount( - {}} - onChangeIsCapped={() => {}} - cappedSize={'0'} - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('does not render the fields', function () { - expect(component.find(TextInput).length).to.equal(0); - }); - - it('has the capped collection checkbox enabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(false); - }); - }); - - context('when isClustered prop is true', function () { - let component; - - beforeEach(function () { - component = mount( - {}} - onChangeIsCapped={() => {}} - cappedSize={'0'} - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('renders the checkbox disabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(true); - }); - }); - - context('when isFLE2 prop is true', function () { - let component; - - beforeEach(function () { - component = mount( - {}} - onChangeIsCapped={() => {}} - cappedSize={'0'} - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('renders the checkbox disabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(true); - }); - }); -}); diff --git a/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.jsx b/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.jsx index f7ad28e2b76..4fe83629e7c 100644 --- a/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.jsx +++ b/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.jsx @@ -15,7 +15,6 @@ const EXPIRE_AFTER_SECONDS_DESCRIPTION = 'The _id field must be a date or an array that contains date values.'; function ClusteredCollectionFields({ - isCapped, isTimeSeries, isClustered, clusteredIndex, @@ -34,7 +33,7 @@ function ClusteredCollectionFields({ return ( onChangeIsClustered(checked)} label="Clustered Collection" data-testid="clustered-collection-fields" @@ -72,7 +71,6 @@ function ClusteredCollectionFields({ } ClusteredCollectionFields.propTypes = { - isCapped: PropTypes.bool.isRequired, isTimeSeries: PropTypes.bool.isRequired, isClustered: PropTypes.bool.isRequired, clusteredIndex: PropTypes.object.isRequired, diff --git a/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.spec.jsx b/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.spec.jsx index 9fb5f0cd22b..9b2dadea38d 100644 --- a/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.spec.jsx +++ b/packages/databases-collections/src/components/collection-fields/clustered-collection-fields.spec.jsx @@ -14,7 +14,6 @@ describe('ClusteredCollectionFields [Component]', function () { component = mount( {}} @@ -40,7 +39,6 @@ describe('ClusteredCollectionFields [Component]', function () { component = mount( {}} @@ -72,7 +70,6 @@ describe('ClusteredCollectionFields [Component]', function () { component = mount( {}} - onChangeField={() => {}} - expireAfterSeconds="" - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('has the clustered checkbox disabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(true); - }); - }); }); diff --git a/packages/databases-collections/src/components/collection-fields/collection-fields.jsx b/packages/databases-collections/src/components/collection-fields/collection-fields.jsx index ef2b422d567..2d46a559cb8 100644 --- a/packages/databases-collections/src/components/collection-fields/collection-fields.jsx +++ b/packages/databases-collections/src/components/collection-fields/collection-fields.jsx @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import _ from 'lodash'; import { Accordion, spacing, css } from '@mongodb-js/compass-components'; -import CappedCollectionFields from './capped-collection-fields'; import CollectionName from './collection-name'; import DatabaseName from './database-name'; import hasTimeSeriesSupport from './has-time-series-support'; @@ -45,13 +44,11 @@ export default class CollectionFields extends PureComponent { }; state = { - isCapped: false, isCustomCollation: false, isTimeSeries: false, isClustered: false, isFLE2: false, fields: { - cappedSize: '', collation: {}, collectionName: '', databaseName: '', @@ -90,18 +87,8 @@ export default class CollectionFields extends PureComponent { }; buildOptions() { - const { - isCapped, - isCustomCollation, - isTimeSeries, - isClustered, - isFLE2, - fields, - } = this.state; - - const cappedOptions = isCapped - ? { capped: true, size: asNumber(fields.cappedSize) } - : {}; + const { isCustomCollation, isTimeSeries, isClustered, isFLE2, fields } = + this.state; const collationOptions = isCustomCollation ? { collation: fields.collation } @@ -141,7 +128,6 @@ export default class CollectionFields extends PureComponent { return omitEmptyFormFields({ ...collationOptions, - ...cappedOptions, ...timeSeriesOptions, ...clusteredOptions, ...fle2Options, @@ -151,19 +137,12 @@ export default class CollectionFields extends PureComponent { render() { const { serverVersion, withDatabase } = this.props; - const { - fields, - isCapped, - isCustomCollation, - isTimeSeries, - isClustered, - isFLE2, - } = this.state; + const { fields, isCustomCollation, isTimeSeries, isClustered, isFLE2 } = + this.state; const { collectionName, databaseName, - cappedSize, collation, timeSeries, expireAfterSeconds, @@ -189,7 +168,6 @@ export default class CollectionFields extends PureComponent { /> {hasTimeSeriesSupport(serverVersion) && (
- - this.setField('cappedSize', newCappedSizeString) - } - onChangeIsCapped={(capped) => - this.setState({ isCapped: capped }, this.updateOptions) - } - /> { @@ -243,7 +208,6 @@ export default class CollectionFields extends PureComponent { /> {hasClusteredCollectionSupport(serverVersion) && ( onChangeIsFLE2(checked)} // Queryable Encryption is the user-facing name of FLE2 label="Queryable Encryption" @@ -169,7 +168,6 @@ function FLE2Fields({ } FLE2Fields.propTypes = { - isCapped: PropTypes.bool.isRequired, isTimeSeries: PropTypes.bool.isRequired, isFLE2: PropTypes.bool.isRequired, onChangeIsFLE2: PropTypes.func.isRequired, diff --git a/packages/databases-collections/src/components/collection-fields/fle2-fields.spec.jsx b/packages/databases-collections/src/components/collection-fields/fle2-fields.spec.jsx index 02035ca5bb6..3d25b0ca714 100644 --- a/packages/databases-collections/src/components/collection-fields/fle2-fields.spec.jsx +++ b/packages/databases-collections/src/components/collection-fields/fle2-fields.spec.jsx @@ -15,7 +15,6 @@ describe('FLE2Fields [Component]', function () { component = mount( {}} - onChangeField={() => {}} - expireAfterSeconds="" - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('has the FLE2 checkbox disabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(true); - }); - }); - describe('when the isTimeSeries prop is true', function () { let component; @@ -143,7 +112,6 @@ describe('FLE2Fields [Component]', function () { component = mount( onChangeIsTimeSeries(checked)} toggled={isTimeSeries} label="Time-Series" @@ -174,7 +173,6 @@ function TimeSeriesFields({ } TimeSeriesFields.propTypes = { - isCapped: PropTypes.bool.isRequired, isTimeSeries: PropTypes.bool.isRequired, isClustered: PropTypes.bool.isRequired, isFLE2: PropTypes.bool.isRequired, diff --git a/packages/databases-collections/src/components/collection-fields/time-series-fields.spec.jsx b/packages/databases-collections/src/components/collection-fields/time-series-fields.spec.jsx index 95b529834eb..4439f94be35 100644 --- a/packages/databases-collections/src/components/collection-fields/time-series-fields.spec.jsx +++ b/packages/databases-collections/src/components/collection-fields/time-series-fields.spec.jsx @@ -14,7 +14,6 @@ describe('TimeSeriesFields [Component]', function () { component = mount( {}} - onChangeField={() => {}} - timeSeries={{}} - expireAfterSeconds="" - /> - ); - }); - - afterEach(function () { - component = null; - }); - - it('has the time-series checkbox disabled', function () { - expect(component.find('Checkbox').props().disabled).to.equal(true); - }); - }); - describe('when supportsFlexibleBucketConfiguration is true', function () { it('renders flexible bucketing options', function () { const component = mount( ; timeseries?: Record; @@ -389,7 +388,6 @@ export const createNamespace = ( await ds.createCollection(namespace, (options as any) ?? {}); const trackEvent = { - is_capped: !!data.options.capped, has_collation: !!data.options.collation, is_timeseries: !!data.options.timeseries, is_clustered: !!data.options.clusteredIndex,