Expose actionable asset download and filesystem failure details
This commit is contained in:
1 parent
8a919f85b2
commit
8c1efae296
4 files changed
+121
-18
No files matched your search
@@ -159,6 +159,7 @@ it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata
|
|||||||
]),
|
]),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
"swf",
|
"swf",
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(mocks.build).toHaveBeenCalledWith(
|
expect(mocks.build).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -731,13 +731,19 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||||
// candidate URLs in order until one succeeds.
|
// candidate URLs in order until one succeeds.
|
||||||
|
let iconFailure = "",
|
||||||
|
swfFailure = "";
|
||||||
const [iconOkResult, swfOk] = await Promise.all([
|
const [iconOkResult, swfOk] = await Promise.all([
|
||||||
preservingExisting && existsSync(iconPath)
|
preservingExisting && existsSync(iconPath)
|
||||||
? Promise.resolve(true)
|
? Promise.resolve(true)
|
||||||
: tryDownloadCandidates(iconUrls, iconPath, "png"),
|
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
||||||
|
iconFailure = detail;
|
||||||
|
}),
|
||||||
preservingExisting && existsSync(swfPath)
|
preservingExisting && existsSync(swfPath)
|
||||||
? Promise.resolve(true)
|
? Promise.resolve(true)
|
||||||
: tryDownloadCandidates(swfUrls, swfPath, "swf"),
|
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
||||||
|
swfFailure = detail;
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
let iconOk = iconOkResult;
|
let iconOk = iconOkResult;
|
||||||
|
|
||||||
@@ -772,10 +778,13 @@ export async function importSingleFurni(params: {
|
|||||||
warnings.push("Icon download failed and SWF icon extraction also failed");
|
warnings.push("Icon download failed and SWF icon extraction also failed");
|
||||||
}
|
}
|
||||||
} else if (!iconOk) {
|
} else if (!iconOk) {
|
||||||
warnings.push("Icon image download failed");
|
warnings.push(
|
||||||
|
`Icon image download failed: ${iconFailure || "unknown cause"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!swfOk && !nitroDownloadOk) warnings.push("SWF download failed");
|
if (!swfOk && !nitroDownloadOk)
|
||||||
|
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
||||||
|
|
||||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
@@ -841,12 +850,10 @@ export async function importSingleFurni(params: {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
warnings,
|
||||||
error: isUpdate
|
error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import rolled back: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
|
||||||
? "No .nitro file could be created; existing furniture was preserved"
|
.filter((warning) => /conversion|download|SWF/.test(warning))
|
||||||
: `Import rolled back: no .nitro file could be created. ${warnings
|
.slice(-3)
|
||||||
.filter((warning) => /conversion|download|SWF/.test(warning))
|
.join(". ")}`,
|
||||||
.slice(-3)
|
|
||||||
.join(". ")}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const write = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("node:fs", () => ({ promises: { writeFile: write } }));
|
||||||
|
|
||||||
|
import { downloadFile, tryDownloadCandidates } from "./download";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
fetchMock.mockReset();
|
||||||
|
write.mockReset().mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
const url = "https://cdn.example/73706/chair.swf";
|
||||||
|
describe("download failure diagnostics", () => {
|
||||||
|
it("reports HTTP status without writing an error page", async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await downloadFile(url, "/assets/chair.swf", {
|
||||||
|
validate: "swf",
|
||||||
|
maxRetries: 0,
|
||||||
|
})
|
||||||
|
).error,
|
||||||
|
).toBe("HTTP 404");
|
||||||
|
expect(write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("distinguishes a successful download from denied file permissions", async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(Buffer.from("FWS12345")));
|
||||||
|
write.mockRejectedValue(
|
||||||
|
Object.assign(new Error("denied"), { code: "EACCES" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await downloadFile(url, "/assets/chair.swf", {
|
||||||
|
validate: "swf",
|
||||||
|
maxRetries: 0,
|
||||||
|
})
|
||||||
|
).error,
|
||||||
|
).toBe("Cannot save file (EACCES)");
|
||||||
|
});
|
||||||
|
it("reports DNS failures", async () => {
|
||||||
|
fetchMock.mockRejectedValue(
|
||||||
|
Object.assign(new Error("fetch failed"), {
|
||||||
|
cause: { code: "ENOTFOUND" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await downloadFile(url, "/assets/chair.swf", { maxRetries: 0 })).error,
|
||||||
|
).toBe("Network failure (ENOTFOUND)");
|
||||||
|
});
|
||||||
|
it("reports a non-SWF response", async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response("<html>not an asset</html>"));
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await downloadFile(url, "/assets/chair.swf", {
|
||||||
|
validate: "swf",
|
||||||
|
maxRetries: 0,
|
||||||
|
})
|
||||||
|
).error,
|
||||||
|
).toBe("Response is not a SWF file");
|
||||||
|
});
|
||||||
|
it("forwards the failing path without URL credentials or query secrets", async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
|
||||||
|
const failure = vi.fn();
|
||||||
|
await tryDownloadCandidates(
|
||||||
|
["https://user:[email protected]/0/chair.swf?token=private"],
|
||||||
|
"/assets/chair.swf",
|
||||||
|
"swf",
|
||||||
|
failure,
|
||||||
|
);
|
||||||
|
expect(failure).toHaveBeenCalledWith("cdn.example/0/chair.swf: HTTP 404");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,10 +37,15 @@ export async function tryDownloadCandidates(
|
|||||||
urls: string[],
|
urls: string[],
|
||||||
destPath: string,
|
destPath: string,
|
||||||
kind: "swf" | "png",
|
kind: "swf" | "png",
|
||||||
|
onFailure?: (detail: string) => void,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
const res = await downloadFile(url, destPath, { validate: kind });
|
const res = await downloadFile(url, destPath, { validate: kind });
|
||||||
if (res.ok) return true;
|
if (res.ok) return true;
|
||||||
|
const source = new URL(url);
|
||||||
|
onFailure?.(
|
||||||
|
`${source.host}${source.pathname}: ${res.error ?? "download failed"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -49,11 +54,12 @@ export async function downloadFile(
|
|||||||
url: string,
|
url: string,
|
||||||
destPath: string,
|
destPath: string,
|
||||||
options?: { maxRetries?: number; validate?: "swf" | "png" | "nitro" },
|
options?: { maxRetries?: number; validate?: "swf" | "png" | "nitro" },
|
||||||
): Promise<{ ok: boolean; size: number }> {
|
): Promise<{ ok: boolean; size: number; error?: string }> {
|
||||||
const maxRetries = options?.maxRetries ?? 3;
|
const maxRetries = options?.maxRetries ?? 3;
|
||||||
const baseDelay = 1000;
|
const baseDelay = 1000;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
let stage: "download" | "write" = "download";
|
||||||
try {
|
try {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||||
@@ -71,40 +77,53 @@ export async function downloadFile(
|
|||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
|
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
|
||||||
);
|
);
|
||||||
return { ok: false, size: 0 };
|
return { ok: false, size: 0, error: `HTTP ${res.status}` };
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
if (buffer.length < 8) {
|
if (buffer.length < 8) {
|
||||||
if (attempt === maxRetries) return { ok: false, size: 0 };
|
if (attempt === maxRetries)
|
||||||
|
return { ok: false, size: 0, error: "Empty or truncated response" };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
|
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
|
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
|
||||||
return { ok: false, size: 0 };
|
return { ok: false, size: 0, error: "Response is not a SWF file" };
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (options?.validate === "png" && !validatePngBytes(buffer)) {
|
if (options?.validate === "png" && !validatePngBytes(buffer)) {
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
|
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
|
||||||
return { ok: false, size: 0 };
|
return { ok: false, size: 0, error: "Response is not a PNG file" };
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (options?.validate === "nitro" && !validateNitroBytes(buffer))
|
if (options?.validate === "nitro" && !validateNitroBytes(buffer))
|
||||||
return { ok: false, size: 0 };
|
return { ok: false, size: 0, error: "Invalid Nitro bundle" };
|
||||||
|
stage = "write";
|
||||||
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||||
return { ok: true, size: buffer.length };
|
return { ok: true, size: buffer.length };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries || stage === "write") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download error ${url}:`,
|
`[import-download] Download error ${url}:`,
|
||||||
(err as Error).message,
|
(err as Error).message,
|
||||||
);
|
);
|
||||||
return { ok: false, size: 0 };
|
const failure = err as {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
cause?: { code?: string };
|
||||||
|
};
|
||||||
|
const code =
|
||||||
|
failure.code ?? failure.cause?.code ?? failure.name ?? "unknown";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
size: 0,
|
||||||
|
error: `${stage === "write" ? "Cannot save file" : "Network failure"} (${code})`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user