From c6dd1f21520ad6b3f45ebfb5e1116ebef0e0e4be Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:29:07 +0000 Subject: [PATCH] use local server in test --- apps/simplex-calculator-bot/README.md | 6 ++ .../calculatorBot.test.ts | 30 +++++- apps/simplex-calculator-bot/test/smpServer.ts | 101 ++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 apps/simplex-calculator-bot/test/smpServer.ts diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index c24e4f033e..ca86bc18f0 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -45,3 +45,9 @@ The bot prints its address on start. The database is stored in `./data`. ```bash npm test ``` + +The end-to-end test runs a local SMP server with the TLS certificates from `tests/fixtures/tls`. On Linux, the test downloads `smp-server` from [simplexmq releases](https://github.com/simplex-chat/simplexmq/releases) to `node_modules/.cache` on the first run. To use another build, or on other systems, pass its path in `SMP_SERVER` variable: + +```bash +SMP_SERVER=/path/to/smp-server npm test +``` diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts index eb2afa85d7..effb491aad 100644 --- a/apps/simplex-calculator-bot/calculatorBot.test.ts +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -1,10 +1,29 @@ -import {test, expect} from "vitest" +import {afterAll, beforeAll, test, expect} from "vitest" import {mkdtempSync, rmSync} from "fs" import {tmpdir} from "os" import {join} from "path" import {CEvt, T} from "@simplex-chat/types" -import {api, util} from "simplex-chat" +import {api, bot, util} from "simplex-chat" import {runCalculatorBot} from "./src/calculatorBot.js" +import {SmpServer, startSmpServer} from "./test/smpServer.js" + +let smpServer: SmpServer + +beforeAll(async () => { smpServer = await startSmpServer() }, 120000) + +afterAll(() => smpServer?.stop()) + +async function useSmpServer(chat: api.ChatApi): Promise { + expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"}) +} + +async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise { + const chat = await api.ChatApi.init(dbOpts) + await chat.apiCreateActiveUser({displayName: "SimpleX Calculator", fullName: ""}) + await chat.startChat() + await useSmpServer(chat) + await chat.close() +} const isCalculator = (display: string) => (ci: T.AChatItem) => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`) @@ -13,12 +32,15 @@ const calculatorShows = (display: string) => ({chatItems}: CEvt.NewChatItems) => const hasText = (text: string) => ({chatItems}: CEvt.NewChatItems) => chatItems.some(ci => ci.chatItem.meta.itemText === text) -test("calculator in business chat (uses preset servers)", async () => { +test("calculator in business chat", async () => { const dir = mkdtempSync(join(tmpdir(), "calculator-bot-")) - const [calculator, _botUser, address] = await runCalculatorBot({type: "sqlite", filePrefix: join(dir, "bot")}) + const botDbOpts: bot.BotDbOpts = {type: "sqlite", filePrefix: join(dir, "bot")} + await prepareBotDatabase(botDbOpts) + const [calculator, _botUser, address] = await runCalculatorBot(botDbOpts) const alice = await api.ChatApi.init({type: "sqlite", filePrefix: join(dir, "alice")}) const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) await alice.startChat() + await useSmpServer(alice) try { const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact)) const firstCalculator = alice.wait("newChatItems", calculatorShows("0"), 30000) diff --git a/apps/simplex-calculator-bot/test/smpServer.ts b/apps/simplex-calculator-bot/test/smpServer.ts new file mode 100644 index 0000000000..c42bee517f --- /dev/null +++ b/apps/simplex-calculator-bot/test/smpServer.ts @@ -0,0 +1,101 @@ +import {ChildProcess, spawn} from "child_process" +import {createHash} from "crypto" +import {once} from "events" +import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from "fs" +import {AddressInfo, createConnection, createServer} from "net" +import {tmpdir} from "os" +import {dirname, join} from "path" + +const tlsFixtures = join(__dirname, "../../../tests/fixtures/tls") +const smpServerRelease = "v7.1.0-beta.3" +const releaseArch: Record = {x64: "x86-64", arm64: "aarch64"} + +export interface SmpServer { + address: string + stop: () => Promise +} + +export async function startSmpServer(): Promise { + const port = await freePort() + const dir = mkdtempSync(join(tmpdir(), "smp-server-")) + const configDir = join(dir, "config") + mkdirSync(configDir) + for (const file of ["ca.crt", "server.crt", "server.key"]) copyFileSync(join(tlsFixtures, file), join(configDir, file)) + const fingerprint = caFingerprint(join(configDir, "ca.crt")) + writeFileSync(join(configDir, "fingerprint"), `${fingerprint}\n`) + writeFileSync(join(configDir, "smp-server.ini"), `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\nwebsockets: off\n`) + const server = spawn(await smpServerExecutable(), ["start"], { + env: {...process.env, SMP_SERVER_CFG_PATH: configDir, SMP_SERVER_LOG_PATH: join(dir, "logs")}, + stdio: ["ignore", "ignore", "inherit"], + }) + let spawnError: Error | undefined + server.on("error", e => { spawnError = e }) + await waitForServer(server, port, () => spawnError) + return { + address: `smp://${fingerprint}@localhost:${port}`, + stop: async () => { + if (server.exitCode === null) { + const exited = once(server, "exit") + server.kill() + await exited + } + rmSync(dir, {recursive: true, force: true}) + }, + } +} + +async function smpServerExecutable(): Promise { + if (process.env.SMP_SERVER) return process.env.SMP_SERVER + const path = join(__dirname, "../node_modules/.cache/smp-server", `smp-server-${smpServerRelease}`) + if (!existsSync(path)) await downloadSmpServer(path) + return path +} + +async function downloadSmpServer(path: string): Promise { + const arch = releaseArch[process.arch] + if (process.platform !== "linux" || !arch) { + throw new Error("smp-server release binaries are only available for Linux, set SMP_SERVER to the smp-server executable") + } + const url = `https://github.com/simplex-chat/simplexmq/releases/download/${smpServerRelease}/smp-server-ubuntu-22_04-${arch}` + const response = await fetch(url) + if (!response.ok) throw new Error(`error downloading ${url}: ${response.status}`) + mkdirSync(dirname(path), {recursive: true}) + writeFileSync(`${path}.download`, Buffer.from(await response.arrayBuffer()), {mode: 0o755}) + renameSync(`${path}.download`, path) +} + +function caFingerprint(caFile: string): string { + const der = Buffer.from(readFileSync(caFile, "utf-8").replace(/-----[^-]+-----|\s/g, ""), "base64") + return createHash("sha256").update(der).digest("base64").replace(/\+/g, "-").replace(/\//g, "_") +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer() + probe.on("error", reject) + probe.listen(0, "127.0.0.1", () => { + const {port} = probe.address() as AddressInfo + probe.close(() => resolve(port)) + }) + }) +} + +async function waitForServer(server: ChildProcess, port: number, spawnError: () => Error | undefined): Promise { + const deadline = Date.now() + 15_000 + while (!(await canConnect(port))) { + if (server.pid === undefined || server.exitCode !== null || Date.now() > deadline) { + throw new Error(`smp-server did not start on port ${port}: ${spawnError()?.message ?? "no error"}`) + } + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +function canConnect(port: number): Promise { + return new Promise(resolve => { + const socket = createConnection({host: "localhost", port}, () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +}