diff --git a/packages/cli/e2e/upload.test.js b/packages/cli/e2e/upload.test.js index 9d092755..1fd784bd 100644 --- a/packages/cli/e2e/upload.test.js +++ b/packages/cli/e2e/upload.test.js @@ -4,7 +4,10 @@ import { getRequiredEnv, run } from "./utils.js"; getRequiredEnv("ARGOS_TOKEN"); -test("upload returns a full build URL", { timeout: 15_000 }, () => { +// This test uploads the full __fixtures__ directory, which includes a 10MB PNG +// stress fixture. That file is sharp-optimized, hashed, and uploaded to S3 over +// a real network connection, so a generous timeout is required to avoid flakes. +test("upload returns a full build URL", { timeout: 30_000 }, () => { const buildName = `argos-cli-e2e-node-${process.env.NODE_VERSION}-${process.env.OS}`; const uploadResult = run([ "upload", diff --git a/packages/core/src/s3.ts b/packages/core/src/s3.ts index eb4d04b8..28837e04 100644 --- a/packages/core/src/s3.ts +++ b/packages/core/src/s3.ts @@ -11,6 +11,58 @@ interface PresignedPostUploadInput extends UploadInput { fields: Record; } +/** + * On failure, S3 responds with an XML body describing the error, e.g.: + * + * + * + * AccessDenied + * Request has expired + * ... + * + * + * Extract the `Code` and `Message` so the user gets the actual reason for the + * failure instead of a generic HTTP status. Returns `null` when the body + * cannot be read or does not look like an S3 error document. + */ +async function readS3ErrorMessage(response: Response): Promise { + try { + const body = await response.text(); + // Trim captures: pretty-printed XML can leave whitespace/newlines around + // the inner text. `[\s\S]` matches across lines in case the value wraps. + const code = body.match(/([\s\S]*?)<\/Code>/)?.[1]?.trim(); + const message = body.match(/([\s\S]*?)<\/Message>/)?.[1]?.trim(); + if (code && message) { + return `${code}: ${message}`; + } + return message || code || null; + } catch { + return null; + } +} + +/** + * Build an error for a failed upload, including the S3 error details when + * available so the user can understand why the upload was rejected. + */ +async function createUploadError( + url: string, + response: Response, +): Promise { + const detail = await readS3ErrorMessage(response); + // `statusText` is often empty in Node (e.g. HTTP/2), so only append it when + // present to avoid a trailing space like "403 ". + const status = response.statusText + ? `${response.status} ${response.statusText}` + : `${response.status}`; + return new Error( + `Failed to upload file to ${url}: ${status}${detail ? ` — ${detail}` : ""}`, + ); +} + +/** + * Upload a file to S3 using a presigned PUT URL. + */ export async function uploadFile(input: UploadInput): Promise { const file = await readFile(input.path); const response = await fetch(input.url, { @@ -22,17 +74,22 @@ export async function uploadFile(input: UploadInput): Promise { body: new Uint8Array(file), }); if (!response.ok) { - throw new Error( - `Failed to upload file to ${input.url}: ${response.status} ${response.statusText}`, - ); + throw await createUploadError(input.url, response); } } +/** + * Upload a file to S3 using a presigned POST. Unlike a presigned PUT, this + * sends the file as multipart form data alongside the policy `fields` + * provided by S3. + */ export async function uploadFileWithPresignedPost( input: PresignedPostUploadInput, ): Promise { const file = await readFile(input.path); const formData = new FormData(); + // The presigned policy fields (key, policy, signature, etc.) must be + // appended before the file part for S3 to accept the upload. for (const [key, value] of Object.entries(input.fields)) { formData.append(key, value); } @@ -48,8 +105,6 @@ export async function uploadFileWithPresignedPost( body: formData, }); if (!response.ok) { - throw new Error( - `Failed to upload file to ${input.url}: ${response.status} ${response.statusText}`, - ); + throw await createUploadError(input.url, response); } }