nodejs: add tests to library (#7694)

* nodejs: add tests to library

* new tests

* remove message splitting

* fix race

* docs

* replace extract-zip with yauzl that it wraps (high severity issue)

* fix ci tests

* skip server tests on macos and win unless SMP_SERVER is set

* fix win test

* fix win test

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
EvgenyandEvgeny @ SimpleX Chat authored and GitHub committed 2026-10-19 19:49:44 +01:00
1 parent ba9269a3ad
commit 46b2cf9f85
73 files changed
+957 -265

No files matched your search

+91 -3
View File
@@ -273,9 +273,11 @@ export class ChatApi {
}
return new Promise((resolve, reject) => {
let done = false
let timer: NodeJS.Timeout | undefined = undefined
const cleanup = () => {
done = true
this.off(event, subscriber)
clearTimeout(timer)
this.off(event, subscriber)
}
const subscriber: EventSubscriberFunc<K> = async (evt: ChatEvent & {type: K}) => {
if (done) return
@@ -288,7 +290,7 @@ export class ChatApi {
}
this.on(event, subscriber)
if (timeout > 0) {
setTimeout(() => { if (!done) { cleanup(); resolve(undefined) } }, timeout)
timer = setTimeout(() => { if (!done) { cleanup(); resolve(undefined) } }, timeout)
}
})
}
@@ -476,9 +478,10 @@ export class ChatApi {
/**
* Update message.
* A live message stays live with `liveMessage = true` and is finalized with `liveMessage = false`.
* Network usage: background.
*/
async apiUpdateChatItem(chatType: T.ChatType, chatId: number, chatItemId: number, msgContent: T.MsgContent, liveMessage: false): Promise<T.ChatItem> {
async apiUpdateChatItem(chatType: T.ChatType, chatId: number, chatItemId: number, msgContent: T.MsgContent, liveMessage = false): Promise<T.ChatItem> {
const r = await this.sendChatCmd(
CC.APIUpdateChatItem.cmdString({
chatRef: {chatType, chatId},
@@ -995,3 +998,88 @@ export class ChatApi {
throw new ChatCommandError("error sending member contact invitation", r)
}
}
/**
* Text shown as a live message while it is written, e.g. a streamed reply.
*/
export class LiveMessage {
private tasks: Promise<void> = Promise.resolve()
private text = ""
private writing = false
private timer: NodeJS.Timeout | undefined = undefined
private item: {itemId: number, text: string} | undefined = undefined
/**
* @param chat - Chat API, or an object with its methods to send, update and delete messages.
* @param {T.ChatType} chatType - Chat type.
* @param {number} chatId - Contact or group ID.
* @param {number} [interval=3000] - Minimum interval between updates of the live message, in milliseconds.
* @param onError - Error handler for sending, updating and deleting messages. Errors are logged when it is omitted.
*/
constructor(
private readonly chat: Pick<ChatApi, "apiSendMessages" | "apiUpdateChatItem" | "apiDeleteChatItems">,
private readonly chatType: T.ChatType,
private readonly chatId: number,
private readonly interval = 3000,
private readonly onError: (error: unknown) => void = e => console.log("live message error", e)
) {}
/**
* Set the text of the live message.
* The first text is sent at once; later texts update the message at most once per interval.
*/
update(text: string): void {
this.text = text
if (!this.writing) {
this.writing = true
this.queueText(text, true)
} else if (this.timer === undefined) {
this.timer = setTimeout(() => {
this.timer = undefined
this.queueText(this.text, true)
}, this.interval)
}
}
/**
* Finalize the live message with the text, or with the last text when omitted.
* A message without text is deleted. The next update starts a new live message.
* The returned promise resolves after all queued commands complete.
*/
end(text = this.text): Promise<void> {
clearTimeout(this.timer)
this.timer = undefined
this.text = ""
this.writing = false
this.queueText(text, false)
return this.tasks
}
private queueText(text: string, live: boolean): void {
this.tasks = this.tasks.then(() => this.showText(text, live)).catch(this.onError)
}
private async showText(text: string, live: boolean): Promise<void> {
try {
await this.showMessage(text, live)
} finally {
if (!live) this.item = undefined
}
}
private async showMessage(text: string, live: boolean): Promise<void> {
const item = this.item
if (item === undefined) {
if (live || text !== "") {
const [{chatItem}] = await this.chat.apiSendMessages([this.chatType, this.chatId], [{msgContent: {type: "text", text}, mentions: {}}], live)
this.item = live ? {itemId: chatItem.meta.itemId, text} : undefined
}
} else if (!live && text === "") {
await this.chat.apiDeleteChatItems(this.chatType, this.chatId, [item.itemId], T.CIDeleteMode.Broadcast)
this.item = undefined
} else if (!live || text !== item.text) {
await this.chat.apiUpdateChatItem(this.chatType, this.chatId, item.itemId, {type: "text", text}, live)
this.item = live ? {itemId: item.itemId, text} : undefined
}
}
}
+42 -2
View File
@@ -5,7 +5,7 @@ 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 * as yauzl from "yauzl"
import type {Backend} from "./core"
import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release"
@@ -19,6 +19,8 @@ const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} =
}
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"]
const S_IFMT = 0o170000
const S_IFLNK = 0o120000
function unsupported(platform: string, arch: string): Error {
return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`)
@@ -115,7 +117,7 @@ export function installLibs(url: string, sha256: string, target: string, lib: st
const zipPath = path.join(tmp, "libs.zip")
await download(url, zipPath, timeoutMs)
await verifySha256(zipPath, sha256, url)
await extract(zipPath, {dir: tmp})
await unzip(zipPath, tmp)
const extracted = path.join(tmp, "libs")
if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`)
await renameInto(extracted, target, path.join(target, lib))
@@ -132,6 +134,44 @@ export function installAddon(url: string, sha256: string, target: string, timeou
})
}
function unzip(zipPath: string, dir: string): Promise<void> {
const root = path.resolve(dir)
return new Promise((resolve, reject) => {
yauzl.open(zipPath, {lazyEntries: true}, (err, zip) => {
if (err || !zip) return reject(err ?? new Error(`cannot open ${zipPath}`))
let failed = false
const fail = (e: unknown) => {
if (failed) return
failed = true
zip.close()
reject(e)
}
zip.on("error", e => {
failed = true
reject(e)
})
zip.on("end", () => resolve())
zip.on("entry", (entry: yauzl.Entry) => {
const dest = path.resolve(root, entry.fileName)
if (!dest.startsWith(root + path.sep)) return fail(new Error(`zip entry outside the target folder: ${entry.fileName}`))
const mode = entry.externalFileAttributes >>> 16
if ((mode & S_IFMT) === S_IFLNK) return fail(new Error(`zip entry is a symlink: ${entry.fileName}`))
if (entry.fileName.endsWith("/")) {
fs.promises.mkdir(dest, {recursive: true}).then(() => zip.readEntry(), fail)
return
}
zip.openReadStream(entry, (e, stream) => {
if (e || !stream) return fail(e ?? new Error(`cannot read ${entry.fileName}`))
fs.promises.mkdir(path.dirname(dest), {recursive: true})
.then(() => pipeline(stream, fs.createWriteStream(dest, {mode: (mode & 0o777) || 0o644})))
.then(() => zip.readEntry(), fail)
})
})
zip.readEntry()
})
})
}
async function verifySha256(file: string, expected: string, url: string): Promise<void> {
const hash = crypto.createHash("sha256")
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)