chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { Client } from '@botpress/client'
export type ApiBot = Awaited<ReturnType<Client['listBots']>>['bots'][0]
export const fetchAllBots = async (client: Client): Promise<ApiBot[]> => await client.list.bots({}).collect()
export type ApiIntegration = Awaited<ReturnType<Client['listIntegrations']>>['integrations'][0]
export const fetchAllIntegrations = async (client: Client): Promise<ApiIntegration[]> =>
await client.list.integrations({}).collect()
export type ApiInterface = Awaited<ReturnType<Client['listInterfaces']>>['interfaces'][0]
export type ApiPlugin = Awaited<ReturnType<Client['listPlugins']>>['plugins'][0]
export const fetchAllPlugins = async (client: Client): Promise<ApiPlugin[]> => await client.list.plugins({}).collect()
+33
View File
@@ -0,0 +1,33 @@
const noBuild = false
const dryRun = false
const secrets = [] satisfies string[]
const sourceMap = false
const watch = true
const verbose = false
const confirm = true
const json = false
const allowDeprecated = false
const isPublic = false
const visibility = 'private' as const
const minify = true
const profile = undefined
const url = undefined
const bypassBreakingChangeDetection = false
export default {
minify,
noBuild,
dryRun,
secrets,
sourceMap,
watch,
verbose,
confirm,
json,
allowDeprecated,
public: isPublic,
visibility,
profile,
url,
bypassBreakingChangeDetection,
} as const
@@ -0,0 +1,27 @@
import * as sdk from '@botpress/sdk'
import integrationWithEntityDependency from './bp_modules/integration-with-entity-dependency'
import pluginWithInterfaceDependency from './bp_modules/plugin-with-interface-dependency'
export default new sdk.BotDefinition({})
.addIntegration(integrationWithEntityDependency, {
alias: 'integration-with-entity-dep',
enabled: true,
configuration: {},
})
.addPlugin(pluginWithInterfaceDependency, {
alias: 'plugin-alias',
configuration: {
foo: 'bar',
item: {
id: 'foo',
name: 'Foo',
color: 'blue',
},
},
dependencies: {
'interface-alias': {
integrationAlias: 'integration-with-entity-dep',
integrationInterfaceAlias: Object.keys(integrationWithEntityDependency.definition.interfaces)[0]!,
},
},
})
@@ -0,0 +1,18 @@
{
"name": "cli-fixtures-bot-with-plugin-dependency",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
},
"bpDependencies": {
"integration-with-entity-dependency": "../../integrations/integration-with-entity-dependency",
"plugin-with-interface-dependency": "../../plugins/plugin-with-interface-dependency"
}
}
@@ -0,0 +1,7 @@
import * as bp from '.botpress'
const bot = new bp.Bot({
actions: {},
})
export default bot
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,17 @@
import * as sdk from '@botpress/sdk'
import interfaceWithEntities from './bp_modules/interface-with-entities'
import * as packageJson from './package.json'
export default new sdk.IntegrationDefinition({
name: packageJson.integrationName,
version: '1.0.0',
entities: {
customItem: {
schema: sdk.z.object({
id: sdk.z.string(),
name: sdk.z.string().optional(),
color: sdk.z.string().optional(),
}),
},
},
}).extend(interfaceWithEntities, ({ entities }) => ({ entities: { item: entities.customItem } }))
@@ -0,0 +1,18 @@
{
"name": "cli-fixtures-integration-with-entity-dependency",
"integrationName": "cli-fixtures-integration-with-entity-dependency",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
},
"bpDependencies": {
"interface-with-entities": "../../interfaces/interface-with-entities"
}
}
@@ -0,0 +1,19 @@
import * as bp from '.botpress'
export default new bp.Integration({
register() {
throw new Error('Not implemented')
},
unregister() {
throw new Error('Not implemented')
},
actions: {
manipulateItem() {
throw new Error('Not implemented')
},
},
channels: {},
handler() {
throw new Error('Not implemented')
},
})
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,38 @@
import * as sdk from '@botpress/sdk'
import * as packageJson from './package.json'
export default new sdk.InterfaceDefinition({
name: packageJson.interfaceName,
version: '1.0.0',
entities: {
item: {
schema: sdk.z.object({
id: sdk.z.string(),
name: sdk.z.string().optional(),
}),
},
},
actions: {
manipulateItem: {
input: {
schema: (entities) =>
sdk.z.object({
item: entities.item,
foo: sdk.z.number(),
}),
},
output: {
schema: () => sdk.z.object({}),
},
},
},
events: {
somethingHappened: {
schema: (entities) =>
sdk.z.object({
item: entities.item,
message: sdk.z.string(),
}),
},
},
})
@@ -0,0 +1,15 @@
{
"name": "cli-fixtures-interface-with-entities",
"interfaceName": "cli-fixtures-interface-with-entities",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "*.ts", "*.json"]
}
@@ -0,0 +1,18 @@
{
"name": "cli-fixtures-plugin-with-interface-dependency",
"pluginName": "cli-fixtures-plugin-with-interface-dependency",
"scripts": {
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.16.4",
"typescript": "^5.6.3"
},
"bpDependencies": {
"interface-with-entities": "../../interfaces/interface-with-entities"
}
}
@@ -0,0 +1,32 @@
import * as sdk from '@botpress/sdk'
import interfaceWithEntities from './bp_modules/interface-with-entities'
import * as packageJson from './package.json'
export default new sdk.PluginDefinition({
name: packageJson.pluginName,
version: '1.0.0',
interfaces: {
'interface-alias': interfaceWithEntities,
},
configuration: {
schema: ({ entities }) =>
sdk.z.object({
item: entities['interface-alias'].item,
foo: sdk.z.literal('bar'),
}),
},
actions: {
doSomething: {
input: {
schema: ({ entities }) =>
sdk.z.object({
item: entities['interface-alias'].item,
bar: sdk.z.number(),
}),
},
output: {
schema: sdk.z.object({}),
},
},
},
})
@@ -0,0 +1,7 @@
import * as bp from '.botpress'
const plugin = new bp.Plugin({
actions: {},
})
export default plugin
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": ["es2022"],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"target": "es2017",
"paths": { "*": ["./*"] },
"outDir": "dist",
"checkJs": false,
"incremental": true,
"exactOptionalPropertyTypes": false,
"resolveJsonModule": true,
"noPropertyAccessFromIndexSignature": false,
"noUnusedLocals": false
},
"include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
}
+143
View File
@@ -0,0 +1,143 @@
import { Logger } from '@bpinternal/log4bot'
import yargs, { YargsConfig, YargsSchema } from '@bpinternal/yargs-extra'
import * as consts from '../src/consts'
import { requiredBotSecrets } from './tests/bot-secrets'
import { createDeployBot } from './tests/create-deploy-bot'
import { createDeployBotWithDependencies } from './tests/create-deploy-bot-with-dependencies'
import { createDeployIntegration } from './tests/create-deploy-integration'
import { devBot } from './tests/dev-bot'
import { installAllInterfaces } from './tests/install-interfaces'
import {
addIntegration,
addPlugin,
addLocalIntegrationKeepsRelativePath,
addDevIntegrationSkipsBpDependencies,
addDevIntegrationPreservesExistingBpDependency,
reinstallLocalIntegration,
} from './tests/install-package'
import { requiredIntegrationSecrets } from './tests/integration-secrets'
import {
enforceWorkspaceHandleIntegration,
enforceWorkspaceHandlePlugin,
prependWorkspaceHandleIntegration,
prependWorkspaceHandlePlugin,
} from './tests/manage-workspace-handle'
import { removePackage } from './tests/remove-package'
import { Test } from './typings'
import { sleep, TmpDirectory } from './utils'
const tests: Test[] = [
createDeployBot,
createDeployBotWithDependencies,
createDeployIntegration,
devBot,
requiredBotSecrets,
requiredIntegrationSecrets,
prependWorkspaceHandleIntegration,
enforceWorkspaceHandleIntegration,
prependWorkspaceHandlePlugin,
enforceWorkspaceHandlePlugin,
addIntegration,
addPlugin,
addLocalIntegrationKeepsRelativePath,
addDevIntegrationSkipsBpDependencies,
addDevIntegrationPreservesExistingBpDependency,
reinstallLocalIntegration,
installAllInterfaces,
removePackage,
]
const timeout = (ms: number) =>
sleep(ms).then(() => {
throw new Error(`Timeout after ${ms}ms`)
})
const TIMEOUT = 300_000
const configSchema = {
timeout: {
type: 'number',
default: TIMEOUT,
},
verbose: {
type: 'boolean',
default: false,
alias: 'v',
},
filter: {
type: 'string',
},
workspaceId: {
type: 'string',
demandOption: true,
},
workspaceHandle: {
type: 'string',
demandOption: true,
},
token: {
type: 'string',
demandOption: true,
},
apiUrl: {
type: 'string',
default: consts.defaultBotpressApiUrl,
},
tunnelUrl: {
type: 'string',
default: consts.defaultTunnelUrl,
},
sdkPath: {
type: 'string',
description: 'Path to the Botpress SDK to install; Allows using a version not released on NPM yet.',
},
clientPath: {
type: 'string',
description: 'Path to the Botpress Client to install; Allows using a version not released on NPM yet.',
},
} satisfies YargsSchema
const main = async (argv: YargsConfig<typeof configSchema>): Promise<never> => {
const logger = new Logger('e2e', { level: argv.verbose ? 'debug' : 'info' })
const filterRegex = argv.filter ? new RegExp(argv.filter) : null
const filteredTests = tests.filter(({ name }) => (filterRegex ? filterRegex.test(name) : true))
logger.info(`Running ${filteredTests.length} / ${tests.length} tests`)
const dependencies = { '@botpress/sdk': argv.sdkPath, '@botpress/client': argv.clientPath } satisfies Record<
string,
string | undefined
>
for (const { name, handler } of filteredTests) {
const logLine = `### Running test: "${name}" ###`
const logPad = '#'.repeat(logLine.length)
logger.info(logPad)
logger.info(logLine)
logger.info(logPad + '\n')
const loggerNamespace = name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '')
const tmpDir = TmpDirectory.create()
try {
const t0 = Date.now()
await Promise.race([
handler({ tmpDir: tmpDir.path, dependencies, logger: logger.sub(loggerNamespace), ...argv }),
timeout(argv.timeout),
])
const t1 = Date.now()
logger.info(`SUCCESS: "${name}" (${t1 - t0}ms)`)
} catch (thrown) {
const err = thrown instanceof Error ? thrown : new Error(`${thrown}`)
logger.attachError(err).error(`FAILURE: "${name}"`)
process.exit(1)
} finally {
tmpDir.cleanup()
}
}
logger.info('All tests passed')
process.exit(0)
}
void yargs.command('$0', 'Run E2E Tests', configSchema, main).parse()
+8
View File
@@ -0,0 +1,8 @@
import * as client from '@botpress/client'
export const config: client.RetryConfig = {
retries: 3,
retryCondition: (err) =>
client.axiosRetry.isNetworkOrIdempotentRequestError(err) || [429, 502].includes(err.response?.status ?? 0),
retryDelay: (retryCount) => retryCount * 1000,
}
+110
View File
@@ -0,0 +1,110 @@
import { Client } from '@botpress/client'
import * as sdk from '@botpress/sdk'
import fs from 'fs'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiBot, fetchAllBots } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
type SecretDef = NonNullable<sdk.BotDefinitionProps['secrets']>
const fetchBot = async (client: Client, botName: string): Promise<ApiBot | undefined> => {
const bots = await fetchAllBots(client)
return bots.find(({ name }) => name === botName)
}
const appendSecretDefinition = (originalTsContent: string, secrets: SecretDef): string => {
const secretEntries = Object.entries(secrets)
.map(([secretName, secretDef]) => ` ${secretName}: ${JSON.stringify(secretDef)},`)
.join('\n')
return originalTsContent.replace(
'new BotDefinition({})',
`new BotDefinition({\n secrets: {\n${secretEntries}\n },\n})`
)
}
export const requiredBotSecrets: Test = {
name: 'cli should require required bot secrets',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'bots')
const botName = uuid.v4()
const botDir = pathlib.join(baseDir, botName)
const definitionPath = pathlib.join(botDir, 'bot.definition.ts')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
const originalDefinition: string = fs.readFileSync(definitionPath, 'utf-8')
const modifiedDefinition = appendSecretDefinition(originalDefinition, {
REQUIRED_SECRET: {},
OPTIONAL_SECRET: { optional: true },
})
fs.writeFileSync(definitionPath, modifiedDefinition, 'utf-8')
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
const bot = await fetchBot(client, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
const { exitCode } = await impl.deploy({
...argv,
workDir: botDir,
secrets: ['OPTIONAL_SECRET=lol'],
botId: bot.id,
createNewBot: false,
})
if (exitCode === 0) {
throw new Error('Expected deploy to fail')
}
await impl
.deploy({
...argv,
workDir: botDir,
secrets: ['REQUIRED_SECRET=lol'],
botId: bot.id,
createNewBot: false,
})
.then(utils.handleExitCode)
const { bot: deployedBot } = await client.getBot({ id: bot.id })
if (!deployedBot.secrets.includes('REQUIRED_SECRET')) {
throw new Error(`Bot ${botName} should have secret REQUIRED_SECRET, got: ${deployedBot.secrets.join(', ')}`)
}
// cleanup deployed bot
await impl.bots.delete({ ...argv, botRef: bot.id }).then(utils.handleExitCode)
if (await fetchBot(client, botName)) {
throw new Error(`Bot ${botName} should have been deleted`)
}
},
}
@@ -0,0 +1,330 @@
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<string, string | undefined>
}) => {
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<string, string | undefined>
}) => {
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<string, string | undefined>
}) => {
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<string, string | undefined>
}) => {
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<string, string | undefined>
}) => {
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<ApiBot | undefined> =>
client.list
.bots({})
.collect()
.then((bots) => bots.find(({ name }) => name === botName))
const _fetchIntegrationByName = (client: Client, integrationName: string): Promise<ApiIntegration | undefined> =>
client.list
.integrations({})
.collect()
.then((integrations) => integrations.find(({ name }) => name === integrationName))
const _fetchInterfaceByName = (client: Client, interfaceName: string): Promise<ApiInterface | undefined> =>
client.list
.publicInterfaces({})
.collect()
.then((interfaces) => interfaces.find(({ name }) => name === interfaceName))
const _fetchPluginByName = (client: Client, pluginName: string): Promise<ApiPlugin | undefined> =>
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<string, unknown> = 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<unknown>) => {
try {
await promise
} catch (thrown: unknown) {
// Swallow errors to allow cleanup to proceed:
console.warn('Error during cleanup:', thrown)
}
}
@@ -0,0 +1,59 @@
import { Client } from '@botpress/client'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiBot, fetchAllBots } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const fetchBot = async (client: Client, botName: string): Promise<ApiBot | undefined> => {
const bots = await fetchAllBots(client)
return bots.find(({ name }) => name === botName)
}
export const createDeployBot: Test = {
name: 'cli should allow creating, building, deploying and managing a bot',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'bots')
const botName = uuid.v4()
const botDir = pathlib.join(baseDir, botName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
const bot = await fetchBot(client, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
await impl.deploy({ ...argv, workDir: botDir, createNewBot: false, botId: bot.id }).then(utils.handleExitCode)
await impl.bots.delete({ ...argv, botRef: bot.id }).then(utils.handleExitCode)
if (await fetchBot(client, botName)) {
throw new Error(`Bot ${botName} should have been deleted`)
}
},
}
@@ -0,0 +1,66 @@
import { Client } from '@botpress/client'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { ApiIntegration, fetchAllIntegrations } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const fetchIntegration = async (client: Client, integrationName: string): Promise<ApiIntegration | undefined> => {
const integrations = await fetchAllIntegrations(client)
return integrations.find(({ name }) => name === integrationName)
}
export const createDeployIntegration: Test = {
name: 'cli should allow creating, building, deploying and managing an integration',
handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const integrationSuffix = uuid.v4().replace(/-/g, '')
const name = `myintegration${integrationSuffix}`
const integrationName = `${workspaceHandle}/${name}`
const integrationDir = pathlib.join(baseDir, name)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: integrationName, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl
.deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: integrationDir })
.then(utils.handleExitCode)
logger.debug(`Fetching integration "${integrationName}"`)
const integration = await fetchIntegration(client, integrationName)
if (!integration) {
throw new Error(`Integration ${integrationName} should have been created`)
}
logger.debug(`Deleting integration "${integrationName}"`)
await impl.integrations.delete({ ...argv, integrationRef: integration.id }).then(utils.handleExitCode)
if (await fetchIntegration(client, integrationName)) {
throw new Error(`Integration ${integrationName} should have been deleted`)
}
},
}
+117
View File
@@ -0,0 +1,117 @@
import { Client, isApiError } from '@botpress/client'
import axios from 'axios'
import findProcess from 'find-process'
import fslib from 'fs'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const handleExitCode = ({ exitCode }: { exitCode: number }) => {
if (exitCode !== 0) {
throw new Error(`Command exited with code ${exitCode}`)
}
}
const fetchDevBotById = async (client: Client, id: string) => {
const { bot } = await client.getBot({ id }).catch((err) => {
if (isApiError(err) && err.type === 'ResourceNotFound') {
return { bot: undefined }
}
throw err
})
return bot
}
const readBotCache = async (
botDir: string
): Promise<{
botId?: string
tunnelId?: string
devId?: string
}> => {
const botCache = pathlib.join(botDir, '.botpress', 'project.cache.json')
if (!fslib.existsSync(botCache)) {
return {}
}
const cacheContent: string = await fslib.promises.readFile(botCache, 'utf-8')
try {
return JSON.parse(cacheContent)
} catch {
return {}
}
}
const PORT = 8075
export const devBot: Test = {
name: 'cli should allow creating and running a bot locally',
handler: async ({ tmpDir, tunnelUrl, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'bots')
const botName = uuid.v4()
const tunnelId = uuid.v4()
const botDir = pathlib.join(baseDir, botName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl.init({ ...argv, workDir: baseDir, name: botName, type: 'bot', template: 'empty' }).then(handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(handleExitCode)
await impl.login({ ...argv }).then(handleExitCode)
const cmdPromise = impl
.dev({ ...argv, workDir: botDir, port: PORT, tunnelUrl, tunnelId, noSecretCaching: true })
.then(handleExitCode)
await utils.sleep(5000)
const allProcess = await findProcess('port', PORT)
const [botProcess] = allProcess
if (allProcess.length > 1) {
throw new Error(`Expected to find only one process listening on port ${PORT}`)
}
if (!botProcess) {
throw new Error(`Expected to find a process listening on port ${PORT}`)
}
const botCache = await readBotCache(botDir)
const { devId: botId } = botCache
if (!botId) {
throw new Error('Expected devId to be set in project cache')
}
const resp = await axios.get(`http://localhost:${PORT}/health`)
if (resp.status !== 200) {
throw new Error(`Expected health endpoint to return 200, got ${resp.status}`)
}
process.kill(botProcess.pid)
await cmdPromise
const apiBot = await fetchDevBotById(client, botId)
if (!apiBot) {
throw new Error(`Dev Bot ${botId} should have been created in the backend`)
}
await impl.bots.delete({ ...argv, botRef: apiBot.id }).then(utils.handleExitCode)
if (await fetchDevBotById(client, botId)) {
throw new Error(`Dev bot ${botId} should have been deleted`)
}
},
}
@@ -0,0 +1,49 @@
import pathlib from 'path'
import impl from '../../src'
import defaults from '../defaults'
import { Test } from '../typings'
import * as utils from '../utils'
export const installAllInterfaces: Test = {
name: 'cli should allow installing public interfaces',
handler: async ({ tmpDir, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'interfaces')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl.login({ ...argv }).then(utils.handleExitCode)
const interfaces: string[] = [
'creatable',
'deletable',
'hitl',
'listable',
'llm',
'readable',
'speech-to-text',
'text-to-image',
'typing-indicator',
'updatable',
]
for (const iface of interfaces) {
logger.info(`Installing interface: ${iface}`)
await impl
.add({
...argv,
packageRef: iface,
installPath: baseDir,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
// TODO: also run a type check on the installed interface
}
},
}
+508
View File
@@ -0,0 +1,508 @@
import * as client from '@botpress/client'
import * as sdk from '@botpress/sdk'
import * as fslib from 'fs'
import * as pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import * as apiUtils from '../../src/api'
import * as consts from '../../src/consts'
import * as cliUtils from '../../src/utils'
import { ApiBot, fetchAllBots } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test, TestProps } from '../typings'
import * as utils from '../utils'
const issueSchema = sdk.z.object({
id: sdk.z.string(),
priority: sdk.z.enum(['high', 'medium', 'low']),
title: sdk.z.string(),
body: sdk.z.string(),
})
const INTEGRATION = {
version: '0.0.1',
title: 'An Integration',
description: 'An integration',
user: { tags: { id: { title: 'ID', description: 'The user ID' } } },
configuration: { schema: sdk.z.object({}), identifier: { required: true, linkTemplateScript: '' } },
configurations: {
token: {
title: 'API Token',
description: 'The token to authenticate with',
schema: sdk.z.object({ token: sdk.z.string() }),
},
},
actions: {
getIssue: {
input: { schema: sdk.z.object({ id: sdk.z.string() }) },
output: { schema: issueSchema },
},
},
events: {
issueCreated: {
title: 'Issue Created',
description: 'An issue was created',
schema: issueSchema,
},
},
channels: {
issueComment: {
title: 'Issue Comment',
description: 'Comment on an issue',
messages: { text: sdk.messages.defaults.text },
conversation: { tags: { id: { title: 'ID', description: 'The issue ID' } } },
message: { tags: { id: { title: 'ID', description: 'The issue comment ID' } } },
},
},
entities: {
issue: {
title: 'Issue',
description: 'An issue',
schema: issueSchema,
},
},
states: {
lastCreatedIssue: {
type: 'integration',
schema: issueSchema,
},
},
identifier: {
extractScript: '',
},
} satisfies Omit<sdk.IntegrationDefinitionProps, 'name'>
const getHomeDir = (props: { tmpDir: string }) => pathlib.join(props.tmpDir, '.botpresshome')
const initBot = async (props: TestProps, definitionFile: string) => {
const { tmpDir, dependencies, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir(props),
confirm: true,
...creds,
}
const botName = uuid.v4().replace(/-/g, '')
const botDir = pathlib.join(tmpDir, botName)
await impl
.init({ ...argv, workDir: tmpDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
await fslib.promises.writeFile(pathlib.join(botDir, 'bot.definition.ts'), definitionFile)
return { botDir }
}
// TODO: add an equivalent test with an interface once interfaces can be created by any workspace
export const addIntegration: Test = {
name: 'cli should allow installing an integration',
handler: async (props) => {
const { tmpDir, workspaceHandle, logger, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const bpClient = new client.Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
const integrationSuffix = uuid.v4().replace(/-/g, '')
const name = `myintegration${integrationSuffix}`
const integrationName = `${workspaceHandle}/${name}`
const createIntegrationBody = await apiUtils.prepareCreateIntegrationBody(
new sdk.IntegrationDefinition({
...INTEGRATION,
name: integrationName,
})
)
const { integration } = await bpClient.createIntegration({
...createIntegrationBody,
dev: true, // this way we ensure the integration will eventually be janitored if the test fails
url: creds.apiUrl,
})
try {
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
[
'import * as sdk from "@botpress/sdk"',
`import anIntegration from "./bp_modules/${workspaceHandle}-${name}"`,
'export default new sdk.BotDefinition({}).addIntegration(anIntegration, {',
' enabled: true,',
' configurationType: null,',
' configuration: {},',
'})',
].join('\n')
)
logger.info('Logging in')
await impl.login(argv).then(utils.handleExitCode)
logger.info('Installing integration')
await impl
.add({
...argv,
installPath: botDir,
packageRef: integration.id,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
logger.info('Building bot')
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await utils.tscCheck({ workDir: botDir }).then(utils.handleExitCode)
} finally {
await impl.integrations
.delete({
...argv,
integrationRef: integration.id,
})
.catch(() => {
logger.warn(`Failed to delete integration ${integration.id}`) // this is not the purpose of the test
})
}
},
}
export const addPlugin: Test = {
name: 'cli should allow installing a plugin',
handler: async (props) => {
const { tmpDir, workspaceHandle, logger, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const bpClient = new client.Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
const pluginSuffix = uuid.v4().replace(/-/g, '')
const name = `myplugin${pluginSuffix}`
const pluginName = `${workspaceHandle}/${name}`
const pluginVersion = '0.1.0'
const pluginAlias = `alias-${uuid.v4().replace(/-/g, '')}`
const botName = uuid.v4()
const createPluginBody = await apiUtils.prepareCreatePluginBody(
new sdk.PluginDefinition({
name: pluginName,
version: pluginVersion,
})
)
const { plugin } = await bpClient.createPlugin({
...createPluginBody,
code: { browser: 'export default {}', node: 'export default {}' },
})
let bot: ApiBot | undefined
try {
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
[
'import * as sdk from "@botpress/sdk"',
`import aPlugin from "./bp_modules/${workspaceHandle}-${name}"`,
'export default new sdk.BotDefinition({}).addPlugin(aPlugin, {',
` alias: '${pluginAlias}',`,
' configuration: {},',
' dependencies: {},',
'})',
].join('\n')
)
logger.info('Logging in')
await impl.login(argv).then(utils.handleExitCode)
logger.info('Installing plugin')
await impl
.add({
...argv,
installPath: botDir,
packageRef: plugin.id,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
logger.info('Building bot')
await impl.build({ ...argv, workDir: botDir }).then(utils.handleExitCode)
await utils.tscCheck({ workDir: botDir }).then(utils.handleExitCode)
logger.info('Deploying bot')
await impl.bots.create({ ...argv, name: botName, ifNotExists: false }).then(utils.handleExitCode)
bot = await fetchBot(bpClient, botName)
if (!bot) {
throw new Error(`Bot ${botName} should have been created`)
}
await impl.deploy({ ...argv, workDir: botDir, createNewBot: false, botId: bot.id }).then(utils.handleExitCode)
logger.info('Checking if plugin is installed')
const plugins = await bpClient.getBot({ id: bot.id }).then(({ bot }) => bot.plugins)
const isPluginInstalled = Object.entries(plugins).some(
([alias, instance]) => alias === pluginAlias && instance.id === plugin.id
)
if (!isPluginInstalled) {
throw new Error(`Plugin ${plugin.id} should have been installed in bot ${bot.id}`)
}
} finally {
if (bot) {
await impl.bots
.delete({
...argv,
botRef: bot.id,
})
.catch(() => {
logger.warn(`Failed to delete bot ${bot!.id}`) // this is not the purpose of the test
})
}
await impl.plugins
.delete({
...argv,
pluginRef: plugin.id,
})
.catch(() => {
logger.warn(`Failed to delete plugin ${plugin.id}`) // this is not the purpose of the test
})
}
},
}
const fetchBot = async (client: client.Client, botName: string): Promise<ApiBot | undefined> =>
await fetchAllBots(client).then((bots) => bots.find(({ name }) => name === botName))
const initIntegration = async (props: TestProps, integrationName: string) => {
const { tmpDir, dependencies, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const integrationDir = pathlib.join(tmpDir, integrationName)
await impl
.init({
...argv,
workDir: tmpDir,
name: `${workspaceHandle}/${integrationName}`,
type: 'integration',
template: 'empty',
})
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
return { integrationDir }
}
export const addLocalIntegrationKeepsRelativePath: Test = {
name: 'cli should store the original relative path in bpDependencies when adding a local integration',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
workspaceHandle,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
// The stored path should be relative to installPath (botDir), not process.cwd()
// Use integrationDir (absolute) as packageRef to avoid cross-drive issues on Windows
const rel = pathlib.relative(botDir, integrationDir).split(pathlib.sep).join('/')
const expectedStoredPath = rel.startsWith('.') ? rel : `./${rel}`
logger.info('Adding local integration via relative path')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: false, alias: undefined })
.then(utils.handleExitCode)
const pkgJson = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const bpDeps = pkgJson.bpDependencies as Record<string, string> | undefined
if (!bpDeps) {
throw new Error('Expected bpDependencies to be set in package.json after bp add')
}
const storedPath = bpDeps[fullIntegrationName]
if (storedPath !== expectedStoredPath) {
throw new Error(`Expected bpDependencies to store "${expectedStoredPath}" but got "${storedPath}"`)
}
},
}
export const addDevIntegrationSkipsBpDependencies: Test = {
name: 'cli should not modify bpDependencies when adding a local integration with --use-dev',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
workspaceHandle,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
// Simulate a dev integration by writing a devId to the project cache
const cacheDir = pathlib.join(integrationDir, '.botpress')
fslib.mkdirSync(cacheDir, { recursive: true })
fslib.writeFileSync(
pathlib.join(cacheDir, 'project.cache.json'),
JSON.stringify({ devId: 'fake-dev-integration-id' })
)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
logger.info('Adding dev integration')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: true, alias: undefined })
.then(utils.handleExitCode)
const pkgJson = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const bpDeps = pkgJson.bpDependencies as Record<string, string> | undefined
if (bpDeps?.[fullIntegrationName] !== undefined) {
throw new Error(
`Expected "${fullIntegrationName}" to NOT be in bpDependencies when using --use-dev, but got: ${JSON.stringify(bpDeps)}`
)
}
},
}
export const addDevIntegrationPreservesExistingBpDependency: Test = {
name: 'cli should not overwrite existing bpDependencies entry when re-adding as dev integration',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
workspaceHandle,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
logger.info('Adding local integration (no --use-dev) to populate bpDependencies')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: false, alias: undefined })
.then(utils.handleExitCode)
const pkgJsonAfterFirst = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const storedRelativePath = (pkgJsonAfterFirst.bpDependencies as Record<string, string> | undefined)?.[
fullIntegrationName
]
if (!storedRelativePath) {
throw new Error('Expected bpDependencies to contain an entry after initial bp add')
}
// Simulate the integration being run with bp dev (devId present in cache)
const cacheDir = pathlib.join(integrationDir, '.botpress')
fslib.mkdirSync(cacheDir, { recursive: true })
fslib.writeFileSync(
pathlib.join(cacheDir, 'project.cache.json'),
JSON.stringify({ devId: 'fake-dev-integration-id' })
)
logger.info('Re-adding same integration with --use-dev')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: true, alias: undefined })
.then(utils.handleExitCode)
const pkgJsonAfterDev = JSON.parse(fslib.readFileSync(pathlib.join(botDir, 'package.json'), 'utf8'))
const bpDepsAfterDev = pkgJsonAfterDev.bpDependencies as Record<string, string> | undefined
if (bpDepsAfterDev?.[fullIntegrationName] !== storedRelativePath) {
throw new Error(
`Expected bpDependencies["${fullIntegrationName}"] to remain "${storedRelativePath}" after --use-dev add, but got: ${JSON.stringify(bpDepsAfterDev?.[fullIntegrationName])}`
)
}
},
}
export const reinstallLocalIntegration: Test = {
name: 'cli should reinstall local integration from bpDependencies regardless of cwd',
handler: async (props) => {
const { tmpDir, logger, workspaceHandle, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir({ tmpDir }),
confirm: true,
...creds,
}
const integrationName = `myintegration${utils.getUUID()}`
const fullIntegrationName = `${workspaceHandle}/${integrationName}`
const { integrationDir } = await initIntegration(props, integrationName)
logger.info('Initializing bot')
const { botDir } = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
logger.info('Adding local integration')
await impl
.add({ ...argv, installPath: botDir, packageRef: integrationDir, useDev: false, alias: undefined })
.then(utils.handleExitCode)
const moduleDir = pathlib.join(botDir, consts.installDirName, cliUtils.casing.to.kebabCase(fullIntegrationName))
if (!fslib.existsSync(moduleDir)) {
throw new Error(`Expected bp_modules to contain "${fullIntegrationName}" after bp add`)
}
logger.info('Deleting bp_modules')
fslib.rmSync(pathlib.join(botDir, 'bp_modules'), { recursive: true, force: true })
logger.info('Reinstalling from bpDependencies')
await impl
.add({ ...argv, installPath: botDir, packageRef: undefined, useDev: false, alias: undefined })
.then(utils.handleExitCode)
if (!fslib.existsSync(moduleDir)) {
throw new Error(`Expected bp_modules to contain "${fullIntegrationName}" after reinstall`)
}
},
}
+37
View File
@@ -0,0 +1,37 @@
import pathlib from 'path'
import impl from '../../src'
import defaults from '../defaults'
import { Test } from '../typings'
import * as utils from '../utils'
export const installAllPlugins: Test = {
name: 'cli should allow installing public plugins',
handler: async ({ tmpDir, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'plugins')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl.login({ ...argv }).then(utils.handleExitCode)
const plugins: string[] = ['hitl', 'file-synchronizer']
for (const iface of plugins) {
logger.info(`Installing plugin: ${iface}`)
await impl
.add({
...argv,
packageRef: iface,
installPath: baseDir,
useDev: false,
alias: undefined,
})
.then(utils.handleExitCode)
}
},
}
@@ -0,0 +1,115 @@
import { Client } from '@botpress/client'
import * as sdk from '@botpress/sdk'
import fs from 'fs'
import pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import { fetchAllIntegrations, ApiIntegration } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
type SecretDef = NonNullable<sdk.IntegrationDefinitionProps['secrets']>
const fetchIntegration = async (client: Client, integrationName: string): Promise<ApiIntegration | undefined> => {
const integrations = await fetchAllIntegrations(client)
return integrations.find(({ name }) => name === integrationName)
}
const appendSecretDefinition = (originalTsContent: string, secrets: SecretDef): string => {
const regex = /( *)version: (['"].*['"]),/
const replacement = [
'version: $2,',
'secrets: {',
...Object.entries(secrets).map(([secretName, secretDef]) => ` ${secretName}: ${JSON.stringify(secretDef)},`),
'},',
]
.map((s) => `$1${s}`) // for indentation
.join('\n')
const modifiedTsContent = originalTsContent.replace(regex, replacement)
return modifiedTsContent
}
export const requiredIntegrationSecrets: Test = {
name: 'cli should require required integration secrets',
handler: async ({ tmpDir, workspaceHandle, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const integrationSuffix = uuid.v4().replace(/-/g, '')
const name = `myintegration${integrationSuffix}`
const integrationName = `${workspaceHandle}/${name}`
const integrationDir = pathlib.join(baseDir, name)
const definitionPath = pathlib.join(integrationDir, 'integration.definition.ts')
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: integrationName, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
const originalDefinition: string = fs.readFileSync(definitionPath, 'utf-8')
const modifiedDefinition = appendSecretDefinition(originalDefinition, {
REQUIRED_SECRET: {},
OPTIONAL_SECRET: { optional: true },
})
fs.writeFileSync(definitionPath, modifiedDefinition, 'utf-8')
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
const { exitCode } = await impl.deploy({
...argv,
workDir: integrationDir,
secrets: ['OPTIONAL_SECRET=lol'],
botId: undefined,
createNewBot: undefined,
})
if (exitCode === 0) {
throw new Error('Expected deploy to fail')
}
await impl
.deploy({
...argv,
workDir: integrationDir,
secrets: ['REQUIRED_SECRET=lol'],
botId: undefined,
createNewBot: undefined,
})
.then(utils.handleExitCode)
// cleanup deployed integration
const { integration: deployedIntegration } = await client.getIntegrationByName({
name: integrationName,
version: '0.1.0',
})
if (!deployedIntegration.secrets.includes('REQUIRED_SECRET')) {
throw new Error(
`Integration ${integrationName} should have secret REQUIRED_SECRET, got: ${deployedIntegration.secrets.join(', ')}`
)
}
await client.deleteIntegration({ id: deployedIntegration.id })
if (await fetchIntegration(client, integrationName)) {
throw new Error(`Integration ${integrationName} should have been deleted`)
}
},
}
@@ -0,0 +1,228 @@
import { Client } from '@botpress/client'
import * as fs from 'fs'
import pathlib from 'path'
import impl from '../../src'
import { ApiIntegration, fetchAllIntegrations, ApiPlugin, fetchAllPlugins } from '../api'
import defaults from '../defaults'
import * as retry from '../retry'
import { Test } from '../typings'
import * as utils from '../utils'
const fetchIntegration = async (client: Client, integrationName: string): Promise<ApiIntegration | undefined> => {
const integrations = await fetchAllIntegrations(client)
return integrations.find(({ name }) => name === integrationName)
}
const fetchPlugin = async (client: Client, pluginName: string): Promise<ApiPlugin | undefined> => {
const plugins = await fetchAllPlugins(client)
return plugins.find(({ name }) => name === pluginName)
}
export const prependWorkspaceHandleIntegration: Test = {
name: 'cli should automatically preprend the workspace handle to the integration name when deploying',
handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const integrationSuffix = utils.getUUID()
const integrationName = `myintegration${integrationSuffix}`
const integrationNameWithHandle = `${workspaceHandle}/${integrationName}`
const integrationDirName = integrationName
const integrationDir = pathlib.join(baseDir, integrationDirName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: integrationNameWithHandle, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
// Remove handle from package.json:
const pkgJsonPath = pathlib.join(integrationDir, 'package.json')
const pkgJson = await fs.promises.readFile(pkgJsonPath, 'utf-8').then(JSON.parse)
pkgJson.name = pkgJson.name.slice(workspaceHandle.length + 1)
await fs.promises.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl
.deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: integrationDir })
.then(utils.handleExitCode)
logger.debug(`Fetching integration "${integrationName}"`)
let integration = await fetchIntegration(client, integrationName)
if (integration) {
throw new Error(`Integration ${integrationName} should not have been created`)
}
const expectedIntegrationName = `${workspaceHandle}/${integrationName}`
logger.debug(`Fetching integration "${expectedIntegrationName}"`)
integration = await fetchIntegration(client, expectedIntegrationName)
if (!integration) {
throw new Error(`Integration ${expectedIntegrationName} should have been created`)
}
logger.debug(`Deleting integration "${integrationName}"`)
await impl.integrations.delete({ ...argv, integrationRef: integration.id }).then(({ exitCode }) => {
exitCode !== 0 && logger.warn(`Failed to delete integration "${integrationName}"`) // not enough to fail the test
})
},
}
export const prependWorkspaceHandlePlugin: Test = {
name: 'cli should automatically prepend the workspace handle to the plugin name when deploying',
handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'plugins')
const pluginSuffix = utils.getUUID()
const pluginName = `myplugin${pluginSuffix}`
const pluginNameWithHandle = `${workspaceHandle}/${pluginName}`
const pluginDir = pathlib.join(baseDir, pluginName)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
const client = new Client({
apiUrl: creds.apiUrl,
token: creds.token,
workspaceId: creds.workspaceId,
retry: retry.config,
})
await impl
.init({ ...argv, workDir: baseDir, name: pluginNameWithHandle, type: 'plugin', template: 'empty' })
.then(utils.handleExitCode)
// Remove handle from package.json pluginName field:
const pkgJsonPath = pathlib.join(pluginDir, 'package.json')
const pkgJson = await fs.promises.readFile(pkgJsonPath, 'utf-8').then(JSON.parse)
pkgJson.pluginName = pluginName
await fs.promises.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
await utils.fixBotpressDependencies({ workDir: pluginDir, target: dependencies })
await utils.npmInstall({ workDir: pluginDir }).then(utils.handleExitCode)
await impl.build({ ...argv, workDir: pluginDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
await impl
.deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: pluginDir })
.then(utils.handleExitCode)
logger.debug(`Fetching plugin "${pluginName}"`)
let plugin = await fetchPlugin(client, pluginName)
if (plugin) {
throw new Error(`Plugin ${pluginName} should not have been created without handle prefix`)
}
const expectedPluginName = `${workspaceHandle}/${pluginName}`
logger.debug(`Fetching plugin "${expectedPluginName}"`)
plugin = await fetchPlugin(client, expectedPluginName)
if (!plugin) {
throw new Error(`Plugin ${expectedPluginName} should have been created`)
}
logger.debug(`Deleting plugin "${expectedPluginName}"`)
await impl.plugins.delete({ ...argv, pluginRef: plugin.id }).then(({ exitCode }) => {
exitCode !== 0 && logger.warn(`Failed to delete plugin "${expectedPluginName}"`)
})
},
}
export const enforceWorkspaceHandleIntegration: Test = {
name: 'cli should fail when attempting to deploy an integration with incorrect workspace handle',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'integrations')
const randomSuffix = utils.getUUID().slice(0, 8)
const name = 'myintegration'
const handle = `myhandle${randomSuffix}`
const integrationName = `${handle}/${name}`
const integrationDir = pathlib.join(baseDir, name)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl
.init({ ...argv, workDir: baseDir, name: integrationName, type: 'integration', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: integrationDir, target: dependencies })
await utils.npmInstall({ workDir: integrationDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
const { exitCode } = await impl.deploy({
...argv,
createNewBot: undefined,
botId: undefined,
workDir: integrationDir,
})
if (exitCode === 0) {
throw new Error(`Integration ${integrationName} should not have been deployed`)
}
},
}
export const enforceWorkspaceHandlePlugin: Test = {
name: 'cli should fail when attempting to deploy a plugin with incorrect workspace handle',
handler: async ({ tmpDir, dependencies, ...creds }) => {
const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome')
const baseDir = pathlib.join(tmpDir, 'plugins')
const randomSuffix = utils.getUUID().slice(0, 8)
const name = 'myplugin'
const handle = `myhandle${randomSuffix}`
const pluginName = `${handle}/${name}`
const pluginDir = pathlib.join(baseDir, name)
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...creds,
}
await impl
.init({ ...argv, workDir: baseDir, name: pluginName, type: 'plugin', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: pluginDir, target: dependencies })
await utils.npmInstall({ workDir: pluginDir }).then(utils.handleExitCode)
await impl.login({ ...argv }).then(utils.handleExitCode)
const { exitCode } = await impl.deploy({
...argv,
createNewBot: undefined,
botId: undefined,
workDir: pluginDir,
})
if (exitCode === 0) {
throw new Error(`Plugin ${pluginName} should not have been deployed`)
}
},
}
+92
View File
@@ -0,0 +1,92 @@
import * as fslib from 'fs'
import * as pathlib from 'path'
import * as uuid from 'uuid'
import impl from '../../src'
import defaults from '../defaults'
import { Test, TestProps } from '../typings'
import * as utils from '../utils'
const getHomeDir = (props: { tmpDir: string }) => pathlib.join(props.tmpDir, '.botpresshome')
const initBot = async (props: TestProps, definitionFile: string) => {
const { tmpDir, dependencies, ...creds } = props
const argv = {
...defaults,
botpressHome: getHomeDir(props),
confirm: true,
...creds,
}
const botName = uuid.v4().replace(/-/g, '')
const botDir = pathlib.join(tmpDir, botName)
await impl
.init({ ...argv, workDir: tmpDir, name: botName, type: 'bot', template: 'empty' })
.then(utils.handleExitCode)
await utils.fixBotpressDependencies({ workDir: botDir, target: dependencies })
await utils.npmInstall({ workDir: botDir }).then(utils.handleExitCode)
fslib.writeFileSync(pathlib.join(botDir, 'bot.definition.ts'), definitionFile)
return { botDir }
}
let botDir: string | undefined = undefined
const ALIAS = 'alias'
export const removePackage: Test = {
name: 'cli should allow removing a plugin',
handler: async (props) => {
try {
const botpressHomeDir = pathlib.join(props.tmpDir, '.botpresshome')
const initializedBot = await initBot(
props,
['import * as sdk from "@botpress/sdk"', 'export default new sdk.BotDefinition({})'].join('\n')
)
botDir = initializedBot.botDir
const argv = {
...defaults,
botpressHome: botpressHomeDir,
confirm: true,
...props,
}
await impl
.login({
...argv,
})
.then(utils.handleExitCode)
const plugin: string = 'hitl'
props.logger.info(`Installing plugin: ${plugin}`)
await impl
.add({
...argv,
packageRef: plugin,
installPath: botDir,
useDev: false,
alias: ALIAS,
})
.then(utils.handleExitCode)
await impl.remove({
...argv,
alias: ALIAS,
workDir: botDir,
})
const aliasPath = pathlib.join(botDir, 'bp_modules', ALIAS)
const exists = await fslib.promises
.access(aliasPath)
.then(() => true)
.catch(() => false)
if (exists) {
throw new Error(`Expected ${aliasPath} to not exist`)
}
const pkgJsonPath = pathlib.join(botDir, 'package.json')
const pkgJson = JSON.parse(fslib.readFileSync(pkgJsonPath, 'utf-8'))
if (pkgJson.bpDependencies && Object.keys(pkgJson.bpDependencies).length !== 0) {
throw new Error('Expected bpDependencies to be empty')
}
} finally {
if (botDir) fslib.rmSync(botDir, { force: true, recursive: true })
}
},
}
+19
View File
@@ -0,0 +1,19 @@
import { Logger } from '@bpinternal/log4bot'
export type TestProps = {
logger: Logger
tmpDir: string
workspaceId: string
workspaceHandle: string
token: string
apiUrl: string
tunnelUrl: string
dependencies: Record<string, string | undefined>
sdkPath?: string
clientPath?: string
}
export type Test = {
name: string
handler: (props: TestProps) => Promise<void>
}
+101
View File
@@ -0,0 +1,101 @@
import childprocess from 'child_process'
import fs from 'fs'
import _ from 'lodash'
import pathlib from 'path'
import tmp from 'tmp'
import * as uuid from 'uuid'
type PackageJson = {
name: string
version?: string
description?: string
scripts?: Record<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
export const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
export class TmpDirectory {
private _closed = false
public static create() {
return new TmpDirectory(tmp.dirSync({ unsafeCleanup: true }))
}
private constructor(private _res: tmp.DirResult) {}
public get path() {
if (this._closed) {
throw new Error('Cannot access tmp directory after cleanup')
}
return this._res.name
}
public cleanup() {
if (this._closed) {
return
}
this._res.removeCallback()
}
}
export type RunCommandOptions = {
workDir: string
}
export type RunCommandOutput = {
exitCode: number
}
export const runCommand = async (cmd: string, { workDir }: RunCommandOptions): Promise<RunCommandOutput> => {
const [program, ...args] = cmd.split(' ')
if (!program) {
throw new Error('Cannot run empty command')
}
const { error, status } = childprocess.spawnSync(program, args, {
cwd: workDir,
stdio: 'inherit',
shell: true,
env: process.env,
})
if (error) {
throw error
}
return { exitCode: status ?? 0 }
}
export const npmInstall = ({ workDir }: RunCommandOptions): Promise<RunCommandOutput> => {
return runCommand('pnpm install', { workDir })
}
export const tscCheck = ({ workDir }: RunCommandOptions): Promise<RunCommandOutput> => {
return runCommand('tsc --noEmit', { workDir })
}
export const fixBotpressDependencies = async ({
workDir,
target,
}: {
workDir: string
target: Record<string, string | undefined>
}) => {
const packageJsonPath = pathlib.join(workDir, 'package.json')
const originalPackageJson: PackageJson = require(packageJsonPath)
const newPackageJson = {
...originalPackageJson,
dependencies: _.mapValues(originalPackageJson.dependencies ?? {}, (version, name) => target[name] ?? version),
}
fs.writeFileSync(packageJsonPath, JSON.stringify(newPackageJson, null, 2))
}
export const handleExitCode = ({ exitCode }: { exitCode: number }) => {
if (exitCode !== 0) {
throw new Error(`Command exited with code ${exitCode}`)
}
}
export const getUUID = () => uuid.v4().replace(/-/g, '')