diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index f405be260d..96012d9457 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -3,6 +3,9 @@ "version": "7.1.0-beta.4", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "simplex-chat": "dist/cli.js" + }, "files": [ "src", "cpp", diff --git a/packages/simplex-chat-nodejs/src/cli.ts b/packages/simplex-chat-nodejs/src/cli.ts new file mode 100644 index 0000000000..369c0dd106 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/cli.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import {parseArgs} from "util" +import type {Backend} from "./core" +import {resolveLibsDir} from "./libs" + +const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]" +const EXIT_INSTALL_FAILED = 1 +const EXIT_USAGE = 2 + +export function parseInstallArgs(argv: string[]): Backend { + const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}}) + if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install") + if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`) + return backend +} + +export async function main(argv: string[]): Promise { + if (argv.includes("-h") || argv.includes("--help")) { + console.log(USAGE) + return 0 + } + let backend: Backend + try { + backend = parseInstallArgs(argv) + } catch (e) { + console.error(`${(e as Error).message}\n${USAGE}`) + return EXIT_USAGE + } + try { + const dir = await resolveLibsDir(backend) + console.log(`libsimplex installed at: ${dir}`) + return 0 + } catch (e) { + console.error(`install failed: ${(e as Error).message}`) + return EXIT_INSTALL_FAILED + } +} + +if (require.main === module) { + main(process.argv.slice(2)).then(code => { process.exitCode = code }) +} diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 49664fed71..81f59942b0 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -1,6 +1,8 @@ import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" import * as simplex from "./simplex" +export type Backend = "sqlite" | "postgres" + /** * Initialize chat controller * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. diff --git a/packages/simplex-chat-nodejs/src/libs.ts b/packages/simplex-chat-nodejs/src/libs.ts new file mode 100644 index 0000000000..95ec9f2a92 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/libs.ts @@ -0,0 +1,139 @@ +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {pipeline} from "stream/promises" +import extract = require("extract-zip") +import type {Backend} from "./core" + +export const LIBS_VERSION = "7.1.0-beta.4" + +const GITHUB_REPO = "simplex-chat/simplex-chat-libs" +const REQUEST_TIMEOUT_MS = 60_000 +export const MAX_REDIRECTS = 5 +const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = { + linux: {name: "linux", lib: "libsimplex.so"}, + darwin: {name: "macos", lib: "libsimplex.dylib"}, + win32: {name: "windows", lib: "libsimplex.dll"}, +} +const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"} +const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"] + +function unsupported(platform: string, arch: string): Error { + return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`) +} + +export function platformTag(platform: string, arch: string): string { + const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}` + if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch) + return tag +} + +function libName(platform: string = process.platform, arch: string = process.arch): string { + const lib = PLATFORMS[platform]?.lib + if (!lib) throw unsupported(platform, arch) + return lib +} + +export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string { + if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat") + if (platform === "win32") { + if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set") + return path.join(env.LOCALAPPDATA, "simplex-chat") + } + return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat") +} + +export function libsUrl(backend: Backend, tag: string): string { + const suffix = backend === "postgres" ? "-postgres" : "" + return `https://github.com/${GITHUB_REPO}/releases/download/v${LIBS_VERSION}/simplex-chat-libs-${tag}${suffix}.zip` +} + +export function libPath(dir: string): string { + return path.join(dir, libName()) +} + +export async function resolveLibsDir( + backend: Backend, + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + const lib = libName(platform, arch) + if (env.SIMPLEX_LIBS_DIR) { + const dir = path.resolve(env.SIMPLEX_LIBS_DIR) + if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`) + return dir + } + const tag = platformTag(platform, arch) + if (backend === "postgres" && tag !== "linux-x86_64") { + throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`) + } + const target = path.resolve(cacheRoot(platform, env), `v${LIBS_VERSION}`, backend) + if (!fs.existsSync(path.join(target, lib))) await installLibs(libsUrl(backend, tag), target, lib) + return target +} + +export async function installLibs(url: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + const parent = path.dirname(target) + await fs.promises.mkdir(parent, {recursive: true}) + const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-")) + try { + console.error(`Downloading libsimplex from ${url} ...`) + const zipPath = path.join(tmp, "libs.zip") + await download(url, zipPath, timeoutMs) + await extract(zipPath, {dir: tmp}) + const extracted = path.join(tmp, "libs") + if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`) + try { + await fs.promises.rename(extracted, target) + } catch (e) { + // Another process installed the same version first; its files are identical. + // Windows reports renaming onto an existing directory as EPERM. + const code = (e as NodeJS.ErrnoException).code + const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target) + if (!lost) throw e + if (!fs.existsSync(path.join(target, lib))) { + throw new Error(`another process partially populated ${target} but libsimplex is missing; remove the directory manually and retry`) + } + } + } finally { + await fs.promises.rm(tmp, {recursive: true, force: true}) + } +} + +function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise { + return new Promise((resolve, reject) => { + const get = url.startsWith("https:") ? https.get : http.get + // Once a response arrives, request errors are ignored: a download settles via pipeline, which + // waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows. + let res: http.IncomingMessage | undefined + const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => { + res = response + const status = response.statusCode ?? 0 + const location = response.headers.location + if (status >= 300 && status < 400 && location) { + response.resume() + if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`)) + let next: URL + try { + next = new URL(location, url) + } catch (e) { + return reject(e) + } + if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`)) + download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject) + return + } + if (status !== 200) { + response.resume() + return reject(new Error(`HTTP ${status} downloading ${url}`)) + } + pipeline(response, fs.createWriteStream(dest)).then(resolve, reject) + }) + const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err)) + req.on("timeout", () => abort(new Error(`timeout downloading ${url}`))) + req.on("error", e => { if (!res) reject(e) }) + }) +} diff --git a/packages/simplex-chat-nodejs/tests/libs.test.ts b/packages/simplex-chat-nodejs/tests/libs.test.ts new file mode 100644 index 0000000000..2e0615f6c1 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/libs.test.ts @@ -0,0 +1,357 @@ +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {AddressInfo} from "net" +import {cacheRoot, installLibs, LIBS_VERSION, libPath, libsUrl, MAX_REDIRECTS, platformTag, resolveLibsDir} from "../src/libs" +import {main, parseInstallArgs} from "../src/cli" +import * as libs from "../src/libs" +import {storedZip} from "./zip" + +const LIB = "libtest.so" +const LINUX_LIB = "libsimplex.so" +const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR} = process.env + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +// A path resolution regression must neither download nor write into the real user cache. +let home: string +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-")) + jest.spyOn(os, "homedir").mockReturnValue(home) + jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") }) + setEnv("XDG_CACHE_HOME", home) + setEnv("LOCALAPPDATA", home) + setEnv("SIMPLEX_LIBS_DIR", undefined) +}) +afterEach(() => { + jest.restoreAllMocks() + setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME) + setEnv("LOCALAPPDATA", LOCALAPPDATA) + setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR) + fs.rmSync(home, {recursive: true, force: true}) +}) + +describe("paths", () => { + let libsDir: string + + beforeEach(() => { + libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-")) + fs.writeFileSync(libPath(libsDir), "lib") + }) + afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true})) + + it("uses the Python cache layout", () => { + expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat")) + expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat")) + expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat")) + expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat")) + expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set") + }) + + it.each([ + ["linux", "x64", "linux-x86_64"], + ["linux", "arm64", "linux-aarch64"], + ["darwin", "x64", "macos-x86_64"], + ["darwin", "arm64", "macos-aarch64"], + ["win32", "x64", "windows-x86_64"], + ])("tags %s/%s as %s", (platform, arch, tag) => { + expect(platformTag(platform, arch)).toBe(tag) + }) + + it("rejects unsupported platforms", () => { + expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform") + }) + + it("rejects an unsupported OS with its arch", async () => { + await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ") + }) + + it("uses the release URLs", () => { + const release = `https://github.com/simplex-chat/simplex-chat-libs/releases/download/v${LIBS_VERSION}` + expect(libsUrl("sqlite", "macos-aarch64")).toBe(`${release}/simplex-chat-libs-macos-aarch64.zip`) + expect(libsUrl("postgres", "linux-x86_64")).toBe(`${release}/simplex-chat-libs-linux-x86_64-postgres.zip`) + }) + + it("resolves an absolute SIMPLEX_LIBS_DIR", async () => { + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir) + }) + + it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => { + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir) + }) + + it.each([ + ["linux", "x64", "libsimplex.so"], + ["darwin", "arm64", "libsimplex.dylib"], + ["win32", "x64", "libsimplex.dll"], + ])("looks for the %s library name", async (platform, arch, lib) => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `) + }) + + it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`) + }) +}) + +describe("installLibs", () => { + let server: http.Server + let base: string + let tmp: string + const routes: {[p: string]: (res: http.ServerResponse) => void} = {} + + beforeAll(async () => { + server = http.createServer((req, res) => { + const route = routes[req.url ?? ""] + if (route) route(res) + else res.writeHead(404).end() + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(() => new Promise(resolve => server.close(() => resolve()))) + + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) }) + afterEach(() => fs.rmSync(tmp, {recursive: true, force: true})) + + const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"}) + routes["/good.zip"] = res => res.writeHead(200).end(good) + routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end() + routes["/nolib.zip"] = res => res.writeHead(200).end(storedZip({"other/file": "x"})) + routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) } + let loopRequests = 0 + routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() } + routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end() + routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end() + routes["/no-location.zip"] = res => res.writeHead(302).end() + routes["/hang.zip"] = () => {} + routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end() + let downloadDirs: string[] = [] + routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) } + + function leftovers(): string[] { + return fs.readdirSync(tmp).filter(f => f.startsWith(".download-")) + } + + // Serves https requests from a local http route (the requested path by default), recording the requested URLs. + function mockHttps(route?: string): string[] { + const requested: string[] = [] + jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => { + requested.push(url) + return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb) + }) as unknown as typeof https.get) + return requested + } + + function renameFails(code: string): void { + jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code})) + } + + beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) }) + + it("follows a redirect and installs libs/", async () => { + const target = path.join(tmp, "v1", "sqlite") + await installLibs(`${base}/redirect.zip`, target, LIB) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true) + }) + + it("downloads into a temp dir next to the target", async () => { + await installLibs(`${base}/observed.zip`, path.join(tmp, "sqlite"), LIB) + expect(downloadDirs).toHaveLength(1) + expect(leftovers()).toEqual([]) + }) + + it("stops following a redirect loop", async () => { + loopRequests = 0 + await expect(installLibs(`${base}/loop.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects") + expect(loopRequests).toBe(MAX_REDIRECTS + 1) + expect(leftovers()).toEqual([]) + }) + + it("follows an https redirect to another host", async () => { + const requested = mockHttps() + const target = path.join(tmp, "sqlite") + await installLibs("https://libs.test/cross-host.zip", target, LIB) + expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + }) + + it("rejects a redirect from https to http", async () => { + mockHttps("/to-http.zip") + await expect(installLibs("https://libs.test/libs.zip", path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol") + expect(leftovers()).toEqual([]) + }) + + it("rejects an invalid redirect location", async () => { + await expect(installLibs(`${base}/bad-location.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL") + expect(leftovers()).toEqual([]) + }) + + it("rejects a redirect without a location", async () => { + await expect(installLibs(`${base}/no-location.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302") + expect(leftovers()).toEqual([]) + }) + + it("rejects 404 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/missing.zip`, target, LIB)).rejects.toThrow("HTTP 404") + expect(leftovers()).toEqual([]) + expect(fs.existsSync(target)).toBe(false) + }) + + it("rejects a zip without the lib", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/nolib.zip`, target, LIB)).rejects.toThrow(`libs/${LIB} missing`) + expect(leftovers()).toEqual([]) + }) + + it("times out a request without a response", async () => { + await expect(installLibs(`${base}/hang.zip`, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it("times out a stalled download", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/stall.zip`, target, LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => { + const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"}) + routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip) + const requested = mockHttps("/libsimplex.zip") + const cache = path.relative(process.cwd(), path.join(tmp, "cache")) + const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64") + expect(requested).toEqual([libsUrl(backend, "linux-x86_64")]) + expect(path.isAbsolute(dir)).toBe(true) + expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", `v${LIBS_VERSION}`, backend)) + expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib") + }) + + it("does not download a cached lib", async () => { + const requested = mockHttps("/good.zip") + const cached = path.join(tmp, "simplex-chat", `v${LIBS_VERSION}`, "postgres") + fs.mkdirSync(cached, {recursive: true}) + fs.writeFileSync(path.join(cached, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a target populated without the lib", async () => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, "other"), "x") + await expect(installLibs(`${base}/good.zip`, target, LIB)).rejects.toThrow(`another process partially populated ${target}`) + expect(leftovers()).toEqual([]) + }) + + it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => { + const requested = mockHttps("/good.zip") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64") + expect(requested).toEqual([]) + }) + + it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, LIB), "lib") + renameFails(code) + await expect(installLibs(`${base}/good.zip`, target, LIB)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("rethrows EPERM from rename when the target does not exist", async () => { + renameFails("EPERM") + await expect(installLibs(`${base}/good.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM") + expect(leftovers()).toEqual([]) + }) + + it("lets concurrent installs share one target", async () => { + const target = path.join(tmp, "sqlite") + await Promise.all([ + installLibs(`${base}/good.zip`, target, LIB), + installLibs(`${base}/good.zip`, target, LIB), + ]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(leftovers()).toEqual([]) + }) +}) + +describe("cli", () => { + function installWithLibsDir(dir: string): Promise { + setEnv("SIMPLEX_LIBS_DIR", dir) + return main(["install"]) + } + + it("parses install arguments", () => { + expect(parseInstallArgs(["install"])).toBe("sqlite") + expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres") + expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres") + expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql") + expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing") + expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'") + expect(() => parseInstallArgs(["run"])).toThrow("expected command: install") + expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install") + expect(() => parseInstallArgs([])).toThrow("expected command: install") + }) + + it("prints the libs directory", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + fs.writeFileSync(libPath(dir), "lib") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + try { + await expect(installWithLibsDir(dir)).resolves.toBe(0) + expect(log).toHaveBeenCalledWith(`libsimplex installed at: ${dir}`) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 when install fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWithLibsDir(dir)).resolves.toBe(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("installs the requested backend", async () => { + const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs") + jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0) + expect(resolve).toHaveBeenCalledWith("postgres") + }) + + it.each(["--help", "-h"])("prints usage on %s", async flag => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", flag])).resolves.toBe(0) + expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]") + }) + + it("returns 2 with usage on invalid arguments", async () => { + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2) + expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/zip.ts b/packages/simplex-chat-nodejs/tests/zip.ts new file mode 100644 index 0000000000..547c8c211c --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/zip.ts @@ -0,0 +1,47 @@ +import * as zlib from "zlib" + +const LOCAL_FILE_HEADER = 0x04034b50 +const CENTRAL_DIRECTORY_HEADER = 0x02014b50 +const END_OF_CENTRAL_DIRECTORY = 0x06054b50 +const ZIP_VERSION = 20 +const LOCAL_HEADER_SIZE = 30 +const CENTRAL_ENTRY_SIZE = 46 +const END_RECORD_SIZE = 22 + +export function storedZip(files: {[name: string]: string}): Buffer { + const local: Buffer[] = [] + const central: Buffer[] = [] + let offset = 0 + for (const [name, content] of Object.entries(files)) { + const nameBuf = Buffer.from(name) + const data = Buffer.from(content) + const crc = zlib.crc32(data) + const header = Buffer.alloc(LOCAL_HEADER_SIZE) + header.writeUInt32LE(LOCAL_FILE_HEADER, 0) + header.writeUInt16LE(ZIP_VERSION, 4) + header.writeUInt32LE(crc, 14) + header.writeUInt32LE(data.length, 18) + header.writeUInt32LE(data.length, 22) + header.writeUInt16LE(nameBuf.length, 26) + local.push(header, nameBuf, data) + const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE) + entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0) + entry.writeUInt16LE(ZIP_VERSION, 4) + entry.writeUInt16LE(ZIP_VERSION, 6) + entry.writeUInt32LE(crc, 16) + entry.writeUInt32LE(data.length, 20) + entry.writeUInt32LE(data.length, 24) + entry.writeUInt16LE(nameBuf.length, 28) + entry.writeUInt32LE(offset, 42) + central.push(entry, nameBuf) + offset += header.length + nameBuf.length + data.length + } + const centralBuf = Buffer.concat(central) + const end = Buffer.alloc(END_RECORD_SIZE) + end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0) + end.writeUInt16LE(Object.keys(files).length, 8) + end.writeUInt16LE(Object.keys(files).length, 10) + end.writeUInt32LE(centralBuf.length, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...local, centralBuf, end]) +}