mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 12:53:18 +00:00
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:
1 parent
ba9269a3ad
commit
46b2cf9f85
73 files changed
+957
-265
No files matched your search
@@ -2,17 +2,25 @@ import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import {CEvt, T} from "@simplex-chat/types"
|
||||
import {api} from ".."
|
||||
import {SmpServer, describeIfSmpServerAvailable, startSmpServer} from "./smpServer"
|
||||
|
||||
const CT = T.ChatType
|
||||
|
||||
describe("API tests (use preset servers)", () => {
|
||||
describeIfSmpServerAvailable("API tests (use local SMP server)", () => {
|
||||
const tmpDir = "./tests/tmp"
|
||||
const alicePath = path.join(tmpDir, "alice")
|
||||
const bobPath = path.join(tmpDir, "bob")
|
||||
let smpServer: SmpServer
|
||||
|
||||
beforeAll(async () => { smpServer = await startSmpServer() }, 120000)
|
||||
afterAll(() => smpServer?.stop())
|
||||
beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true}))
|
||||
afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true}))
|
||||
|
||||
async function useSmpServer(chat: api.ChatApi): Promise<void> {
|
||||
expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
|
||||
}
|
||||
|
||||
it("should send/receive message", async () => {
|
||||
// create users and start chat controllers
|
||||
const alice = await api.ChatApi.init({type: "sqlite", filePrefix: alicePath})
|
||||
@@ -27,32 +35,37 @@ describe("API tests (use preset servers)", () => {
|
||||
await bob.apiCreateActiveUser({displayName: "bob", fullName: ""})
|
||||
await alice.startChat()
|
||||
await bob.startChat()
|
||||
await useSmpServer(alice)
|
||||
await useSmpServer(bob)
|
||||
// connect via link
|
||||
const link = await alice.apiCreateLink(aliceUser.userId)
|
||||
const connected = Promise.all([alice.wait("contactConnected"), bob.wait("contactConnected")])
|
||||
await expect(bob.apiConnectActiveUser(link)).resolves.toBe(api.ConnReqType.Invitation)
|
||||
const [bobContact, aliceContact] = await Promise.all([
|
||||
(await alice.wait("contactConnected")).contact,
|
||||
(await bob.wait("contactConnected")).contact
|
||||
])
|
||||
const [{contact: bobContact}, {contact: aliceContact}] = await connected
|
||||
expect(bobContact).toMatchObject({profile: {displayName: "bob"}})
|
||||
expect(aliceContact).toMatchObject({profile: {displayName: "alice"}})
|
||||
// exchange messages
|
||||
const isMessage = ({contactId}: T.Contact, msg: string) => (evt: CEvt.NewChatItems) =>
|
||||
evt.chatItems.some(ci => ci.chatInfo.type === CT.Direct && ci.chatInfo.contact.contactId === contactId && ci.chatItem.meta.itemText === msg)
|
||||
const hello = bob.wait("newChatItems", isMessage(aliceContact, "hello"))
|
||||
await alice.apiSendTextMessage([CT.Direct, bobContact.contactId], "hello")
|
||||
await bob.wait("newChatItems", isMessage(aliceContact, "hello"))
|
||||
await hello
|
||||
const helloToo = alice.wait("newChatItems", isMessage(bobContact, "hello too"), 10000)
|
||||
await bob.apiSendTextMessage([CT.Direct, aliceContact.contactId], "hello too")
|
||||
await alice.wait("newChatItems", isMessage(bobContact, "hello too"), 10000)
|
||||
await expect(helloToo).resolves.toBeDefined()
|
||||
const howAreYou = bob.wait("newChatItems", isMessage(aliceContact, "how are you?"))
|
||||
await alice.apiSendTextMessage([CT.Direct, bobContact.contactId], "how are you?")
|
||||
await bob.wait("newChatItems", isMessage(aliceContact, "how are you?"))
|
||||
await howAreYou
|
||||
const okAndYou = alice.wait("newChatItems", isMessage(bobContact, "ok, and you?"), 10000)
|
||||
await bob.apiSendTextMessage([CT.Direct, aliceContact.contactId], "ok, and you?")
|
||||
await alice.wait("newChatItems", isMessage(bobContact, "ok, and you?"), 10000)
|
||||
await expect(okAndYou).resolves.toBeDefined()
|
||||
// no more messages
|
||||
await expect(alice.wait("newChatItems", 500)).resolves.toBeUndefined()
|
||||
await expect(bob.wait("newChatItems", 500)).resolves.toBeUndefined()
|
||||
// delete contacts, stop chat controllers and close databases
|
||||
const deleted = bob.wait("contactDeletedByContact")
|
||||
await alice.apiDeleteChat(CT.Direct, bobContact.contactId)
|
||||
await bob.wait("contactDeletedByContact")
|
||||
await deleted
|
||||
await bob.apiDeleteChat(CT.Direct, aliceContact.contactId)
|
||||
await alice.stopChat()
|
||||
await bob.stopChat()
|
||||
@@ -60,8 +73,7 @@ describe("API tests (use preset servers)", () => {
|
||||
await bob.close()
|
||||
await expect(alice.startChat()).rejects.toThrow("chat api controller not initialized")
|
||||
await expect(bob.startChat()).rejects.toThrow("chat api controller not initialized")
|
||||
expect(servers.length).toBe(2)
|
||||
expect(servers[0] !== servers[1]).toBe(true)
|
||||
expect([...new Set(servers)]).toEqual(["localhost"])
|
||||
expect(eventCount > 0).toBe(true)
|
||||
}, 30000)
|
||||
|
||||
@@ -77,20 +89,19 @@ describe("API tests (use preset servers)", () => {
|
||||
await alice.startChat()
|
||||
await bob.startChat()
|
||||
await carol.startChat()
|
||||
await useSmpServer(alice)
|
||||
await useSmpServer(bob)
|
||||
await useSmpServer(carol)
|
||||
// connect alice <-> bob
|
||||
const aliceLink1 = await alice.apiCreateLink(aliceUser.userId)
|
||||
const bobConnected = Promise.all([alice.wait("contactConnected"), bob.wait("contactConnected")])
|
||||
await expect(bob.apiConnectActiveUser(aliceLink1)).resolves.toBe(api.ConnReqType.Invitation)
|
||||
const [bobContact] = await Promise.all([
|
||||
(await alice.wait("contactConnected")).contact,
|
||||
(await bob.wait("contactConnected")).contact
|
||||
])
|
||||
const [{contact: bobContact}] = await bobConnected
|
||||
// connect alice <-> carol
|
||||
const aliceLink2 = await alice.apiCreateLink(aliceUser.userId)
|
||||
const carolConnected = Promise.all([alice.wait("contactConnected"), carol.wait("contactConnected")])
|
||||
await expect(carol.apiConnectActiveUser(aliceLink2)).resolves.toBe(api.ConnReqType.Invitation)
|
||||
const [carolContact] = await Promise.all([
|
||||
(await alice.wait("contactConnected")).contact,
|
||||
(await carol.wait("contactConnected")).contact
|
||||
])
|
||||
const [{contact: carolContact}] = await carolConnected
|
||||
// create group with direct messages enabled
|
||||
const group = await alice.apiNewGroup(aliceUser.userId, {
|
||||
displayName: "test-group",
|
||||
@@ -105,10 +116,10 @@ describe("API tests (use preset servers)", () => {
|
||||
await alice.apiAddMember(groupId, bobContact.contactId, T.GroupMemberRole.Member)
|
||||
const bobInvEvt = await bobInvP
|
||||
expect(bobInvEvt).toBeDefined()
|
||||
const aliceBobConnP = alice.wait("connectedToGroupMember", 15000)
|
||||
const bobAliceConnP = bob.wait("connectedToGroupMember", 15000)
|
||||
const aliceBobConnP = alice.wait("joinedGroupMember", 15000)
|
||||
const bobAliceConnP = bob.wait("userJoinedGroup", 15000)
|
||||
await bob.apiJoinGroup(bobInvEvt!.groupInfo.groupId)
|
||||
await Promise.all([aliceBobConnP, bobAliceConnP])
|
||||
expect(await Promise.all([aliceBobConnP, bobAliceConnP])).not.toContain(undefined)
|
||||
// add carol to the group
|
||||
const carolInvP = carol.wait("receivedGroupInvitation", 30000)
|
||||
await alice.apiAddMember(groupId, carolContact.contactId, T.GroupMemberRole.Member)
|
||||
@@ -117,14 +128,13 @@ describe("API tests (use preset servers)", () => {
|
||||
// wait for carol to connect to both alice and bob (and vice versa)
|
||||
const bobCarolConnP = bob.wait("connectedToGroupMember",
|
||||
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
|
||||
const carolAliceConnP = carol.wait("connectedToGroupMember",
|
||||
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "alice", 30000)
|
||||
const carolAliceConnP = carol.wait("userJoinedGroup", 30000)
|
||||
const carolBobConnP = carol.wait("connectedToGroupMember",
|
||||
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "bob", 30000)
|
||||
const aliceCarolConnP = alice.wait("connectedToGroupMember",
|
||||
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
|
||||
const aliceCarolConnP = alice.wait("joinedGroupMember",
|
||||
(evt: CEvt.JoinedGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
|
||||
await carol.apiJoinGroup(carolInvEvt!.groupInfo.groupId)
|
||||
await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP])
|
||||
expect(await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP])).not.toContain(undefined)
|
||||
// find carol's memberId from bob's perspective
|
||||
const members = await bob.apiListMembers(groupId)
|
||||
const carolMember = members.find(m => m.memberProfile.displayName === "carol")
|
||||
|
||||
@@ -26,6 +26,17 @@ describe("documented success responses", () => {
|
||||
await expect(chat.apiUpdateChatItem(T.ChatType.Direct, 1, 2, {type: "text", text: "same"}, false)).resolves.toEqual(chatItem)
|
||||
})
|
||||
|
||||
it("apiUpdateChatItem updates and finalizes a live message", async () => {
|
||||
const chatItem = {meta: {itemId: 2}}
|
||||
const chat = await chatWithResponse({type: "chatItemUpdated", user, chatItem: {chatItem}})
|
||||
await expect(chat.apiUpdateChatItem(T.ChatType.Group, 1, 2, {type: "text", text: "partial"}, true)).resolves.toEqual(chatItem)
|
||||
await expect(chat.apiUpdateChatItem(T.ChatType.Group, 1, 2, {type: "text", text: "final"})).resolves.toEqual(chatItem)
|
||||
expect(jest.mocked(core.chatSendCmd).mock.calls.map(([, cmd]) => cmd)).toEqual([
|
||||
'/_update item #1 2 live=on json {"msgContent":{"type":"text","text":"partial"},"mentions":{}}',
|
||||
'/_update item #1 2 json {"msgContent":{"type":"text","text":"final"},"mentions":{}}',
|
||||
])
|
||||
})
|
||||
|
||||
it("apiSetProfileAddress accepts userProfileNoChange", async () => {
|
||||
const chat = await chatWithResponse({type: "userProfileNoChange", user})
|
||||
await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []})
|
||||
@@ -129,6 +140,122 @@ describe("startChat lifecycle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("LiveMessage", () => {
|
||||
const interval = 50
|
||||
const json = JSON.stringify
|
||||
|
||||
function recordingChat() {
|
||||
const calls: string[] = []
|
||||
let itemId = 0
|
||||
const ref = (chatType: T.ChatType, chatId: number) => T.ChatType.cmdString(chatType) + chatId
|
||||
const chat = {
|
||||
apiSendMessages: async ([chatType, chatId]: [T.ChatType, number], [{msgContent}]: T.ComposedMessage[], live: boolean) => {
|
||||
calls.push(`send ${ref(chatType, chatId)} ${json(msgContent.text)} live=${live}`)
|
||||
return [{chatItem: {meta: {itemId: ++itemId}}}] as unknown as T.AChatItem[]
|
||||
},
|
||||
apiUpdateChatItem: async (chatType: T.ChatType, chatId: number, chatItemId: number, msgContent: T.MsgContent, live: boolean) => {
|
||||
calls.push(`update ${ref(chatType, chatId)} ${chatItemId} ${json(msgContent.text)} live=${live}`)
|
||||
return {} as T.ChatItem
|
||||
},
|
||||
apiDeleteChatItems: async (chatType: T.ChatType, chatId: number, chatItemIds: number[], deleteMode: T.CIDeleteMode) => {
|
||||
calls.push(`delete ${ref(chatType, chatId)} ${chatItemIds} ${deleteMode}`)
|
||||
return [] as T.ChatItemDeletion[]
|
||||
},
|
||||
}
|
||||
return {chat, calls}
|
||||
}
|
||||
|
||||
function recordingLiveMessage(): {live: api.LiveMessage, calls: string[]} {
|
||||
const {chat, calls} = recordingChat()
|
||||
return {live: new api.LiveMessage(chat, T.ChatType.Group, 5, interval), calls}
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
it("sends the first text at once, updates at most once per interval and finalizes", async () => {
|
||||
const {live, calls} = recordingLiveMessage()
|
||||
live.update("")
|
||||
live.update("Hel")
|
||||
live.update("Hello")
|
||||
await wait(interval / 5)
|
||||
expect(calls).toEqual(['send #5 "" live=true'])
|
||||
await wait(interval * 2)
|
||||
expect(calls).toEqual(['send #5 "" live=true', 'update #5 1 "Hello" live=true'])
|
||||
await live.end("Hello world")
|
||||
expect(calls).toEqual(['send #5 "" live=true', 'update #5 1 "Hello" live=true', 'update #5 1 "Hello world" live=false'])
|
||||
})
|
||||
|
||||
it("does not repeat unchanged text", async () => {
|
||||
const {live, calls} = recordingLiveMessage()
|
||||
live.update("Hello")
|
||||
live.update("Hello")
|
||||
await wait(interval * 2)
|
||||
await live.end()
|
||||
expect(calls).toEqual(['send #5 "Hello" live=true', 'update #5 1 "Hello" live=false'])
|
||||
})
|
||||
|
||||
it("deletes a live message without text", async () => {
|
||||
const {live, calls} = recordingLiveMessage()
|
||||
live.update("")
|
||||
await live.end()
|
||||
expect(calls).toEqual(['send #5 "" live=true', "delete #5 1 broadcast"])
|
||||
})
|
||||
|
||||
it("sends the final text without a live message", async () => {
|
||||
const {live, calls} = recordingLiveMessage()
|
||||
await live.end()
|
||||
await live.end("Done")
|
||||
expect(calls).toEqual(['send #5 "Done" live=false'])
|
||||
})
|
||||
|
||||
it("finalizes the last text", async () => {
|
||||
const {live, calls} = recordingLiveMessage()
|
||||
live.update("")
|
||||
live.update("partial")
|
||||
await live.end()
|
||||
expect(calls).toEqual(['send #5 "" live=true', 'update #5 1 "partial" live=false'])
|
||||
})
|
||||
|
||||
it("starts the next live message after the end", async () => {
|
||||
const {live, calls} = recordingLiveMessage()
|
||||
live.update("")
|
||||
void live.end("first")
|
||||
live.update("")
|
||||
await live.end("second")
|
||||
expect(calls).toEqual([
|
||||
'send #5 "" live=true',
|
||||
'update #5 1 "first" live=false',
|
||||
'send #5 "" live=true',
|
||||
'update #5 2 "second" live=false',
|
||||
])
|
||||
})
|
||||
|
||||
it("passes errors to the handler and repeats the change with the next text", async () => {
|
||||
const {chat, calls} = recordingChat()
|
||||
const error = new Error("update failed")
|
||||
chat.apiUpdateChatItem = jest.fn(chat.apiUpdateChatItem).mockRejectedValueOnce(error)
|
||||
const errors: unknown[] = []
|
||||
const live = new api.LiveMessage(chat, T.ChatType.Group, 5, interval, e => errors.push(e))
|
||||
live.update("")
|
||||
live.update("Hello")
|
||||
await wait(interval * 2)
|
||||
await live.end("Hello world")
|
||||
expect(errors).toEqual([error])
|
||||
expect(calls).toEqual(['send #5 "" live=true', 'update #5 1 "Hello world" live=false'])
|
||||
})
|
||||
|
||||
it("logs errors by default", async () => {
|
||||
const {chat} = recordingChat()
|
||||
const error = new Error("send failed")
|
||||
chat.apiSendMessages = jest.fn(chat.apiSendMessages).mockRejectedValueOnce(error)
|
||||
const log = jest.spyOn(console, "log").mockImplementation(() => {})
|
||||
await new api.LiveMessage(chat, T.ChatType.Group, 5).end("Hello")
|
||||
expect(log).toHaveBeenCalledWith("live message error", error)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatApi.init", () => {
|
||||
it("loads the library for the configured backend before opening the database", async () => {
|
||||
let loaded!: () => void
|
||||
|
||||
@@ -3,18 +3,35 @@ import * as fs from "fs"
|
||||
import * as assert from "assert"
|
||||
import {CEvt, T} from "@simplex-chat/types"
|
||||
import {api, bot, util} from ".."
|
||||
import {SmpServer, describeIfSmpServerAvailable, startSmpServer} from "./smpServer"
|
||||
|
||||
const CT = T.ChatType
|
||||
|
||||
describe("Bot tests (use preset servers)", () => {
|
||||
describeIfSmpServerAvailable("Bot tests (use local SMP server)", () => {
|
||||
const tmpDir = "./tests/tmp"
|
||||
const botPath = path.join(tmpDir, "bot")
|
||||
const alicePath = path.join(tmpDir, "alice")
|
||||
let smpServer: SmpServer
|
||||
|
||||
beforeAll(async () => { smpServer = await startSmpServer() }, 120000)
|
||||
afterAll(() => smpServer?.stop())
|
||||
beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true}))
|
||||
afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true}))
|
||||
|
||||
async function useSmpServer(chat: api.ChatApi): Promise<void> {
|
||||
expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
|
||||
}
|
||||
|
||||
async function prepareBotDatabase(): Promise<void> {
|
||||
const chat = await api.ChatApi.init({type: "sqlite", filePrefix: botPath})
|
||||
await chat.apiCreateActiveUser({displayName: "Squaring bot", fullName: ""})
|
||||
await chat.startChat()
|
||||
await useSmpServer(chat)
|
||||
await chat.close()
|
||||
}
|
||||
|
||||
it("should reply to messages", async () => {
|
||||
await prepareBotDatabase()
|
||||
// run bot
|
||||
const [chat, botUser, botAddress] = await bot.run({
|
||||
profile: {displayName: "Squaring bot", fullName: ""},
|
||||
@@ -33,24 +50,25 @@ describe("Bot tests (use preset servers)", () => {
|
||||
const alice = await api.ChatApi.init({type: "sqlite", filePrefix: alicePath})
|
||||
const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""})
|
||||
await alice.startChat()
|
||||
await useSmpServer(alice)
|
||||
// connect to bot
|
||||
const [plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(botAddress.connLinkContact))
|
||||
assert(plan.type === "contactAddress")
|
||||
const connected = Promise.all([alice.wait("contactConnected"), chat.wait("contactConnected")])
|
||||
await expect(alice.apiConnect(aliceUser.userId, false, link)).resolves.toBe(api.ConnReqType.Contact)
|
||||
const [botContact, aliceContact] = await Promise.all([
|
||||
(await alice.wait("contactConnected")).contact,
|
||||
(await chat.wait("contactConnected")).contact
|
||||
])
|
||||
const [{contact: botContact}, {contact: aliceContact}] = await connected
|
||||
expect(botContact.profile.displayName).toBe("Squaring bot")
|
||||
// send message to bot
|
||||
const isMessage = ({contactId}: T.Contact, msg: string) => (evt: CEvt.NewChatItems) =>
|
||||
evt.chatItems.some(ci => ci.chatInfo.type === CT.Direct && ci.chatInfo.contact.contactId === contactId && ci.chatItem.meta.itemText === msg)
|
||||
const reply = alice.wait("newChatItems", isMessage(botContact, "2 * 2 = 4"), 5000)
|
||||
await alice.apiSendTextMessage([CT.Direct, botContact.contactId], "2")
|
||||
console.log("after sending message")
|
||||
await alice.wait("newChatItems", isMessage(botContact, "2 * 2 = 4"), 5000)
|
||||
await expect(reply).resolves.toBeDefined()
|
||||
// cleanup
|
||||
const deleted = chat.wait("contactDeletedByContact", ({contact}) => contact.contactId === aliceContact.contactId)
|
||||
await alice.apiDeleteChat(CT.Direct, botContact.contactId)
|
||||
await chat.wait("contactDeletedByContact", ({contact}) => contact.contactId === aliceContact.contactId)
|
||||
await deleted
|
||||
await chat.apiDeleteUserAddress(botUser.userId)
|
||||
await chat.stopChat()
|
||||
await chat.close()
|
||||
|
||||
@@ -17,8 +17,18 @@ describe("Core tests", () => {
|
||||
}, 300000);
|
||||
const tmpDir = "./tests/tmp";
|
||||
const dbPath = path.join(tmpDir, "simplex_v1");
|
||||
const templateDir = "./tests/template";
|
||||
const templateDbPath = path.join(templateDir, "simplex_v1");
|
||||
|
||||
beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true}));
|
||||
beforeAll(async () => {
|
||||
fs.mkdirSync(templateDir, {recursive: true});
|
||||
await stopAndClose(await core.chatMigrateInit(templateDbPath, "key", core.MigrationConfirmation.YesUp));
|
||||
}, 30000);
|
||||
afterAll(() => fs.rmSync(templateDir, {recursive: true, force: true}));
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(tmpDir, {recursive: true});
|
||||
copyTemplateDb(dbPath);
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true}));
|
||||
|
||||
async function stopAndClose(ctrl: bigint): Promise<void> {
|
||||
@@ -26,6 +36,10 @@ describe("Core tests", () => {
|
||||
await core.chatCloseStore(ctrl);
|
||||
}
|
||||
|
||||
function copyTemplateDb(dbFilePrefix: string): void {
|
||||
for (const suffix of ["_chat.db", "_agent.db"]) fs.copyFileSync(templateDbPath + suffix, dbFilePrefix + suffix);
|
||||
}
|
||||
|
||||
it("should initialize chat controller", async () => {
|
||||
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
||||
expect(typeof ctrl).toBe("bigint");
|
||||
@@ -132,7 +146,7 @@ describe("Core tests", () => {
|
||||
|
||||
it("should not block the libuv pool while receiving", async () => {
|
||||
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
||||
const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 2_000_000));
|
||||
const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 500_000));
|
||||
const start = Date.now();
|
||||
await fs.promises.stat(tmpDir);
|
||||
expect(Date.now() - start).toBeLessThan(200);
|
||||
@@ -159,8 +173,10 @@ describe("Core tests", () => {
|
||||
}, 30000);
|
||||
|
||||
it("should receive on two controllers concurrently", async () => {
|
||||
const ctrlA = await core.chatMigrateInit(path.join(tmpDir, "simplex_a"), "key", core.MigrationConfirmation.YesUp);
|
||||
const ctrlB = await core.chatMigrateInit(path.join(tmpDir, "simplex_b"), "key", core.MigrationConfirmation.YesUp);
|
||||
const dbPathB = path.join(tmpDir, "simplex_b");
|
||||
copyTemplateDb(dbPathB);
|
||||
const ctrlA = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
||||
const ctrlB = await core.chatMigrateInit(dbPathB, "key", core.MigrationConfirmation.YesUp);
|
||||
const start = Date.now();
|
||||
await expect(Promise.all([core.chatRecvMsgWait(ctrlA, 1_000_000), core.chatRecvMsgWait(ctrlB, 1_000_000)]))
|
||||
.resolves.toEqual([undefined, undefined]);
|
||||
@@ -235,7 +251,7 @@ describe("Core tests", () => {
|
||||
}, 35000);
|
||||
|
||||
it("should let the process exit while a receiver is idle", () => {
|
||||
const childDbPath = path.resolve(tmpDir, "simplex_child");
|
||||
const childDbPath = path.resolve(dbPath);
|
||||
const script = `
|
||||
const simplex = require("./build/Release/simplex.node");
|
||||
simplex.load(${JSON.stringify(libPath)});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBtjCCATagAwIBAgIUe2PryrWo0xXX9vcA3WfbCzcdmgAwBQYDK2VxMCoxFjAU
|
||||
BgNVBAMMDVNNUCBzZXJ2ZXIgQ0ExEDAOBgNVBAoMB1NpbXBsZVgwIBcNMjIwMTEx
|
||||
MTExNjM5WhgPNDc1OTEyMDgxMTE2MzlaMCoxFjAUBgNVBAMMDVNNUCBzZXJ2ZXIg
|
||||
Q0ExEDAOBgNVBAoMB1NpbXBsZVgwQzAFBgMrZXEDOgCAcvFwVicR+RLZpiEWPFNR
|
||||
XYTbf+mFcX1NHIyPQDugFwOCgqJAW1fsjYgFhtQJSMH/lc1N7clfm4CjUzBRMB0G
|
||||
A1UdDgQWBBQcUJvR7mm26yxMQfCsWgbnwMmJVDAfBgNVHSMEGDAWgBQcUJvR7mm2
|
||||
6yxMQfCsWgbnwMmJVDAPBgNVHRMBAf8EBTADAQH/MAUGAytlcQNzAAAP/hMPNxyW
|
||||
fyJi+iJViodU+C/aklnvHtjh5P3AbiVCSUfY6+PEdvkC8Ov0pBAYpYi5ukSNNVXl
|
||||
ABVRlipB+vOcLQStNyaZ7kXzQ2IO/0btmIidh+G6SP8I4aytYIYYcV5pEUZpG1L1
|
||||
57g8P29SDv81AA==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBvDCCATygAwIBAgIUUgqht3ibEJ9jp5JuroML5Baxa/IwBQYDK2VxMCoxFjAU
|
||||
BgNVBAMMDVNNUCBzZXJ2ZXIgQ0ExEDAOBgNVBAoMB1NpbXBsZVgwIBcNMjIwMTEx
|
||||
MTYzOTQ2WhgPNDc1OTEyMDgxNjM5NDZaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDBD
|
||||
MAUGAytlcQM6AFj+fmjmgOMBFgQ7lXfICSlSaB5sYp1wzhM2IaqgmVhWbDs3Tw7W
|
||||
oUXFJsWpKku1kFjLH2yhqIInAKNvMG0wCQYDVR0TBAIwADALBgNVHQ8EBAMCA8gw
|
||||
EwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFMjbHbxESDI8YDPjJ8kXWjkg
|
||||
jHxmMB8GA1UdIwQYMBaAFBxQm9HuabbrLExB8KxaBufAyYlUMAUGAytlcQNzAP98
|
||||
RpFacmjxsc07GwC07uAxUxNgxX4R2nA+0St866uFbdyxApiyn44giG+m+YXCXLjh
|
||||
1ffKEhhqAPdlpzByLarx8EWgmOsisBCRWrmraksOV15FKxjPqm7OYKp4f9GvieAQ
|
||||
o/SKOyZVVdcZX7NcuDUxAA==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MEcCAQAwBQYDK2VxBDsEOZcXSjKQ3FQ9EBfqHmFIidOu4LecpUgi5NVPMhx7mS2d
|
||||
QNQ0lFasp+EfvLErbs1Sd9xLVxA1hnZJng==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -249,6 +249,35 @@ describe("installLibs", () => {
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects a symlink entry and cleans up", async () => {
|
||||
const zip = storedZip({[`libs/${LIB}`]: "lib", "libs/link": {content: "../../outside", mode: 0o120777}})
|
||||
routes["/symlink.zip"] = res => res.writeHead(200).end(zip)
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await expect(installLibs(`${base}/symlink.zip`, sha256(zip), target, LIB)).rejects.toThrow("zip entry is a symlink: libs/link")
|
||||
expect(leftovers()).toEqual([])
|
||||
expect(fs.existsSync(target)).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects an entry outside the target folder and cleans up", async () => {
|
||||
const zip = storedZip({[`libs/${LIB}`]: "lib", "libs/../../outside": "x"})
|
||||
routes["/outside.zip"] = res => res.writeHead(200).end(zip)
|
||||
await expect(installLibs(`${base}/outside.zip`, sha256(zip), path.join(tmp, "sqlite"), LIB)).rejects.toThrow("libs/../../outside")
|
||||
expect(leftovers()).toEqual([])
|
||||
expect(fs.existsSync(path.join(tmp, "outside"))).toBe(false)
|
||||
expect(fs.existsSync(path.join(path.dirname(tmp), "outside"))).toBe(false)
|
||||
})
|
||||
|
||||
const itOnPosix = process.platform === "win32" ? it.skip : it
|
||||
|
||||
itOnPosix("keeps the file mode of an entry", async () => {
|
||||
const zip = storedZip({[`libs/${LIB}`]: {content: "lib", mode: 0o100755}, "libs/data": {content: "x", mode: 0o100644}})
|
||||
routes["/modes.zip"] = res => res.writeHead(200).end(zip)
|
||||
const target = path.join(tmp, "sqlite")
|
||||
await installLibs(`${base}/modes.zip`, sha256(zip), target, LIB)
|
||||
expect(fs.statSync(path.join(target, LIB)).mode & 0o777).toBe(0o755)
|
||||
expect(fs.statSync(path.join(target, "data")).mode & 0o777).toBe(0o644)
|
||||
})
|
||||
|
||||
it("times out a request without a response", async () => {
|
||||
await expect(installLibs(`${base}/hang.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout")
|
||||
expect(leftovers()).toEqual([])
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import {T} from "@simplex-chat/types"
|
||||
import {api} from ".."
|
||||
import {SmpServer, describeIfSmpServerAvailable, startSmpServer} from "./smpServer"
|
||||
|
||||
const CT = T.ChatType
|
||||
|
||||
describeIfSmpServerAvailable("Live message tests (use local SMP server)", () => {
|
||||
const tmpDir = "./tests/tmp"
|
||||
const alicePath = path.join(tmpDir, "alice")
|
||||
const bobPath = path.join(tmpDir, "bob")
|
||||
let smpServer: SmpServer
|
||||
|
||||
beforeAll(async () => { smpServer = await startSmpServer() }, 120000)
|
||||
afterAll(() => smpServer?.stop())
|
||||
beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true}))
|
||||
afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true}))
|
||||
|
||||
async function useSmpServer(chat: api.ChatApi): Promise<void> {
|
||||
expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
|
||||
}
|
||||
|
||||
it("should show, update, finalize and delete live messages", async () => {
|
||||
const alice = await api.ChatApi.init({type: "sqlite", filePrefix: alicePath})
|
||||
const bob = await api.ChatApi.init({type: "sqlite", filePrefix: bobPath})
|
||||
const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""})
|
||||
await bob.apiCreateActiveUser({displayName: "bob", fullName: ""})
|
||||
await alice.startChat()
|
||||
await bob.startChat()
|
||||
await useSmpServer(alice)
|
||||
await useSmpServer(bob)
|
||||
const aliceConnected = alice.wait("contactConnected", 30000)
|
||||
const bobConnected = bob.wait("contactConnected", 30000)
|
||||
await bob.apiConnectActiveUser(await alice.apiCreateLink(aliceUser.userId))
|
||||
const bobContact = (await aliceConnected)!.contact
|
||||
expect(await bobConnected).toBeDefined()
|
||||
|
||||
const timeout = 10000
|
||||
const received = (check: (meta: T.CIMeta) => boolean) =>
|
||||
bob.wait("newChatItems", ({chatItems}) => chatItems.some(({chatItem: {meta}}) => check(meta)), timeout)
|
||||
const updated = (check: (meta: T.CIMeta) => boolean) =>
|
||||
bob.wait("chatItemUpdated", ({chatItem: {chatItem: {meta}}}) => check(meta), timeout)
|
||||
const live = new api.LiveMessage(alice, CT.Direct, bobContact.contactId, 500)
|
||||
|
||||
const started = received(meta => meta.itemLive === true && meta.itemText === "")
|
||||
live.update("")
|
||||
const itemId = (await started)!.chatItems[0].chatItem.meta.itemId
|
||||
const updatedLive = updated(meta => meta.itemId === itemId && meta.itemLive === true && meta.itemText === "Hello")
|
||||
live.update("Hel")
|
||||
live.update("Hello")
|
||||
expect(await updatedLive).toBeDefined()
|
||||
const finalized = updated(meta => meta.itemId === itemId && meta.itemLive === false && meta.itemText === "Hello world")
|
||||
await live.end("Hello world")
|
||||
expect((await finalized)?.chatItem.chatItem.meta.itemEdited).toBe(false)
|
||||
|
||||
const emptyStarted = received(meta => meta.itemLive === true && meta.itemText === "")
|
||||
live.update("")
|
||||
const emptyItemId = (await emptyStarted)!.chatItems[0].chatItem.meta.itemId
|
||||
const deleted = bob.wait("chatItemsDeleted", ({chatItemDeletions}) =>
|
||||
chatItemDeletions.some(({deletedChatItem}) => deletedChatItem.chatItem.meta.itemId === emptyItemId), timeout)
|
||||
await live.end()
|
||||
expect(await deleted).toBeDefined()
|
||||
|
||||
await alice.close()
|
||||
await bob.close()
|
||||
}, 90000)
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
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, "fixtures/tls")
|
||||
const smpServerRelease = "v7.1.0-beta.3"
|
||||
const releaseArch: Record<string, string | undefined> = {x64: "x86-64", arm64: "aarch64"}
|
||||
const downloadArch = process.platform === "linux" ? releaseArch[process.arch] : undefined
|
||||
const downloadAttempts = 4
|
||||
|
||||
export const describeIfSmpServerAvailable = process.env.SMP_SERVER || downloadArch ? describe : describe.skip
|
||||
|
||||
export interface SmpServer {
|
||||
address: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
export async function startSmpServer(): Promise<SmpServer> {
|
||||
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"],
|
||||
})
|
||||
const stop = async () => {
|
||||
if (server.exitCode === null) {
|
||||
const exited = once(server, "exit")
|
||||
server.kill()
|
||||
await exited
|
||||
}
|
||||
rmSync(dir, {recursive: true, force: true})
|
||||
}
|
||||
try {
|
||||
await once(server, "spawn")
|
||||
await waitForServer(server, port)
|
||||
} catch (e) {
|
||||
await stop()
|
||||
throw e
|
||||
}
|
||||
return {address: `smp://${fingerprint}@localhost:${port}`, stop}
|
||||
}
|
||||
|
||||
async function smpServerExecutable(): Promise<string> {
|
||||
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<void> {
|
||||
if (!downloadArch) {
|
||||
throw new Error(`smp-server release binaries are not available for ${process.platform} ${process.arch}, set SMP_SERVER to the smp-server path`)
|
||||
}
|
||||
const url = `https://github.com/simplex-chat/simplexmq/releases/download/${smpServerRelease}/smp-server-ubuntu-22_04-${downloadArch}`
|
||||
const data = await downloadWithRetries(url)
|
||||
mkdirSync(dirname(path), {recursive: true})
|
||||
writeFileSync(`${path}.download`, data, {mode: 0o755})
|
||||
renameSync(`${path}.download`, path)
|
||||
}
|
||||
|
||||
async function downloadWithRetries(url: string): Promise<Buffer> {
|
||||
for (let attempt = 1; attempt < downloadAttempts; attempt++) {
|
||||
const data = await download(url).catch(() => undefined)
|
||||
if (data) return data
|
||||
await new Promise(resolve => setTimeout(resolve, attempt * 5000))
|
||||
}
|
||||
return download(url)
|
||||
}
|
||||
|
||||
async function download(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`error downloading ${url}: ${response.status}`)
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
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<number> {
|
||||
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): Promise<void> {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (!(await canConnect(port))) {
|
||||
if (server.exitCode !== null || Date.now() > deadline) throw new Error(`smp-server did not start on port ${port}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
function canConnect(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const socket = createConnection({host: "localhost", port}, () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
socket.on("error", () => resolve(false))
|
||||
})
|
||||
}
|
||||
@@ -4,15 +4,17 @@ const LOCAL_FILE_HEADER = 0x04034b50
|
||||
const CENTRAL_DIRECTORY_HEADER = 0x02014b50
|
||||
const END_OF_CENTRAL_DIRECTORY = 0x06054b50
|
||||
const ZIP_VERSION = 20
|
||||
const MADE_BY_UNIX = 3 << 8
|
||||
const LOCAL_HEADER_SIZE = 30
|
||||
const CENTRAL_ENTRY_SIZE = 46
|
||||
const END_RECORD_SIZE = 22
|
||||
|
||||
export function storedZip(files: {[name: string]: string}): Buffer {
|
||||
export function storedZip(files: {[name: string]: string | {content: string, mode: number}}): Buffer {
|
||||
const local: Buffer[] = []
|
||||
const central: Buffer[] = []
|
||||
let offset = 0
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
for (const [name, file] of Object.entries(files)) {
|
||||
const {content, mode} = typeof file === "string" ? {content: file, mode: 0} : file
|
||||
const nameBuf = Buffer.from(name)
|
||||
const data = Buffer.from(content)
|
||||
const crc = zlib.crc32(data)
|
||||
@@ -26,12 +28,13 @@ export function storedZip(files: {[name: string]: string}): Buffer {
|
||||
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(mode ? MADE_BY_UNIX | ZIP_VERSION : 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(mode * 0x10000, 38)
|
||||
entry.writeUInt32LE(offset, 42)
|
||||
central.push(entry, nameBuf)
|
||||
offset += header.length + nameBuf.length + data.length
|
||||
|
||||
Reference in new issue
Block a user