mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes * test(css): exercise the configured PostCSS plugin chain
This commit is contained in:
1 parent
26d52c7631
commit
2a2ba2f790
6 files changed
+283
-16
No files matched your search
@@ -1244,7 +1244,7 @@ export function registerFileContentRoutes(
|
|||||||
let destroyUpload: (() => void) | undefined;
|
let destroyUpload: (() => void) | undefined;
|
||||||
|
|
||||||
const abortUpload = () => {
|
const abortUpload = () => {
|
||||||
if (req.complete || cleanupStarted) return;
|
if (resolved || cleanupStarted) return;
|
||||||
requestAborted = true;
|
requestAborted = true;
|
||||||
cleanupStarted = true;
|
cleanupStarted = true;
|
||||||
destroyUpload?.();
|
destroyUpload?.();
|
||||||
@@ -1252,7 +1252,12 @@ export function registerFileContentRoutes(
|
|||||||
|
|
||||||
req.once("aborted", abortUpload);
|
req.once("aborted", abortUpload);
|
||||||
req.once("error", abortUpload);
|
req.once("error", abortUpload);
|
||||||
req.once("close", abortUpload);
|
req.once("close", () => {
|
||||||
|
if (!req.complete) abortUpload();
|
||||||
|
});
|
||||||
|
res.once("close", () => {
|
||||||
|
if (!res.writableFinished) abortUpload();
|
||||||
|
});
|
||||||
|
|
||||||
const bb = Busboy({ headers: req.headers });
|
const bb = Busboy({ headers: req.headers });
|
||||||
|
|
||||||
@@ -1311,6 +1316,10 @@ export function registerFileContentRoutes(
|
|||||||
|
|
||||||
getSessionSftp(sshConn)
|
getSessionSftp(sshConn)
|
||||||
.then((sftp) => {
|
.then((sftp) => {
|
||||||
|
if (requestAborted) {
|
||||||
|
fileStream.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const writeStream = sftp.createWriteStream(fullPath);
|
const writeStream = sftp.createWriteStream(fullPath);
|
||||||
const removePartialFile = () => {
|
const removePartialFile = () => {
|
||||||
writeStream.destroy();
|
writeStream.destroy();
|
||||||
@@ -1325,11 +1334,6 @@ export function registerFileContentRoutes(
|
|||||||
};
|
};
|
||||||
destroyUpload = removePartialFile;
|
destroyUpload = removePartialFile;
|
||||||
|
|
||||||
if (requestAborted) {
|
|
||||||
removePartialFile();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeStream.on("error", (err) => {
|
writeStream.on("error", (err) => {
|
||||||
ctx.log.error("SFTP write stream error during upload:", err);
|
ctx.log.error("SFTP write stream error during upload:", err);
|
||||||
if (!resolved && !requestAborted) {
|
if (!resolved && !requestAborted) {
|
||||||
@@ -1460,12 +1464,24 @@ export function registerFileContentRoutes(
|
|||||||
let bytesWritten = 0;
|
let bytesWritten = 0;
|
||||||
const uploadStartTime = Date.now();
|
const uploadStartTime = Date.now();
|
||||||
|
|
||||||
|
let destroyUpload: (() => void) | undefined;
|
||||||
|
res.once("close", () => {
|
||||||
|
if (resolved || res.writableFinished) return;
|
||||||
|
resolved = true;
|
||||||
|
destroyUpload?.();
|
||||||
|
});
|
||||||
|
|
||||||
getSessionSftp(sshConn)
|
getSessionSftp(sshConn)
|
||||||
.then((sftp) => {
|
.then((sftp) => {
|
||||||
|
if (resolved) return;
|
||||||
const writeStream = sftp.createWriteStream(fullPath, {
|
const writeStream = sftp.createWriteStream(fullPath, {
|
||||||
flags: offset === 0 ? "w" : "r+",
|
flags: offset === 0 ? "w" : "r+",
|
||||||
start: offset,
|
start: offset,
|
||||||
});
|
});
|
||||||
|
destroyUpload = () => {
|
||||||
|
req.unpipe(writeStream as unknown as NodeJS.WritableStream);
|
||||||
|
writeStream.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
const fail = (status: number, error: string) => {
|
const fail = (status: number, error: string) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
|
|||||||
@@ -1185,9 +1185,14 @@ function FileManagerContent({
|
|||||||
|
|
||||||
if (files.length === 0 && emptyDirs.length === 0) return;
|
if (files.length === 0 && emptyDirs.length === 0) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cancel = {
|
||||||
|
label: t("fileManager.cancel"),
|
||||||
|
onClick: () => controller.abort(),
|
||||||
|
};
|
||||||
const progressToast = toast.loading(
|
const progressToast = toast.loading(
|
||||||
t("fileManager.uploadingFolderFiles", { count: files.length }),
|
t("fileManager.uploadingFolderFiles", { count: files.length }),
|
||||||
{ duration: Infinity },
|
{ duration: Infinity, cancel },
|
||||||
);
|
);
|
||||||
|
|
||||||
const failed: string[] = [];
|
const failed: string[] = [];
|
||||||
@@ -1214,6 +1219,7 @@ function FileManagerContent({
|
|||||||
a.split("/").length - b.split("/").length || a.localeCompare(b),
|
a.split("/").length - b.split("/").length || a.localeCompare(b),
|
||||||
);
|
);
|
||||||
for (const dir of sortedDirs) {
|
for (const dir of sortedDirs) {
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
const parentDir = dir.split("/").slice(0, -1).join("/");
|
const parentDir = dir.split("/").slice(0, -1).join("/");
|
||||||
const targetPath = parentDir ? `${base}${parentDir}/` : base;
|
const targetPath = parentDir ? `${base}${parentDir}/` : base;
|
||||||
const folderName = dir.split("/").pop()!;
|
const folderName = dir.split("/").pop()!;
|
||||||
@@ -1226,6 +1232,7 @@ function FileManagerContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const { file, relativePath } of files) {
|
for (const { file, relativePath } of files) {
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
const dirPart = relativePath.includes("/")
|
const dirPart = relativePath.includes("/")
|
||||||
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
||||||
: "";
|
: "";
|
||||||
@@ -1238,13 +1245,18 @@ function FileManagerContent({
|
|||||||
file.name,
|
file.name,
|
||||||
file,
|
file,
|
||||||
currentHost?.id,
|
currentHost?.id,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
controller.signal,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
failed.push(relativePath);
|
failed.push(relativePath);
|
||||||
console.error(`Failed to upload ${relativePath}:`, error);
|
console.error(`Failed to upload ${relativePath}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
toast.dismiss(progressToast);
|
toast.dismiss(progressToast);
|
||||||
if (failed.length === 0) {
|
if (failed.length === 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -1263,6 +1275,11 @@ function FileManagerContent({
|
|||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.dismiss(progressToast);
|
toast.dismiss(progressToast);
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
toast.info(t("fileManager.localTransferCancelled"));
|
||||||
|
handleRefreshDirectory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(t("fileManager.failedToUploadFile"));
|
toast.error(t("fileManager.failedToUploadFile"));
|
||||||
console.error("Folder upload failed:", error);
|
console.error("Folder upload failed:", error);
|
||||||
}
|
}
|
||||||
@@ -1280,12 +1297,17 @@ function FileManagerContent({
|
|||||||
async function handleUploadFile(file: File) {
|
async function handleUploadFile(file: File) {
|
||||||
if (!sshSessionId) return;
|
if (!sshSessionId) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cancel = {
|
||||||
|
label: t("fileManager.cancel"),
|
||||||
|
onClick: () => controller.abort(),
|
||||||
|
};
|
||||||
const progressToast = toast.loading(
|
const progressToast = toast.loading(
|
||||||
t("fileManager.uploadingFile", {
|
t("fileManager.uploadingFile", {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: formatFileSize(file.size),
|
size: formatFileSize(file.size),
|
||||||
}),
|
}),
|
||||||
{ duration: Infinity },
|
{ duration: Infinity, cancel },
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateProgress = (p: {
|
const updateProgress = (p: {
|
||||||
@@ -1294,13 +1316,14 @@ function FileManagerContent({
|
|||||||
bytesSent: number;
|
bytesSent: number;
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
const percent = Math.min(
|
const percent = Math.min(
|
||||||
100,
|
100,
|
||||||
Math.round((p.bytesSent / p.totalBytes) * 100),
|
Math.round((p.bytesSent / p.totalBytes) * 100),
|
||||||
);
|
);
|
||||||
toast.loading(
|
toast.loading(
|
||||||
`Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`,
|
`Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`,
|
||||||
{ id: progressToast, duration: Infinity },
|
{ id: progressToast, duration: Infinity, cancel },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1315,7 +1338,9 @@ function FileManagerContent({
|
|||||||
currentHost?.id,
|
currentHost?.id,
|
||||||
undefined,
|
undefined,
|
||||||
updateProgress,
|
updateProgress,
|
||||||
|
controller.signal,
|
||||||
);
|
);
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
toast.dismiss(progressToast);
|
toast.dismiss(progressToast);
|
||||||
|
|
||||||
@@ -1325,6 +1350,11 @@ function FileManagerContent({
|
|||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.dismiss(progressToast);
|
toast.dismiss(progressToast);
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
toast.info(t("fileManager.localTransferCancelled"));
|
||||||
|
handleRefreshDirectory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const uploadErr = error instanceof Error ? error : null;
|
const uploadErr = error instanceof Error ? error : null;
|
||||||
if (
|
if (
|
||||||
uploadErr?.message?.includes("connection") ||
|
uploadErr?.message?.includes("connection") ||
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ export async function uploadSSHFile(
|
|||||||
bytesSent: number;
|
bytesSent: number;
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
}) => void,
|
}) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
// Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB)
|
// Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB)
|
||||||
// crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox,
|
// crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox,
|
||||||
@@ -368,6 +369,7 @@ export async function uploadSSHFile(
|
|||||||
const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (file.size > CHUNK_THRESHOLD_BYTES) {
|
if (file.size > CHUNK_THRESHOLD_BYTES) {
|
||||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
|
||||||
fileLogger.info("Starting chunked upload", {
|
fileLogger.info("Starting chunked upload", {
|
||||||
@@ -380,6 +382,7 @@ export async function uploadSSHFile(
|
|||||||
|
|
||||||
let bytesSent = 0;
|
let bytesSent = 0;
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const start = i * CHUNK_SIZE_BYTES;
|
const start = i * CHUNK_SIZE_BYTES;
|
||||||
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
|
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
|
||||||
const chunkBlob = file.slice(start, end);
|
const chunkBlob = file.slice(start, end);
|
||||||
@@ -397,6 +400,7 @@ export async function uploadSSHFile(
|
|||||||
},
|
},
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
|
signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -433,6 +437,7 @@ export async function uploadSSHFile(
|
|||||||
form,
|
form,
|
||||||
{
|
{
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
|
signal,
|
||||||
onUploadProgress: (event) => {
|
onUploadProgress: (event) => {
|
||||||
const totalBytes =
|
const totalBytes =
|
||||||
typeof event.total === "number" && event.total > 0
|
typeof event.total === "number" && event.total > 0
|
||||||
@@ -449,6 +454,7 @@ export async function uploadSSHFile(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
handleApiError(error, "upload SSH file");
|
handleApiError(error, "upload SSH file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { PassThrough, Writable } from "node:stream";
|
||||||
|
import type { Express, Request, Response } from "express";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
|
import { registerFileContentRoutes } from "../../src/backend/content-routes";
|
||||||
|
import type { SSHSession } from "../../src/backend/session";
|
||||||
|
|
||||||
|
const { getSessionSftp } = vi.hoisted(() => ({ getSessionSftp: vi.fn() }));
|
||||||
|
vi.mock("../../src/backend/session.js", () => ({ getSessionSftp }));
|
||||||
|
|
||||||
|
function setup(route = "/uploadFileChunk") {
|
||||||
|
const routes = new Map<string, (req: Request, res: Response) => void>();
|
||||||
|
const app = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: (path: string, handler: (req: Request, res: Response) => void) =>
|
||||||
|
routes.set(path, handler),
|
||||||
|
} as unknown as Express;
|
||||||
|
registerFileContentRoutes(app, {
|
||||||
|
ctx: {
|
||||||
|
currentActor: () => "user-1",
|
||||||
|
log: { info: vi.fn(), error: vi.fn() },
|
||||||
|
} as unknown as PluginContext,
|
||||||
|
sshSessions: { s: { isConnected: true } as SSHSession },
|
||||||
|
verifySessionOwnership: () => true,
|
||||||
|
});
|
||||||
|
const req = Object.assign(new PassThrough(), {
|
||||||
|
query: {
|
||||||
|
sessionId: "s",
|
||||||
|
path: "/",
|
||||||
|
fileName: "upload.bin",
|
||||||
|
offset: "0",
|
||||||
|
totalSize: "4",
|
||||||
|
},
|
||||||
|
headers: { "content-type": "multipart/form-data; boundary=test-boundary" },
|
||||||
|
body: Buffer.from("data"),
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
|
const res = Object.assign(new EventEmitter(), {
|
||||||
|
writableFinished: false,
|
||||||
|
json: vi.fn(),
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
});
|
||||||
|
const start = () =>
|
||||||
|
routes.get(route)!(req as unknown as Request, res as unknown as Response);
|
||||||
|
return { req, res, start };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sftp() {
|
||||||
|
// Keep the SSH write pending after HTTP has received the complete body.
|
||||||
|
const stream = new Writable({ write() {} });
|
||||||
|
return { stream, createWriteStream: vi.fn(() => stream) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chunk upload disconnects", () => {
|
||||||
|
it("stops SSH writes when the client disconnects after sending its body", async () => {
|
||||||
|
const remote = sftp();
|
||||||
|
getSessionSftp.mockResolvedValue(remote);
|
||||||
|
const { res, start } = setup();
|
||||||
|
start();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(remote.createWriteStream).toHaveBeenCalledOnce();
|
||||||
|
res.emit("close");
|
||||||
|
expect(remote.stream.destroyed).toBe(true);
|
||||||
|
remote.stream.emit("finish");
|
||||||
|
expect(res.json).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open a remote file if cancelled while waiting for SFTP", async () => {
|
||||||
|
const remote = sftp();
|
||||||
|
let connect!: (value: typeof remote) => void;
|
||||||
|
getSessionSftp.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
connect = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { res, start } = setup();
|
||||||
|
start();
|
||||||
|
res.emit("close");
|
||||||
|
connect(remote);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(remote.createWriteStream).not.toHaveBeenCalled();
|
||||||
|
expect(res.json).not.toHaveBeenCalled();
|
||||||
|
remote.stream.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cancel a successfully completed response", async () => {
|
||||||
|
const remote = sftp();
|
||||||
|
getSessionSftp.mockResolvedValue(remote);
|
||||||
|
const { res, start } = setup();
|
||||||
|
start();
|
||||||
|
await Promise.resolve();
|
||||||
|
remote.stream.emit("finish");
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ complete: true }),
|
||||||
|
);
|
||||||
|
res.writableFinished = true;
|
||||||
|
res.emit("close");
|
||||||
|
expect(remote.stream.destroyed).toBe(false);
|
||||||
|
remote.stream.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multipart upload disconnects", () => {
|
||||||
|
it("keeps normal request completion alive but cancels unfinished SSH writes on response disconnect", async () => {
|
||||||
|
const remote = {
|
||||||
|
...sftp(),
|
||||||
|
unlink: vi.fn((_path, callback) => callback(null)),
|
||||||
|
};
|
||||||
|
getSessionSftp.mockResolvedValue(remote);
|
||||||
|
const { req, res, start } = setup("/uploadFileStream");
|
||||||
|
start();
|
||||||
|
req.end(
|
||||||
|
[
|
||||||
|
"--test-boundary",
|
||||||
|
'Content-Disposition: form-data; name="sessionId"',
|
||||||
|
"",
|
||||||
|
"s",
|
||||||
|
"--test-boundary",
|
||||||
|
'Content-Disposition: form-data; name="path"',
|
||||||
|
"",
|
||||||
|
"/",
|
||||||
|
"--test-boundary",
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="a.txt"',
|
||||||
|
"Content-Type: text/plain",
|
||||||
|
"",
|
||||||
|
"data",
|
||||||
|
"--test-boundary--",
|
||||||
|
"",
|
||||||
|
].join("\r\n"),
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
expect(remote.createWriteStream).toHaveBeenCalledWith("/a.txt");
|
||||||
|
req.emit("close");
|
||||||
|
expect(remote.stream.destroyed).toBe(false);
|
||||||
|
res.emit("close");
|
||||||
|
expect(remote.stream.destroyed).toBe(true);
|
||||||
|
expect(remote.unlink).toHaveBeenCalledWith("/a.txt", expect.any(Function));
|
||||||
|
expect(res.json).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const fileManagerApiMock = vi.hoisted(() => ({
|
const fileManagerApiMock = vi.hoisted(() => ({
|
||||||
post: vi.fn(async () => ({ data: { complete: false } })),
|
post: vi.fn(async () => ({ data: { complete: false } })),
|
||||||
|
postForm: vi.fn(async () => ({ data: {} })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../src/frontend/api/client", () => ({
|
vi.mock("../../../src/frontend/api/client", () => ({
|
||||||
@@ -36,6 +37,7 @@ import { uploadSSHFile } from "../../../src/frontend/api/ssh-file-operations-api
|
|||||||
describe("chunked SSH file uploads", () => {
|
describe("chunked SSH file uploads", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fileManagerApiMock.post.mockClear();
|
fileManagerApiMock.post.mockClear();
|
||||||
|
fileManagerApiMock.postForm.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends raw chunks with the byte offset expected by the server", async () => {
|
it("sends raw chunks with the byte offset expected by the server", async () => {
|
||||||
@@ -62,6 +64,7 @@ describe("chunked SSH file uploads", () => {
|
|||||||
},
|
},
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
|
signal: undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
|
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
|
||||||
@@ -73,3 +76,71 @@ describe("chunked SSH file uploads", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("upload cancellation", () => {
|
||||||
|
it("passes the signal to multipart uploads", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
await uploadSSHFile(
|
||||||
|
"s",
|
||||||
|
"/",
|
||||||
|
"a.txt",
|
||||||
|
new File(["data"], "a.txt"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
expect(fileManagerApiMock.postForm).toHaveBeenLastCalledWith(
|
||||||
|
"/uploadFileStream",
|
||||||
|
expect.any(FormData),
|
||||||
|
expect.objectContaining({ signal: controller.signal }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start an already cancelled upload", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
fileManagerApiMock.postForm.mockClear();
|
||||||
|
await expect(
|
||||||
|
uploadSSHFile(
|
||||||
|
"s",
|
||||||
|
"/",
|
||||||
|
"a.txt",
|
||||||
|
new File(["data"], "a.txt"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
controller.signal,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(fileManagerApiMock.postForm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops scheduling chunks after cancellation", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fileManagerApiMock.post.mockClear();
|
||||||
|
const file = {
|
||||||
|
size: 2 * 1024 ** 3,
|
||||||
|
slice: vi.fn(() => new Blob(["chunk"])),
|
||||||
|
} as unknown as File;
|
||||||
|
await expect(
|
||||||
|
uploadSSHFile(
|
||||||
|
"s",
|
||||||
|
"/",
|
||||||
|
"large.bin",
|
||||||
|
file,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => controller.abort(),
|
||||||
|
controller.signal,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(fileManagerApiMock.post).toHaveBeenCalledOnce();
|
||||||
|
expect(fileManagerApiMock.post).toHaveBeenCalledWith(
|
||||||
|
"/uploadFileChunk",
|
||||||
|
expect.any(Blob),
|
||||||
|
expect.objectContaining({ signal: controller.signal }),
|
||||||
|
);
|
||||||
|
expect(file.slice).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import postcss from "postcss";
|
import postcss, { type AcceptedPlugin } from "postcss";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const config = require("../postcss.config.cjs") as {
|
const config = require("../postcss.config.cjs") as {
|
||||||
plugins: Record<string, unknown>;
|
plugins: AcceptedPlugin[];
|
||||||
};
|
};
|
||||||
const pxtorem = require("postcss-pxtorem");
|
|
||||||
|
|
||||||
async function convert(css: string, from = "src/ui/index.css") {
|
async function convert(css: string, from = "src/ui/index.css") {
|
||||||
const result = await postcss([
|
const result = await postcss(config.plugins).process(css, { from });
|
||||||
pxtorem(config.plugins["postcss-pxtorem"]),
|
|
||||||
]).process(css, { from });
|
|
||||||
return result.css;
|
return result.css;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("interface size conversion", () => {
|
describe("interface size conversion", () => {
|
||||||
|
it("preserves circular radii emitted in exponent notation", async () => {
|
||||||
|
expect(await convert(".a{border-radius:3.40282e38px}")).toBe(
|
||||||
|
".a{border-radius:calc(infinity * 1px)}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("turns px into rem against the 14px Normal root", async () => {
|
it("turns px into rem against the 14px Normal root", async () => {
|
||||||
expect(await convert(".a{font-size:10px;width:280px}")).toBe(
|
expect(await convert(".a{font-size:10px;width:280px}")).toBe(
|
||||||
".a{font-size:0.71429rem;width:20rem}",
|
".a{font-size:0.71429rem;width:20rem}",
|
||||||
|
|||||||
Reference in new issue
Block a user