mirror of
https://github.com/tutao/tutanota.git
synced 2025-10-19 07:53:47 +00:00

Electron uses xdg-open to open files. On GNOME it will try to use gio open which reads user configs from a few files. To detect those config files it uses `XDG_CURRENT_DESKTOP` env variable. Electron overwrites `XDG_CURRENT_DESKTOP` to enable some compatibility functions in Chromium. This breaks gio because it can't locate the config files for default apps. It can be reproduced on Ubuntu with GNOME. There is a fix in Electron to restore the env for opening files, but it doesn't work. We worked around that by invoking xdg-open directly with correct env. Additionally, we fixed opening the directories having the same issue and getting stuck by using showItemInFolder instead. This also has an additional benefit of highlighting the file in the file manager. Close #9696 Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com>
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import o from "@tutao/otest"
|
|
import { createDataFile } from "../../../../src/common/api/common/DataFile.js"
|
|
import { DesktopFileFacade } from "../../../../src/common/desktop/files/DesktopFileFacade.js"
|
|
import { ApplicationWindow } from "../../../../src/common/desktop/ApplicationWindow.js"
|
|
import { func, matchers, object, verify, when } from "testdouble"
|
|
import { ElectronExports, FsExports, PathExports } from "../../../../src/common/desktop/ElectronExportTypes.js"
|
|
import { NotFoundError, PreconditionFailedError, TooManyRequestsError } from "../../../../src/common/api/common/error/RestError.js"
|
|
import type fs from "node:fs"
|
|
import { assertThrows } from "@tutao/tutanota-test-utils"
|
|
import { stringToUtf8Uint8Array } from "@tutao/tutanota-utils"
|
|
import { DesktopConfig } from "../../../../src/common/desktop/config/DesktopConfig.js"
|
|
import { DesktopUtils } from "../../../../src/common/desktop/DesktopUtils.js"
|
|
import { DateProvider } from "../../../../src/common/api/common/DateProvider.js"
|
|
import { TempFs } from "../../../../src/common/desktop/files/TempFs.js"
|
|
import { BuildConfigKey, DesktopConfigKey } from "../../../../src/common/desktop/config/ConfigKeys.js"
|
|
import { HttpMethod } from "../../../../src/common/api/common/EntityFunctions"
|
|
import { FetchImpl, FetchResult } from "../../../../src/common/desktop/net/NetAgent"
|
|
import { CommandExecutor } from "../../../../src/common/desktop/CommandExecutor"
|
|
|
|
const DEFAULT_DOWNLOAD_PATH = "/a/download/path/"
|
|
|
|
o.spec("DesktopFileFacade", function () {
|
|
let win: ApplicationWindow
|
|
let conf: DesktopConfig
|
|
let du: DesktopUtils
|
|
let dp: DateProvider
|
|
let fetch: FetchImpl
|
|
let electron: ElectronExports
|
|
let fs: FsExports
|
|
let tfs: TempFs
|
|
let ff: DesktopFileFacade
|
|
let path: PathExports
|
|
let executor: CommandExecutor
|
|
let process: Writeable<Partial<NodeJS.Process>>
|
|
|
|
o.beforeEach(function () {
|
|
win = object()
|
|
fetch = func() as FetchImpl
|
|
fs = object()
|
|
tfs = object()
|
|
path = object()
|
|
fs.promises = object()
|
|
process = {
|
|
env: {},
|
|
}
|
|
when(fs.promises.stat(matchers.anything())).thenResolve({ size: 42 })
|
|
electron = object()
|
|
// @ts-ignore read-only prop
|
|
electron["shell"] = object()
|
|
// @ts-ignore read-only prop
|
|
electron["dialog"] = object()
|
|
process.platform = "linux"
|
|
|
|
conf = object()
|
|
du = object()
|
|
dp = object()
|
|
executor = object()
|
|
|
|
ff = new DesktopFileFacade(win, conf, dp, fetch, electron, tfs, fs, path, executor, process as NodeJS.Process)
|
|
})
|
|
o.spec("saveDataFile", function () {
|
|
o("when there's no existing file it will be simply written", async function () {
|
|
const dataFile = createDataFile("blob", "application/octet-stream", new Uint8Array([1]))
|
|
when(fs.promises.readdir(matchers.anything())).thenResolve(["somethingelse"])
|
|
when(fs.promises.mkdir("/tutanota/tmp/path/download", { recursive: true })).thenResolve(undefined)
|
|
when(fs.promises.writeFile("/tutanota/tmp/path/download/blob", dataFile.data)).thenResolve()
|
|
await ff.writeTempDataFile(dataFile)
|
|
})
|
|
|
|
o("with default download path but file exists -> nonclobbering name is chosen", async function () {
|
|
const e = new Error() as any
|
|
e.code = "EEXISTS"
|
|
const dataFile = createDataFile("blob", "application/octet-stream", new Uint8Array([1]))
|
|
when(fs.promises.writeFile("/tutanota/tmp/path/download/blob", matchers.anything())).thenReject(e)
|
|
when(fs.promises.readdir(matchers.anything())).thenResolve(["blob"])
|
|
when(fs.promises.mkdir("/tutanota/tmp/path/download", { recursive: true })).thenResolve(undefined)
|
|
when(fs.promises.writeFile("/tutanota/tmp/path/download/blob-1", dataFile.data)).thenResolve()
|
|
await ff.writeTempDataFile(dataFile)
|
|
})
|
|
})
|
|
|
|
o.spec("download", function () {
|
|
o("no error", async function () {
|
|
const headers = { v: "foo", accessToken: "bar" }
|
|
const expectedFilePath = "/tutanota/tmp/path/encrypted/nativelyDownloadedFile"
|
|
const response: FetchResult = mockResponse(200, { responseBody: new Uint8Array() })
|
|
const ws: fs.WriteStream = mockWriteStream(response)
|
|
when(fs.createWriteStream(expectedFilePath, { emitClose: true })).thenReturn(ws)
|
|
when(
|
|
fetch(urlMatches(new URL("some://url/file")), {
|
|
method: "GET",
|
|
headers,
|
|
}),
|
|
).thenResolve(response)
|
|
// @ts-ignore callback omit
|
|
when(ws.on("finish")).thenCallback(undefined, undefined)
|
|
when(tfs.ensureEncryptedDir()).thenResolve("/tutanota/tmp/path/encrypted")
|
|
|
|
const downloadResult = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
|
|
o(downloadResult.statusCode).equals(200)
|
|
o(downloadResult.encryptedFileUri).equals(expectedFilePath)
|
|
})
|
|
|
|
o("404 error gets returned", async function () {
|
|
const headers = {
|
|
v: "foo",
|
|
accessToken: "bar",
|
|
}
|
|
|
|
const errorId = "123"
|
|
const response: FetchResult = mockResponse(NotFoundError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
|
|
|
|
o(result).deepEquals({
|
|
statusCode: 404,
|
|
errorId,
|
|
precondition: null,
|
|
suspensionTime: null,
|
|
encryptedFileUri: null,
|
|
})
|
|
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
|
|
})
|
|
|
|
o("retry-after", async function () {
|
|
const retryAfter = "20"
|
|
const errorId = "123"
|
|
|
|
const response: FetchResult = mockResponse(TooManyRequestsError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
"retry-after": retryAfter,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
|
|
const headers = { v: "foo", accessToken: "bar" }
|
|
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
|
|
|
|
o(result).deepEquals({
|
|
statusCode: TooManyRequestsError.CODE,
|
|
errorId,
|
|
precondition: null,
|
|
suspensionTime: retryAfter,
|
|
encryptedFileUri: null,
|
|
})
|
|
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
|
|
})
|
|
|
|
o("suspension", async function () {
|
|
const headers = { v: "foo", accessToken: "bar" }
|
|
const errorId = "123"
|
|
const retryAfter = "20"
|
|
const response: FetchResult = mockResponse(TooManyRequestsError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
"suspension-time": retryAfter,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
|
|
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
|
|
|
|
o(result).deepEquals({
|
|
statusCode: TooManyRequestsError.CODE,
|
|
errorId,
|
|
precondition: null,
|
|
suspensionTime: retryAfter,
|
|
encryptedFileUri: null,
|
|
})
|
|
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
|
|
})
|
|
|
|
o("precondition", async function () {
|
|
const headers = { v: "foo", accessToken: "bar" }
|
|
const errorId = "123"
|
|
const precondition = "a.2"
|
|
const response: FetchResult = mockResponse(PreconditionFailedError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
precondition: precondition,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
|
|
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
|
|
|
|
o(result).deepEquals({
|
|
statusCode: PreconditionFailedError.CODE,
|
|
errorId,
|
|
precondition,
|
|
suspensionTime: null,
|
|
encryptedFileUri: null,
|
|
})
|
|
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
|
|
})
|
|
|
|
o("IO error during download leads to cleanup and error is thrown", async function () {
|
|
const headers = { v: "foo", accessToken: "bar" }
|
|
const response: FetchResult = mockResponse(200, { responseBody: new Uint8Array() })
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
const error = new Error("Test! I/O error")
|
|
const ws = mockWriteStream()
|
|
when(ws.on("finish", matchers.anything())).thenThrow(error)
|
|
when(fs.createWriteStream(matchers.anything(), matchers.anything())).thenReturn(ws)
|
|
when(tfs.ensureEncryptedDir()).thenResolve("/tutanota/tmp/path/encrypted")
|
|
|
|
const e = await assertThrows(Error, () => ff.download("some://url/file", "nativelyDownloadedFile", headers))
|
|
o(e).equals(error)
|
|
verify(fs.promises.unlink("/tutanota/tmp/path/encrypted/nativelyDownloadedFile"), { times: 1 })
|
|
})
|
|
})
|
|
|
|
o.spec("upload", function () {
|
|
const fileToUploadPath = "/tutnaota/tmp/path/encrypted/toUpload.txt"
|
|
const targetUrl = "https://test.tutanota.com/rest/for/a/bit"
|
|
|
|
o("when there's no error it uploads correct data and returns the right result", async function () {
|
|
const body = stringToUtf8Uint8Array("BODY")
|
|
const response = mockResponse(200, { responseBody: body })
|
|
const headers = {
|
|
blobAccessToken: "1236",
|
|
}
|
|
const fileStreamMock = mockReadStream()
|
|
when(fs.createReadStream(fileToUploadPath)).thenReturn(fileStreamMock)
|
|
when(
|
|
fetch(urlMatches(new URL(targetUrl)), {
|
|
method: HttpMethod.POST,
|
|
headers,
|
|
body: fileStreamMock,
|
|
}),
|
|
).thenResolve(response)
|
|
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, headers)
|
|
|
|
o(uploadResult.statusCode).equals(200)
|
|
o(uploadResult.errorId).equals(null)
|
|
o(uploadResult.precondition).equals(null)
|
|
o(uploadResult.suspensionTime).equals(null)
|
|
o(Array.from(uploadResult.responseBody)).deepEquals(Array.from(body))
|
|
})
|
|
|
|
o("when 404 is returned it returns correct result", async function () {
|
|
const errorId = "123"
|
|
const response = mockResponse(404, { responseHeaders: { "error-id": errorId } })
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
|
|
o(uploadResult.statusCode).equals(404)
|
|
o(uploadResult.errorId).equals(errorId)
|
|
o(uploadResult.precondition).equals(null)
|
|
o(uploadResult.suspensionTime).equals(null)
|
|
o(Array.from(uploadResult.responseBody)).deepEquals([])
|
|
})
|
|
|
|
o("when retry-after is returned, it is propagated", async function () {
|
|
const retryAFter = "20"
|
|
const errorId = "123"
|
|
const response = mockResponse(TooManyRequestsError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
"retry-after": retryAFter,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
|
|
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
|
|
|
|
o(uploadResult.statusCode).equals(TooManyRequestsError.CODE)
|
|
o(uploadResult.errorId).equals(errorId)
|
|
o(uploadResult.precondition).equals(null)
|
|
o(uploadResult.suspensionTime).equals(retryAFter)
|
|
o(Array.from(uploadResult.responseBody)).deepEquals([])
|
|
})
|
|
|
|
o("when suspension-time is returned, it is propagated", async function () {
|
|
const retryAFter = "20"
|
|
const errorId = "123"
|
|
const response = mockResponse(TooManyRequestsError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
"suspension-time": retryAFter,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
|
|
|
|
o(uploadResult.statusCode).equals(TooManyRequestsError.CODE)
|
|
o(uploadResult.errorId).equals(errorId)
|
|
o(uploadResult.precondition).equals(null)
|
|
o(uploadResult.suspensionTime).equals(retryAFter)
|
|
o(Array.from(uploadResult.responseBody)).deepEquals([])
|
|
})
|
|
|
|
o("when precondition-time is returned, it is propagated", async function () {
|
|
const precondition = "a.2"
|
|
const errorId = "123"
|
|
const response = mockResponse(PreconditionFailedError.CODE, {
|
|
responseHeaders: {
|
|
"error-id": errorId,
|
|
precondition: precondition,
|
|
},
|
|
})
|
|
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
|
|
|
|
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
|
|
|
|
o(uploadResult.statusCode).equals(PreconditionFailedError.CODE)
|
|
o(uploadResult.errorId).equals(errorId)
|
|
o(uploadResult.precondition).equals(precondition)
|
|
o(uploadResult.suspensionTime).equals(null)
|
|
o(Array.from(uploadResult.responseBody)).deepEquals([])
|
|
})
|
|
})
|
|
|
|
o.spec("open", function () {
|
|
o.spec("open on linux", () => {
|
|
o.test("open on ubuntu", async function () {
|
|
when(electron.shell.openPath("/some/folder/file")).thenReject(new Error("wrong function"))
|
|
when(executor.run(matchers.anything())).thenResolve({})
|
|
process.env!.ORIGINAL_XDG_CURRENT_DESKTOP = "original ;)"
|
|
process.env!.SOMETHING_ELSE = "something else!"
|
|
await ff.open("/some/folder/file")
|
|
|
|
verify(
|
|
executor.run({
|
|
executable: "xdg-open",
|
|
args: ["/some/folder/file"],
|
|
env: {
|
|
XDG_CURRENT_DESKTOP: "original ;)",
|
|
ORIGINAL_XDG_CURRENT_DESKTOP: "original ;)",
|
|
SOMETHING_ELSE: "something else!",
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
o.test("open on non ubuntu", async function () {
|
|
when(electron.shell.openPath("/some/folder/file")).thenReject(new Error("wrong function"))
|
|
when(executor.run(matchers.anything())).thenResolve({})
|
|
process.env!.ORIGINAL_XDG_CURRENT_DESKTOP = undefined
|
|
process.env!.SOMETHING_ELSE = "something else!"
|
|
await ff.open("/some/folder/file")
|
|
|
|
verify(
|
|
executor.run({
|
|
executable: "xdg-open",
|
|
args: ["/some/folder/file"],
|
|
env: undefined,
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
o.test("open on windows", async function () {
|
|
process.platform = "win32"
|
|
when(electron.dialog.showMessageBox(matchers.anything())).thenReturn(
|
|
Promise.resolve({
|
|
response: 1,
|
|
checkboxChecked: false,
|
|
}),
|
|
)
|
|
await ff.open("exec.exe")
|
|
verify(electron.shell.openPath(matchers.anything()), { times: 0 })
|
|
})
|
|
})
|
|
|
|
o.spec("join", function () {
|
|
o("join a single file", async function () {
|
|
const ws: fs.WriteStream = mockWriteStream()
|
|
const rs: fs.ReadStream = mockReadStream(ws)
|
|
when(fs.createWriteStream(matchers.anything(), matchers.anything())).thenReturn(ws)
|
|
when(fs.createReadStream(matchers.anything())).thenReturn(rs)
|
|
// @ts-ignore callback omit
|
|
when(rs.on("end")).thenCallback(undefined, undefined)
|
|
when(fs.promises.readdir("/tutanota/tmp/path/unencrypted")).thenResolve(["folderContents"])
|
|
when(tfs.ensureUnencrytpedDir()).thenResolve("/tutanota/tmp/path/unencrypted")
|
|
const joinedFilePath = await ff.joinFiles("fileName.pdf", ["/file1"])
|
|
o(joinedFilePath).equals("/tutanota/tmp/path/unencrypted/fileName.pdf")
|
|
})
|
|
})
|
|
|
|
o.spec("splitFile", function () {
|
|
o("returns one slice for a small file", async function () {
|
|
// fs mock returns file name as the content
|
|
const filename = "/tutanota/tmp/path/download/small.txt"
|
|
const fileContent = stringToUtf8Uint8Array(filename)
|
|
const filenameHash = "9ca089f82e397e9e860daa312ac25def39f2da0e066f0de94ffc02aa7b3a6250"
|
|
const expectedChunkPath = `/tutanota/tmp/path/unencrypted/${filenameHash}.0.blob`
|
|
when(tfs.ensureUnencrytpedDir()).thenResolve("/tutanota/tmp/path/unencrypted")
|
|
when(fs.promises.writeFile(expectedChunkPath, fileContent)).thenResolve()
|
|
when(fs.promises.readFile(filename)).thenResolve(Buffer.from(fileContent))
|
|
const chunks = await ff.splitFile(filename, 1024)
|
|
o(chunks).deepEquals([expectedChunkPath])("only one chunk")
|
|
})
|
|
|
|
o("returns multiple slices for a bigger file", async function () {
|
|
// fs mock returns file name as the content
|
|
const filename = "/tutanota/tmp/path/download/big.txt"
|
|
// length 37
|
|
const fileContent = stringToUtf8Uint8Array(filename)
|
|
const filenameHash = "c24646a4738a92d624cd03134f26c371d8a2950d2b3bbce7921c288de9a56fd3"
|
|
const expectedChunkPath0 = `/tutanota/tmp/path/unencrypted/${filenameHash}.0.blob`
|
|
const expectedChunkPath1 = `/tutanota/tmp/path/unencrypted/${filenameHash}.1.blob`
|
|
|
|
when(tfs.ensureUnencrytpedDir()).thenResolve("/tutanota/tmp/path/unencrypted")
|
|
when(fs.promises.writeFile(expectedChunkPath0, fileContent.slice(0, 30))).thenResolve()
|
|
when(fs.promises.writeFile(expectedChunkPath1, fileContent.slice(30))).thenResolve()
|
|
when(fs.promises.readFile(filename)).thenResolve(Buffer.from(fileContent))
|
|
const chunks = await ff.splitFile(filename, 30)
|
|
o(chunks).deepEquals([expectedChunkPath0, expectedChunkPath1])("both written files are in the returned array")
|
|
})
|
|
})
|
|
|
|
o.spec("showInFileExplorer", function () {
|
|
o("two downloads, open two filemanagers", async function () {
|
|
const dir = "/path/to"
|
|
const p = dir + "/file.txt"
|
|
await ff.showInFileExplorer(p)
|
|
verify(electron.shell.showItemInFolder(p), { times: 1 })
|
|
})
|
|
|
|
o("two downloads, open two filemanagers after a pause", async function () {
|
|
const time = 1629115820468
|
|
const dir = "/path/to"
|
|
const p = dir + "/file.txt"
|
|
await ff.showInFileExplorer(p)
|
|
when(dp.now()).thenReturn(time)
|
|
when(conf.getConst(BuildConfigKey.fileManagerTimeout)).thenResolve(2)
|
|
verify(electron.shell.showItemInFolder(p), { times: 1 })
|
|
when(dp.now()).thenReturn(time + 10)
|
|
await ff.showInFileExplorer(p)
|
|
verify(electron.shell.showItemInFolder(p), { times: 2 })
|
|
})
|
|
})
|
|
|
|
o.spec("putFileIntoDownloadsFolder", function () {
|
|
o("putFileIntoDownloadsFolder", async function () {
|
|
const src = "/path/random.pdf"
|
|
const filename = "fileName.pdf"
|
|
when(conf.getVar(DesktopConfigKey.defaultDownloadPath)).thenResolve(DEFAULT_DOWNLOAD_PATH)
|
|
when(fs.promises.readdir(matchers.anything())).thenResolve([])
|
|
const copiedFileUri = await ff.putFileIntoDownloadsFolder(src, filename)
|
|
verify(fs.promises.copyFile(src, DEFAULT_DOWNLOAD_PATH + "fileName.pdf"))
|
|
o(copiedFileUri).equals(DEFAULT_DOWNLOAD_PATH + "fileName.pdf")
|
|
})
|
|
})
|
|
|
|
o.spec("size", function () {
|
|
o("size", async function () {
|
|
when(fs.promises.stat(matchers.anything())).thenResolve({ size: 33 })
|
|
o(await ff.getSize("/file1")).equals(33)
|
|
})
|
|
})
|
|
|
|
o.spec("hash", function () {
|
|
o("hash", async function () {
|
|
when(fs.promises.readFile("/file1")).thenResolve(new Uint8Array([0, 1, 2, 3]) as Buffer)
|
|
o(await ff.hashFile("/file1")).equals("BU7ewdAh")
|
|
})
|
|
})
|
|
})
|
|
|
|
function mockReadStream(ws?: fs.WriteStream): fs.ReadStream {
|
|
const rs: fs.ReadStream = object()
|
|
if (ws != null) {
|
|
when(rs.pipe(ws, { end: false })).thenReturn(ws)
|
|
}
|
|
|
|
return rs
|
|
}
|
|
|
|
const urlMatches = matchers.create({
|
|
name: "urlMatches",
|
|
matches(matcherArgs: any[], actual: any): boolean {
|
|
return (actual as URL).toString() === (matcherArgs[0] as URL).toString()
|
|
},
|
|
})
|
|
|
|
function mockWriteStream(response?: FetchResult): fs.WriteStream {
|
|
const ws: fs.WriteStream = object()
|
|
if (response != null) {
|
|
// when(response.pipe(ws)).thenReturn(ws)
|
|
}
|
|
const closeCapturer = matchers.captor()
|
|
when(ws.on("close", closeCapturer.capture())).thenReturn(ws)
|
|
when(ws.close()).thenDo(() => closeCapturer.value())
|
|
return ws
|
|
}
|
|
|
|
function mockResponse(
|
|
statusCode: number,
|
|
resOpts: {
|
|
responseBody?: Uint8Array
|
|
responseHeaders?: Record<string, string>
|
|
},
|
|
): FetchResult {
|
|
const { responseBody, responseHeaders } = resOpts
|
|
return new global.Response(responseBody, {
|
|
status: statusCode,
|
|
headers: new Headers(responseHeaders),
|
|
}) as FetchResult
|
|
}
|