mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
fix(sky-8861): UI shows real filename for short artifact URLs (#5666)
This commit is contained in:
parent
595955614e
commit
a6203ef52b
4 changed files with 92 additions and 9 deletions
|
|
@ -40,6 +40,7 @@ import { useWorkflowRunWithWorkflowQuery } from "./hooks/useWorkflowRunWithWorkf
|
|||
import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline";
|
||||
import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery";
|
||||
import { findActiveItem } from "./workflowRun/workflowTimelineUtils";
|
||||
import { filenameForDownloadedFileUrl } from "./workflowRun/blockDownloadedFiles";
|
||||
import { isBlockItem } from "./types/workflowRunTypes";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CodeEditor } from "./components/CodeEditor";
|
||||
|
|
@ -515,9 +516,7 @@ function WorkflowRun() {
|
|||
<ScrollAreaViewport className="max-h-[250px] space-y-2">
|
||||
{fileUrls.length > 0 ? (
|
||||
fileUrls.map((url) => {
|
||||
// Extract filename from URL path, stripping query params from signed URLs
|
||||
const urlPath = url.split("?")[0] ?? url;
|
||||
const filename = urlPath.split("/").pop() || "download";
|
||||
const filename = filenameForDownloadedFileUrl(url);
|
||||
return (
|
||||
<div key={url} title={url} className="flex gap-2">
|
||||
<FileIcon className="size-6" />
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResi
|
|||
import { SummarizeOutput } from "@/components/SummarizeOutput";
|
||||
import { isTaskVariantBlock } from "../types/workflowTypes";
|
||||
import { statusIsAFailureType } from "@/routes/tasks/types";
|
||||
import { getBlockDownloadedFileUrls } from "./blockDownloadedFiles";
|
||||
import {
|
||||
filenameForDownloadedFileUrl,
|
||||
getBlockDownloadedFileUrls,
|
||||
} from "./blockDownloadedFiles";
|
||||
|
||||
function SummaryDisplay({
|
||||
summary,
|
||||
|
|
@ -344,9 +347,7 @@ function WorkflowRunOutput() {
|
|||
<div className="space-y-2">
|
||||
{fileUrls.length > 0 ? (
|
||||
fileUrls.map((url) => {
|
||||
// Extract filename from URL path, stripping query params from signed URLs
|
||||
const urlPath = url.split("?")[0] ?? url;
|
||||
const filename = urlPath.split("/").pop() || "download";
|
||||
const filename = filenameForDownloadedFileUrl(url);
|
||||
return (
|
||||
<div key={url} title={url} className="flex gap-2">
|
||||
<FileIcon className="size-6" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,50 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { getBlockDownloadedFileUrls } from "./blockDownloadedFiles";
|
||||
import {
|
||||
filenameForDownloadedFileUrl,
|
||||
getBlockDownloadedFileUrls,
|
||||
} from "./blockDownloadedFiles";
|
||||
|
||||
describe("filenameForDownloadedFileUrl", () => {
|
||||
test("short signed URL: pulls filename from artifact_name query param", () => {
|
||||
const url =
|
||||
"https://api.skyvern.com/v1/artifacts/a_123/content" +
|
||||
"?expiry=1&kid=k&sig=s&artifact_name=invoice-2026.pdf&artifact_type=download";
|
||||
expect(filenameForDownloadedFileUrl(url)).toBe("invoice-2026.pdf");
|
||||
});
|
||||
|
||||
test("short signed URL with non-ASCII filename: round-trips via URL decoding", () => {
|
||||
const url =
|
||||
"https://api.skyvern.com/v1/artifacts/a_123/content" +
|
||||
"?expiry=1&kid=k&sig=s&artifact_name=" +
|
||||
encodeURIComponent("\u62a5\u544a-2026.pdf");
|
||||
expect(filenameForDownloadedFileUrl(url)).toBe("报告-2026.pdf");
|
||||
});
|
||||
|
||||
test("legacy S3 presigned URL: pulls filename from path basename", () => {
|
||||
const url =
|
||||
"https://skyvern-uploads.s3.amazonaws.com/" +
|
||||
"downloads/production/o_1/wr_1/legacy-report.pdf" +
|
||||
"?AWSAccessKeyId=ASIA&Signature=sig&Expires=1234567890";
|
||||
expect(filenameForDownloadedFileUrl(url)).toBe("legacy-report.pdf");
|
||||
});
|
||||
|
||||
test("legacy S3 presigned URL with percent-encoded filename: decodes the basename", () => {
|
||||
const url =
|
||||
"https://skyvern-uploads.s3.amazonaws.com/" +
|
||||
"downloads/production/o_1/wr_1/Q2%20report.pdf" +
|
||||
"?AWSAccessKeyId=x&Signature=y";
|
||||
expect(filenameForDownloadedFileUrl(url)).toBe("Q2 report.pdf");
|
||||
});
|
||||
|
||||
test("short URL without artifact_name and trailing /content: returns 'download'", () => {
|
||||
const url = "https://api.skyvern.com/v1/artifacts/a_123/content?sig=x";
|
||||
expect(filenameForDownloadedFileUrl(url)).toBe("download");
|
||||
});
|
||||
|
||||
test("malformed URL: returns 'download'", () => {
|
||||
expect(filenameForDownloadedFileUrl("not a url")).toBe("download");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlockDownloadedFileUrls", () => {
|
||||
test("returns [] when blockOutput is null/undefined/string/array", () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,44 @@ function urlKey(url: string): string {
|
|||
return url.split("?")[0] ?? url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort filename for a downloaded-file URL.
|
||||
*
|
||||
* Two URL shapes are in the wild:
|
||||
* 1. Short signed artifact URLs introduced in SKY-8861, of shape
|
||||
* ``/v1/artifacts/{id}/content?artifact_name=foo.pdf&...``. The path
|
||||
* basename is always ``content``; the real filename is in the query
|
||||
* parameter.
|
||||
* 2. Legacy S3 presigned URLs, of shape
|
||||
* ``https://skyvern-uploads.s3.amazonaws.com/downloads/.../foo.pdf?...``.
|
||||
* The path basename *is* the filename, but it may be percent-encoded
|
||||
* for filenames containing spaces / unicode.
|
||||
*/
|
||||
function filenameForDownloadedFileUrl(url: string): string {
|
||||
const fallback = "download";
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
// searchParams.get auto-decodes percent-encoding, so non-ASCII
|
||||
// artifact_name values round-trip without an explicit decodeURIComponent.
|
||||
const fromQuery = parsed.searchParams.get("artifact_name");
|
||||
if (fromQuery) {
|
||||
return fromQuery;
|
||||
}
|
||||
const last = parsed.pathname.split("/").pop();
|
||||
if (!last || last === "content") {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(last);
|
||||
} catch {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockDownloadedFileUrls(
|
||||
blockOutput: object | Array<unknown> | string | null | undefined,
|
||||
freshFallbackUrls: ReadonlyArray<string>,
|
||||
|
|
@ -41,4 +79,4 @@ function getBlockDownloadedFileUrls(
|
|||
return blockUrls.map((url) => freshByPath.get(urlKey(url)) ?? url);
|
||||
}
|
||||
|
||||
export { getBlockDownloadedFileUrls };
|
||||
export { filenameForDownloadedFileUrl, getBlockDownloadedFileUrls };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue