import { Client } from '@botpress/client' import fs from 'fs/promises' import pathlib from 'path' import * as uuid from 'uuid' import impl from '../../src' import { ApiBot, ApiIntegration, ApiInterface, ApiPlugin } from '../api' import defaults from '../defaults' import * as retry from '../retry' import { Test } from '../typings' import * as utils from '../utils' type ARGV = typeof defaults & { botpressHome: string confirm: true workspaceId: string workspaceHandle: string token: string apiUrl: string tunnelUrl: string sdkPath?: string clientPath?: string } export const createDeployBotWithDependencies: Test = { name: 'cli should allow creating, building, deploying and managing a bot that has nested dependencies', handler: async ({ tmpDir, dependencies, logger, ...creds }) => { if (!_isBotpressWorkspace(creds.workspaceHandle, creds.workspaceId)) { // Unfortunately, only the botpress workspace can deploy interfaces logger.info( `Skipping test because the workspace is not a Botpress workspace (workspaceHandle: ${creds.workspaceHandle}, workspaceId: ${creds.workspaceId})` ) return } const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome') const fixturesDir = pathlib.join(process.cwd(), 'e2e', 'fixtures') const interfaceDir = pathlib.join(tmpDir, 'interfaces', 'interface-with-entities') const integrationDir = pathlib.join(tmpDir, 'integrations', 'integration-with-entity-dependency') const pluginDir = pathlib.join(tmpDir, 'plugins', 'plugin-with-interface-dependency') const botDir = pathlib.join(tmpDir, 'bots', 'bot-with-plugin-dependency') // copy all subfolders from fixturesDir to tmpDir: await fs.cp(fixturesDir, tmpDir, { recursive: true, force: true }) const argv = { ...defaults, botpressHome: botpressHomeDir, confirm: true, ...creds, } satisfies ARGV const client = new Client({ apiUrl: creds.apiUrl, token: creds.token, workspaceId: creds.workspaceId, retry: retry.config, }) await impl.login({ ...argv }).then(utils.handleExitCode) // Generate names: const interfaceName = _generateInterfaceName() const integrationName = _generateIntegrationName() const pluginName = _generatePluginName() const botName = _generateBotName() // Deploy dependencies: await using _iface = await _deployInterface({ argv, client, dependencies, workDir: interfaceDir, interfaceName }) await using _integration = await _deployIntegration({ argv, client, dependencies, workDir: integrationDir, integrationName, }) await using _plugin = await _deployPlugin({ argv, client, dependencies, workDir: pluginDir, pluginName }) // Deploy bot: await using bot = await _deployBot({ argv, client, dependencies, workDir: botDir, botName }) // Fetch and verify the deployed bot: const deployedBot = await client.getBot({ id: bot.botId }).then((res) => res.bot) // TODO: use vitest for our e2e tests and replace this series of if/else // checks with an expect().toMatchObject() assertion. // Check that the plugin is installed and enabled: if (!deployedBot.plugins?.['plugin-alias']?.enabled) { throw new Error('Expected bot to have plugin "plugin-alias"') } // Check that the schemas have been merged correctly: const action = deployedBot.actions?.['plugin-alias#doSomething'] if (!action?.input?.schema?.properties) { throw new Error('Expected bot to have action "plugin-alias#doSomething"') } const itemEntityProperties = Object.keys(action.input.schema.properties.item.properties) // Property defined by the interface: if (!itemEntityProperties.includes('name')) { throw new Error('Expected "plugin-alias#doSomething" action input to have item.name property') } // Property defined by the integration: if (!itemEntityProperties.includes('color')) { throw new Error('Expected "plugin-alias#doSomething" action input to have item.color property') } }, } const _isBotpressWorkspace = (workspaceHandle: string, workspaceId: string): boolean => workspaceHandle === 'botpress' || [ '6a76fa10-e150-4ff6-8f59-a300feec06c1', '95de33eb-1551-4af9-9088-e5dcb02efd09', '11111111-1111-1111-aaaa-111111111111', ].includes(workspaceId) const _deployInterface = async ({ argv, client, workDir, interfaceName, dependencies, }: { argv: ARGV client: Client workDir: string interfaceName: string dependencies: Record }) => { await _editPackageJson({ workDir, interfaceName }) await _installAndBuild({ argv, workDir, dependencies }) await impl .deploy({ ...argv, workDir, createNewBot: undefined, botId: undefined, visibility: 'public' }) .then(utils.handleExitCode) return { [Symbol.asyncDispose]: () => _deleteInterfaceIfExists(client, interfaceName), } } const _deployIntegration = async ({ argv, client, workDir, integrationName, dependencies, }: { argv: ARGV client: Client workDir: string integrationName: string dependencies: Record }) => { await _editPackageJson({ workDir, integrationName }) await _installAndBuild({ argv, workDir, dependencies }) await impl.deploy({ ...argv, workDir, createNewBot: undefined, botId: undefined }).then(utils.handleExitCode) return { [Symbol.asyncDispose]: () => _deleteIntegrationIfExists(client, integrationName), } } const _deployPlugin = async ({ argv, client, workDir, pluginName, dependencies, }: { argv: ARGV client: Client workDir: string pluginName: string dependencies: Record }) => { await _editPackageJson({ workDir, pluginName }) await _installAndBuild({ argv, workDir, dependencies }) await impl.deploy({ ...argv, workDir, createNewBot: undefined, botId: undefined }).then(utils.handleExitCode) return { [Symbol.asyncDispose]: () => _deletePluginIfExists(client, pluginName), } } const _deployBot = async ({ argv, client, workDir, botName, dependencies, }: { argv: ARGV client: Client workDir: string botName: string dependencies: Record }) => { await _editPackageJson({ workDir, botName }) await _installAndBuild({ argv, workDir, dependencies }) await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode) const bot = await _fetchBotByName(client, botName) if (!bot) { throw new Error(`Bot ${botName} should have been created`) } await impl.deploy({ ...argv, workDir, createNewBot: false, botId: bot.id }).then(utils.handleExitCode) return { botId: bot.id, [Symbol.asyncDispose]: () => _deleteBotIfExists(client, botName), } } const _installAndBuild = async ({ argv, workDir, dependencies, }: { argv: ARGV workDir: string dependencies: Record }) => { await utils.fixBotpressDependencies({ workDir, target: dependencies }) await utils.npmInstall({ workDir }).then(utils.handleExitCode) const prevDir = process.cwd() process.chdir(workDir) await impl .add({ ...argv, installPath: workDir, packageRef: undefined, useDev: false, alias: undefined, }) .then(utils.handleExitCode) process.chdir(prevDir) await impl.build({ ...argv, workDir }).then(utils.handleExitCode) } const _fetchBotByName = (client: Client, botName: string): Promise => client.list .bots({}) .collect() .then((bots) => bots.find(({ name }) => name === botName)) const _fetchIntegrationByName = (client: Client, integrationName: string): Promise => client.list .integrations({}) .collect() .then((integrations) => integrations.find(({ name }) => name === integrationName)) const _fetchInterfaceByName = (client: Client, interfaceName: string): Promise => client.list .publicInterfaces({}) .collect() .then((interfaces) => interfaces.find(({ name }) => name === interfaceName)) const _fetchPluginByName = (client: Client, pluginName: string): Promise => client.list .plugins({}) .collect() .then((plugins) => plugins.find(({ name }) => name === pluginName)) const _editPackageJson = async (payload: { workDir: string; [k: string]: string }) => { const { workDir, ...modifications } = payload const packageJsonPath = pathlib.join(workDir, 'package.json') const originalPackageJson: Record = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) const newPackageJson = { ...originalPackageJson, ...modifications, } await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2)) } const _generateInterfaceName = () => `iface-${uuid.v4()}` as const const _generateIntegrationName = () => `integration-${uuid.v4()}` as const const _generatePluginName = () => `plugin-${uuid.v4()}` as const const _generateBotName = () => `bot-${uuid.v4()}` as const const _deleteBotIfExists = async (client: Client, botName: string): Promise => { console.debug(`Deleting bot: ${botName}`) const bot = await _fetchBotByName(client, botName) if (!bot) { return } await _swallowErrors(client.deleteBot({ id: bot.id })).then(() => utils.sleep(10_000)) } const _deleteIntegrationIfExists = async (client: Client, integrationName: string): Promise => { console.debug(`Deleting integration: ${integrationName}`) const integration = await _fetchIntegrationByName(client, integrationName) if (!integration) { return } await _swallowErrors(client.deleteIntegration({ id: integration.id })) } const _deleteInterfaceIfExists = async (client: Client, interfaceName: string): Promise => { console.debug(`Deleting interface: ${interfaceName}`) const iface = await _fetchInterfaceByName(client, interfaceName) if (!iface) { return } await _swallowErrors(client.deleteInterface({ id: iface.id })) } const _deletePluginIfExists = async (client: Client, pluginName: string): Promise => { console.debug(`Deleting plugin: ${pluginName}`) const plugin = await _fetchPluginByName(client, pluginName) if (!plugin) { return } await _swallowErrors(client.deletePlugin({ id: plugin.id })) } const _swallowErrors = async (promise: Promise) => { try { await promise } catch (thrown: unknown) { // Swallow errors to allow cleanup to proceed: console.warn('Error during cleanup:', thrown) } }