247 lines
9.2 KiB
JavaScript
247 lines
9.2 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const os = require("node:os");
|
||
|
|
const path = require("node:path");
|
||
|
|
const test = require("node:test");
|
||
|
|
const { stageRendererFileToTemp } = require("./stageUploadFile.cjs");
|
||
|
|
|
||
|
|
function createChunkedFile(chunks) {
|
||
|
|
return {
|
||
|
|
stream: () => new ReadableStream({
|
||
|
|
start(controller) {
|
||
|
|
for (const chunk of chunks) controller.enqueue(new Uint8Array(chunk));
|
||
|
|
controller.close();
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createShortWriteFs(writeSizes, onWrite = null) {
|
||
|
|
const writeCalls = [];
|
||
|
|
let writeIndex = 0;
|
||
|
|
return {
|
||
|
|
writeCalls,
|
||
|
|
fsImpl: {
|
||
|
|
promises: {
|
||
|
|
open: async (...args) => {
|
||
|
|
const handle = await fs.promises.open(...args);
|
||
|
|
return {
|
||
|
|
write: async (buffer, offset = 0, length = buffer.length - offset, position = null) => {
|
||
|
|
const requestedLength = Math.max(0, Number(length));
|
||
|
|
const configuredLength = writeSizes[writeIndex] ?? requestedLength;
|
||
|
|
const actualLength = Math.min(requestedLength, configuredLength);
|
||
|
|
writeCalls.push({ offset, length: requestedLength, position, actualLength });
|
||
|
|
const result = await handle.write(buffer, offset, actualLength, position);
|
||
|
|
writeIndex += 1;
|
||
|
|
await onWrite?.({ writeIndex, result });
|
||
|
|
return result;
|
||
|
|
},
|
||
|
|
close: () => handle.close(),
|
||
|
|
};
|
||
|
|
},
|
||
|
|
unlink: (...args) => fs.promises.unlink(...args),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("pathless renderer files stream into a controlled temp file without arrayBuffer", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
let arrayBufferCalls = 0;
|
||
|
|
const chunks = [new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])];
|
||
|
|
const file = {
|
||
|
|
arrayBuffer: async () => { arrayBufferCalls += 1; return new ArrayBuffer(0); },
|
||
|
|
stream: () => new ReadableStream({
|
||
|
|
start(controller) {
|
||
|
|
for (const chunk of chunks) controller.enqueue(chunk);
|
||
|
|
controller.close();
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
assert.equal(await stageRendererFileToTemp(file, localPath, fs), localPath);
|
||
|
|
assert.deepEqual(await fs.promises.readFile(localPath), Buffer.from([1, 2, 3, 4, 5]));
|
||
|
|
assert.equal(arrayBufferCalls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a partial file-handle write retries the unwritten suffix", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-short-write-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
const { fsImpl, writeCalls } = createShortWriteFs([2]);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
await stageRendererFileToTemp(createChunkedFile([[1, 2, 3, 4, 5]]), localPath, fsImpl),
|
||
|
|
localPath,
|
||
|
|
);
|
||
|
|
assert.deepEqual(await fs.promises.readFile(localPath), Buffer.from([1, 2, 3, 4, 5]));
|
||
|
|
assert.deepEqual(writeCalls.map(({ offset, length, position }) => ({ offset, length, position })), [
|
||
|
|
{ offset: 0, length: 5, position: 0 },
|
||
|
|
{ offset: 2, length: 3, position: 2 },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("multiple partial writes across chunks preserve every byte and advance offsets", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-many-short-writes-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
const { fsImpl, writeCalls } = createShortWriteFs([1, 2, 1, 1]);
|
||
|
|
|
||
|
|
await stageRendererFileToTemp(
|
||
|
|
createChunkedFile([[10, 11, 12, 13], [20, 21]]),
|
||
|
|
localPath,
|
||
|
|
fsImpl,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(await fs.promises.readFile(localPath), Buffer.from([10, 11, 12, 13, 20, 21]));
|
||
|
|
assert.deepEqual(writeCalls.map(({ offset, length, position }) => ({ offset, length, position })), [
|
||
|
|
{ offset: 0, length: 4, position: 0 },
|
||
|
|
{ offset: 1, length: 3, position: 1 },
|
||
|
|
{ offset: 3, length: 1, position: 3 },
|
||
|
|
{ offset: 0, length: 2, position: 4 },
|
||
|
|
{ offset: 1, length: 1, position: 5 },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a zero-byte file-handle write fails instead of spinning and removes the temp file", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-zero-write-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
const { fsImpl, writeCalls } = createShortWriteFs([0]);
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
stageRendererFileToTemp(createChunkedFile([[1, 2, 3]]), localPath, fsImpl),
|
||
|
|
/Unable to stage the complete upload file/,
|
||
|
|
);
|
||
|
|
assert.equal(writeCalls.length, 1);
|
||
|
|
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("cancelling after a partial write stops the current chunk and removes the temp file", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-cancel-short-write-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
const controller = new AbortController();
|
||
|
|
const { fsImpl, writeCalls } = createShortWriteFs([2], ({ writeIndex }) => {
|
||
|
|
if (writeIndex === 1) controller.abort(new Error("cancel mid-chunk"));
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
stageRendererFileToTemp(
|
||
|
|
createChunkedFile([[1, 2, 3, 4, 5]]),
|
||
|
|
localPath,
|
||
|
|
fsImpl,
|
||
|
|
controller.signal,
|
||
|
|
),
|
||
|
|
/cancel mid-chunk/,
|
||
|
|
);
|
||
|
|
assert.equal(writeCalls.length, 1);
|
||
|
|
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("failed pathless-file staging removes its partial temp file", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-fail-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
let reads = 0;
|
||
|
|
const file = {
|
||
|
|
stream: () => ({
|
||
|
|
getReader: () => ({
|
||
|
|
read: async () => {
|
||
|
|
reads += 1;
|
||
|
|
if (reads === 1) return { done: false, value: new Uint8Array([1]) };
|
||
|
|
throw new Error("source failed");
|
||
|
|
},
|
||
|
|
releaseLock: () => {},
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
await assert.rejects(stageRendererFileToTemp(file, localPath, fs), /source failed/);
|
||
|
|
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a synchronous File.stream failure closes the new handle and removes the temp file", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-stream-init-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
|
||
|
|
await assert.rejects(stageRendererFileToTemp({
|
||
|
|
stream() {
|
||
|
|
throw new Error("stream init failed");
|
||
|
|
},
|
||
|
|
}, localPath, fs), /stream init failed/);
|
||
|
|
|
||
|
|
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a synchronous getReader failure closes the new handle and removes the temp file", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-reader-init-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
|
||
|
|
await assert.rejects(stageRendererFileToTemp({
|
||
|
|
stream: () => ({
|
||
|
|
getReader() {
|
||
|
|
throw new Error("reader init failed");
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
}, localPath, fs), /reader init failed/);
|
||
|
|
|
||
|
|
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("stream initialization failure still closes the handle when temp deletion fails", async () => {
|
||
|
|
let closeCalls = 0;
|
||
|
|
let unlinkCalls = 0;
|
||
|
|
const fakeFs = {
|
||
|
|
promises: {
|
||
|
|
open: async () => ({
|
||
|
|
close: async () => { closeCalls += 1; },
|
||
|
|
}),
|
||
|
|
unlink: async () => {
|
||
|
|
unlinkCalls += 1;
|
||
|
|
throw new Error("delete denied");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
await assert.rejects(stageRendererFileToTemp({
|
||
|
|
stream() {
|
||
|
|
throw new Error("stream init failed");
|
||
|
|
},
|
||
|
|
}, "/controlled/upload.part", fakeFs), /stream init failed/);
|
||
|
|
|
||
|
|
assert.equal(unlinkCalls, 1);
|
||
|
|
assert.ok(closeCalls >= 1, "the file handle must close even if cleanup unlink fails");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("cancelling a blocked pathless-file read closes it and removes the partial file", async (t) => {
|
||
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-cancel-"));
|
||
|
|
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(dir, "upload.part");
|
||
|
|
const controller = new AbortController();
|
||
|
|
let cancelCalls = 0;
|
||
|
|
let finishRead;
|
||
|
|
const file = {
|
||
|
|
stream: () => ({
|
||
|
|
getReader: () => ({
|
||
|
|
read: () => new Promise((resolve) => { finishRead = resolve; }),
|
||
|
|
cancel: async () => {
|
||
|
|
cancelCalls += 1;
|
||
|
|
finishRead?.({ done: true });
|
||
|
|
},
|
||
|
|
releaseLock: () => {},
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
const staging = stageRendererFileToTemp(file, localPath, fs, controller.signal);
|
||
|
|
while (!finishRead) await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
controller.abort(new Error("cancel now"));
|
||
|
|
await assert.rejects(staging, /cancel now/);
|
||
|
|
assert.equal(cancelCalls, 1);
|
||
|
|
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||
|
|
});
|