From d19f17b90a58daf5a931d37f09d314cc5c528a87 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 10:33:34 +0000 Subject: [PATCH] chore(client): only accept standard types for file uploads --- scripts/build | 4 +- src/client.ts | 4 +- src/internal/polyfill/file.node.d.ts | 9 - src/internal/polyfill/file.node.js | 17 -- src/internal/polyfill/file.node.mjs | 9 - src/internal/shims.ts | 56 ----- .../{polyfill => shims}/crypto.node.d.ts | 0 .../{polyfill => shims}/crypto.node.js | 0 .../{polyfill => shims}/crypto.node.mjs | 0 src/internal/shims/file.node.d.ts | 20 ++ src/internal/shims/file.node.js | 11 + src/internal/shims/file.node.mjs | 2 + src/internal/to-file.ts | 152 ++++++++++++ src/internal/uploads.ts | 220 ++++-------------- src/internal/utils/uuid.ts | 2 +- src/uploads.ts | 3 +- tests/uploads.test.ts | 38 ++- 17 files changed, 267 insertions(+), 280 deletions(-) delete mode 100644 src/internal/polyfill/file.node.d.ts delete mode 100644 src/internal/polyfill/file.node.js delete mode 100644 src/internal/polyfill/file.node.mjs rename src/internal/{polyfill => shims}/crypto.node.d.ts (100%) rename src/internal/{polyfill => shims}/crypto.node.js (100%) rename src/internal/{polyfill => shims}/crypto.node.mjs (100%) create mode 100644 src/internal/shims/file.node.d.ts create mode 100644 src/internal/shims/file.node.js create mode 100644 src/internal/shims/file.node.mjs create mode 100644 src/internal/to-file.ts diff --git a/scripts/build b/scripts/build index 08bfe2e..7b100ad 100755 --- a/scripts/build +++ b/scripts/build @@ -40,8 +40,8 @@ cp dist/index.d.ts dist/index.d.mts cp tsconfig.dist-src.json dist/src/tsconfig.json cp src/internal/shim-types.d.ts dist/internal/shim-types.d.ts cp src/internal/shim-types.d.ts dist/internal/shim-types.d.mts -mkdir -p dist/internal/polyfill -cp src/internal/polyfill/*.{mjs,js,d.ts} dist/internal/polyfill +mkdir -p dist/internal/shims +cp src/internal/shims/*.{mjs,js,d.ts} dist/internal/shims node scripts/utils/postprocess-files.cjs diff --git a/src/client.ts b/src/client.ts index 9836801..8c0af2b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -726,7 +726,9 @@ export class Gitpod { const timeout = setTimeout(() => controller.abort(), ms); - const isReadableBody = Shims.isReadableLike(options.body); + const isReadableBody = + ((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) || + (typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body); const fetchOptions: RequestInit = { signal: controller.signal as any, diff --git a/src/internal/polyfill/file.node.d.ts b/src/internal/polyfill/file.node.d.ts deleted file mode 100644 index b2a59bf..0000000 --- a/src/internal/polyfill/file.node.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file polyfills the global `File` object for you if it's not already defined - * when running on Node.js - * - * This is only needed on Node.js v18 & v19. Newer versions already define `File` - * as a global. - */ - -export {}; diff --git a/src/internal/polyfill/file.node.js b/src/internal/polyfill/file.node.js deleted file mode 100644 index eba997e..0000000 --- a/src/internal/polyfill/file.node.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * This file polyfills the global `File` object for you if it's not already defined - * when running on Node.js - * - * This is only needed on Node.js v18 & v19. Newer versions already define `File` - * as a global. - */ - -if (typeof require !== 'undefined') { - if (!globalThis.File) { - try { - // Use [require][0](...) and not require(...) so bundlers don't try to bundle the - // buffer module. - globalThis.File = [require][0]('node:buffer').File; - } catch (e) {} - } -} diff --git a/src/internal/polyfill/file.node.mjs b/src/internal/polyfill/file.node.mjs deleted file mode 100644 index 520dcb8..0000000 --- a/src/internal/polyfill/file.node.mjs +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file polyfills the global `File` object for you if it's not already defined - * when running on Node.js - * - * This is only needed on Node.js v18 & v19. Newer versions already define `File` - * as a global. - */ - -import './file.node.js'; diff --git a/src/internal/shims.ts b/src/internal/shims.ts index cb91e94..95b03fb 100644 --- a/src/internal/shims.ts +++ b/src/internal/shims.ts @@ -20,62 +20,6 @@ export function getDefaultFetch(): Fetch { ); } -/** - * A minimal copy of the NodeJS `stream.Readable` class so that we can - * accept the NodeJS types in certain places, e.g. file uploads - * - * https://nodejs.org/api/stream.html#class-streamreadable - */ -export interface ReadableLike { - readable: boolean; - readonly readableEnded: boolean; - readonly readableFlowing: boolean | null; - readonly readableHighWaterMark: number; - readonly readableLength: number; - readonly readableObjectMode: boolean; - destroyed: boolean; - read(size?: number): any; - pause(): this; - resume(): this; - isPaused(): boolean; - destroy(error?: Error): this; - [Symbol.asyncIterator](): AsyncIterableIterator; -} - -/** - * Determines if the given value looks like a NodeJS `stream.Readable` - * object and that it is readable, i.e. has not been consumed. - * - * https://nodejs.org/api/stream.html#class-streamreadable - */ -export function isReadableLike(value: any) { - // We declare our own class of Readable here, so it's not feasible to - // do an 'instanceof' check. Instead, check for Readable-like properties. - return !!value && value.readable === true && typeof value.read === 'function'; -} - -/** - * A minimal copy of the NodeJS `fs.ReadStream` class for usage within file uploads. - * - * https://nodejs.org/api/fs.html#class-fsreadstream - */ -export interface FsReadStreamLike extends ReadableLike { - path: {}; // real type is string | Buffer but we can't reference `Buffer` here -} - -/** - * Determines if the given value looks like a NodeJS `fs.ReadStream` - * object. - * - * This just checks if the object matches our `Readable` interface - * and defines a `path` property, there may be false positives. - * - * https://nodejs.org/api/fs.html#class-fsreadstream - */ -export function isFsReadStreamLike(value: any): value is FsReadStreamLike { - return isReadableLike(value) && 'path' in value; -} - type ReadableStreamArgs = ConstructorParameters; export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream { diff --git a/src/internal/polyfill/crypto.node.d.ts b/src/internal/shims/crypto.node.d.ts similarity index 100% rename from src/internal/polyfill/crypto.node.d.ts rename to src/internal/shims/crypto.node.d.ts diff --git a/src/internal/polyfill/crypto.node.js b/src/internal/shims/crypto.node.js similarity index 100% rename from src/internal/polyfill/crypto.node.js rename to src/internal/shims/crypto.node.js diff --git a/src/internal/polyfill/crypto.node.mjs b/src/internal/shims/crypto.node.mjs similarity index 100% rename from src/internal/polyfill/crypto.node.mjs rename to src/internal/shims/crypto.node.mjs diff --git a/src/internal/shims/file.node.d.ts b/src/internal/shims/file.node.d.ts new file mode 100644 index 0000000..9dc6b2f --- /dev/null +++ b/src/internal/shims/file.node.d.ts @@ -0,0 +1,20 @@ +// The infer is to make TS show it as a nice union type, +// instead of literally `ConstructorParameters[0]` +type FallbackBlobSource = ConstructorParameters[0] extends infer T ? T : never; +/** + * A [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) provides information about files. + */ +declare class FallbackFile extends Blob { + constructor(sources: FallbackBlobSource, fileName: string, options?: any); + /** + * The name of the `File`. + */ + readonly name: string; + /** + * The last modified date of the `File`. + */ + readonly lastModified: number; +} +export type File = InstanceType; +export const File: typeof globalThis extends { File: infer fileConstructor } ? fileConstructor +: typeof FallbackFile; diff --git a/src/internal/shims/file.node.js b/src/internal/shims/file.node.js new file mode 100644 index 0000000..3f8c2ed --- /dev/null +++ b/src/internal/shims/file.node.js @@ -0,0 +1,11 @@ +if (typeof require !== 'undefined') { + if (globalThis.File) { + exports.File = globalThis.File; + } else { + try { + // Use [require][0](...) and not require(...) so bundlers don't try to bundle the + // buffer module. + exports.File = [require][0]('node:buffer').File; + } catch (e) {} + } +} diff --git a/src/internal/shims/file.node.mjs b/src/internal/shims/file.node.mjs new file mode 100644 index 0000000..1f103f5 --- /dev/null +++ b/src/internal/shims/file.node.mjs @@ -0,0 +1,2 @@ +import * as mod from './file.node.js'; +export const File = globalThis.File || mod.File; diff --git a/src/internal/to-file.ts b/src/internal/to-file.ts new file mode 100644 index 0000000..69b76d3 --- /dev/null +++ b/src/internal/to-file.ts @@ -0,0 +1,152 @@ +import { File } from './shims/file.node.js'; +import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads'; +import type { FilePropertyBag } from './builtin-types'; + +type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; + +/** + * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. + * Don't add arrayBuffer here, node-fetch doesn't have it + */ +interface BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ + readonly size: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ + readonly type: string; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ + text(): Promise; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ + slice(start?: number, end?: number): BlobLike; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.size === 'number' && + typeof value.type === 'string' && + typeof value.text === 'function' && + typeof value.slice === 'function' && + typeof value.arrayBuffer === 'function'; + +/** + * Intended to match DOM File, node:buffer File, undici File, etc. + */ +interface FileLike extends BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ + readonly lastModified: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ + readonly name?: string | undefined; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.lastModified === 'number' && + isBlobLike(value); + +/** + * Intended to match DOM Response, node-fetch Response, undici Response, etc. + */ +export interface ResponseLike { + url: string; + blob(): Promise; +} + +const isResponseLike = (value: any): value is ResponseLike => + value != null && + typeof value === 'object' && + typeof value.url === 'string' && + typeof value.blob === 'function'; + +export type ToFileInput = + | FileLike + | ResponseLike + | Exclude + | AsyncIterable; + +/** + * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats + * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s + * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible + * @param {Object=} options additional properties + * @param {string=} options.type the MIME type of the content + * @param {number=} options.lastModified the last modified timestamp + * @returns a {@link File} with the given properties + */ +export async function toFile( + value: ToFileInput | PromiseLike, + name?: string | null | undefined, + options?: FilePropertyBag | undefined, +): Promise { + // If it's a promise, resolve it. + value = await value; + + // If we've been given a `File` we don't need to do anything + if (isFileLike(value)) { + if (File && value instanceof File) { + return value; + } + return makeFile([await value.arrayBuffer()], value.name); + } + + if (isResponseLike(value)) { + const blob = await value.blob(); + name ||= new URL(value.url).pathname.split(/[\\/]/).pop(); + + return makeFile(await getBytes(blob), name, options); + } + + const parts = await getBytes(value); + + name ||= getName(value); + + if (!options?.type) { + const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); + if (typeof type === 'string') { + options = { ...options, type }; + } + } + + return makeFile(parts, name, options); +} + +async function getBytes(value: BlobLikePart | AsyncIterable): Promise> { + let parts: Array = []; + if ( + typeof value === 'string' || + ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. + value instanceof ArrayBuffer + ) { + parts.push(value); + } else if (isBlobLike(value)) { + parts.push(value instanceof Blob ? value : await value.arrayBuffer()); + } else if ( + isAsyncIterable(value) // includes Readable, ReadableStream, etc. + ) { + for await (const chunk of value) { + parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? + } + } else { + const constructor = value?.constructor?.name; + throw new Error( + `Unexpected data type: ${typeof value}${ + constructor ? `; constructor: ${constructor}` : '' + }${propsForError(value)}`, + ); + } + + return parts; +} + +function propsForError(value: unknown): string { + if (typeof value !== 'object' || value === null) return ''; + const props = Object.getOwnPropertyNames(value); + return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; +} diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts index ee2029c..54431c1 100644 --- a/src/internal/uploads.ts +++ b/src/internal/uploads.ts @@ -1,11 +1,16 @@ import { type RequestOptions } from './request-options'; import type { FilePropertyBag, Fetch } from './builtin-types'; -import { isFsReadStreamLike, type FsReadStreamLike } from './shims'; import type { Gitpod } from '../client'; -import './polyfill/file.node.js'; +import { File } from './shims/file.node.js'; +import { ReadableStreamFrom } from './shims'; -type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; -type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +type FsReadStream = AsyncIterable & { path: string | { toString(): string } }; + +// https://github.com/oven-sh/bun/issues/5980 +interface BunFile extends Blob { + readonly name?: string | undefined; +} /** * Typically, this is a native "File" class. @@ -16,188 +21,41 @@ type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; * For convenience, you can also pass a fetch Response, or in Node, * the result of fs.createReadStream(). */ -export type Uploadable = FileLike | ResponseLike | FsReadStreamLike; - -/** - * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. - * Don't add arrayBuffer here, node-fetch doesn't have it - */ -interface BlobLike { - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ - readonly size: number; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ - readonly type: string; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ - text(): Promise; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ - slice(start?: number, end?: number): BlobLike; -} - -/** - * This check adds the arrayBuffer() method type because it is available and used at runtime - */ -const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise } => - value != null && - typeof value === 'object' && - typeof value.size === 'number' && - typeof value.type === 'string' && - typeof value.text === 'function' && - typeof value.slice === 'function' && - typeof value.arrayBuffer === 'function'; - -/** - * Intended to match DOM File, node:buffer File, undici File, etc. - */ -interface FileLike extends BlobLike { - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ - readonly lastModified: number; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ - readonly name?: string | undefined; -} -declare var FileClass: { - prototype: FileLike; - new (fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): FileLike; -}; - -/** - * This check adds the arrayBuffer() method type because it is available and used at runtime - */ -const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise } => - value != null && - typeof value === 'object' && - typeof value.name === 'string' && - typeof value.lastModified === 'number' && - isBlobLike(value); - -/** - * Intended to match DOM Response, node-fetch Response, undici Response, etc. - */ -export interface ResponseLike { - url: string; - blob(): Promise; -} - -const isResponseLike = (value: any): value is ResponseLike => - value != null && - typeof value === 'object' && - typeof value.url === 'string' && - typeof value.blob === 'function'; - -const isUploadable = (value: any): value is Uploadable => { - return isFileLike(value) || isResponseLike(value) || isFsReadStreamLike(value); -}; - -type ToFileInput = Uploadable | Exclude | AsyncIterable; +export type Uploadable = File | Response | FsReadStream | BunFile; /** * Construct a `File` instance. This is used to ensure a helpful error is thrown - * for environments that don't define a global `File` yet and so that we don't - * accidentally rely on a global `File` type in our annotations. + * for environments that don't define a global `File` yet. */ -function makeFile(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): FileLike { - const File = (globalThis as any).File as typeof FileClass | undefined; +export function makeFile( + fileBits: BlobPart[], + fileName: string | undefined, + options?: FilePropertyBag, +): File { if (typeof File === 'undefined') { throw new Error('`File` is not defined as a global which is required for file uploads'); } - return new File(fileBits, fileName, options); + return new File(fileBits as any, fileName ?? 'unknown_file', options); } -/** - * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats - * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s - * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible - * @param {Object=} options additional properties - * @param {string=} options.type the MIME type of the content - * @param {number=} options.lastModified the last modified timestamp - * @returns a {@link File} with the given properties - */ -export async function toFile( - value: ToFileInput | PromiseLike, - name?: string | null | undefined, - options?: FilePropertyBag | undefined, -): Promise { - // If it's a promise, resolve it. - value = await value; - - // If we've been given a `File` we don't need to do anything - if (isFileLike(value)) { - const File = (globalThis as any).File as typeof FileClass | undefined; - if (File && value instanceof File) { - return value; - } - return makeFile([await value.arrayBuffer()], value.name ?? 'unknown_file'); - } - - if (isResponseLike(value)) { - const blob = await value.blob(); - name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file'; - - return makeFile(await getBytes(blob), name, options); - } - - const parts = await getBytes(value); - - name ||= getName(value) ?? 'unknown_file'; - - if (!options?.type) { - const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); - if (typeof type === 'string') { - options = { ...options, type }; - } - } - - return makeFile(parts, name, options); -} - -export async function getBytes( - value: Uploadable | BlobLikePart | AsyncIterable, -): Promise> { - let parts: Array = []; - if ( - typeof value === 'string' || - ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. - value instanceof ArrayBuffer - ) { - parts.push(value); - } else if (isBlobLike(value)) { - parts.push(value instanceof Blob ? value : await value.arrayBuffer()); - } else if ( - isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc. - ) { - for await (const chunk of value) { - parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? - } - } else { - const constructor = value?.constructor?.name; - throw new Error( - `Unexpected data type: ${typeof value}${ - constructor ? `; constructor: ${constructor}` : '' - }${propsForError(value)}`, - ); - } - - return parts; -} - -function propsForError(value: unknown): string { - if (typeof value !== 'object' || value === null) return ''; - const props = Object.getOwnPropertyNames(value); - return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; -} - -function getName(value: unknown): string | undefined { +export function getName(value: any): string | undefined { return ( - (typeof value === 'object' && - value !== null && - (('name' in value && String(value.name)) || - ('filename' in value && String(value.filename)) || - ('path' in value && String(value.path).split(/[\\/]/).pop()))) || - undefined + ( + (typeof value === 'object' && + value !== null && + (('name' in value && value.name && String(value.name)) || + ('url' in value && value.url && String(value.url)) || + ('filename' in value && value.filename && String(value.filename)) || + ('path' in value && value.path && String(value.path)))) || + '' + ) + .split(/[\\/]/) + .pop() || undefined ); } -const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator => +export const isAsyncIterable = (value: any): value is AsyncIterable => value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; /** @@ -268,6 +126,15 @@ export const createForm = async >( return form; }; +// We check for Blob not File because Bun.File doesn't inherit from File, +// but they both inherit from Blob and have a `name` property at runtime. +const isNamedBlob = (value: object) => value instanceof File || (value instanceof Blob && 'name' in value); + +const isUploadable = (value: unknown) => + typeof value === 'object' && + value !== null && + (value instanceof Response || isAsyncIterable(value) || isNamedBlob(value)); + const hasUploadableValue = (value: unknown): boolean => { if (isUploadable(value)) return true; if (Array.isArray(value)) return value.some(hasUploadableValue); @@ -290,9 +157,12 @@ const addFormValue = async (form: FormData, key: string, value: unknown): Promis // TODO: make nested formats configurable if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { form.append(key, String(value)); - } else if (isUploadable(value)) { - const file = await toFile(value); - form.append(key, file as any); + } else if (value instanceof Response) { + form.append(key, makeFile([await value.blob()], getName(value))); + } else if (isAsyncIterable(value)) { + form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value))); + } else if (isNamedBlob(value)) { + form.append(key, value, getName(value)); } else if (Array.isArray(value)) { await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); } else if (typeof value === 'object') { diff --git a/src/internal/utils/uuid.ts b/src/internal/utils/uuid.ts index 6c43f81..1349c42 100644 --- a/src/internal/utils/uuid.ts +++ b/src/internal/utils/uuid.ts @@ -1,6 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { crypto } from '../polyfill/crypto.node'; +import { crypto } from '../shims/crypto.node.js'; /** * https://stackoverflow.com/a/2117523 diff --git a/src/uploads.ts b/src/uploads.ts index 77b6576..79d3073 100644 --- a/src/uploads.ts +++ b/src/uploads.ts @@ -1 +1,2 @@ -export { type Uploadable, toFile } from './internal/uploads'; +export { type Uploadable } from './internal/uploads'; +export { toFile, type ToFileInput } from './internal/to-file'; diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts index 5758464..0fc3454 100644 --- a/tests/uploads.test.ts +++ b/tests/uploads.test.ts @@ -1,5 +1,5 @@ import fs from 'fs'; -import type { ResponseLike } from '@gitpod/sdk/internal/uploads'; +import type { ResponseLike } from '@gitpod/sdk/internal/to-file'; import { toFile } from '@gitpod/sdk/uploads'; class MyClass { @@ -13,6 +13,12 @@ function mockResponse({ url, content }: { url: string; content?: Blob }): Respon }; } +beforeEach(() => { + // The file shim captures the global File object when it's first imported. + // Reset modules before each test so we can test the error thrown when it's undefined. + jest.resetModules(); +}); + describe('toFile', () => { it('throws a helpful error for mismatched types', async () => { await expect( @@ -62,15 +68,29 @@ describe('toFile', () => { expect(file.name).toEqual('input.jsonl'); expect(file.type).toBe('jsonl'); }); + + it('is assignable to File and Blob', async () => { + const input = new File(['foo'], 'input.jsonl', { type: 'jsonl' }); + const result = await toFile(input); + const file: File = result; + const blob: Blob = result; + void file, blob; + }); }); -test('missing File error message', async () => { - // @ts-ignore - globalThis.File = undefined; +describe('missing File error message', () => { + beforeEach(() => { + // @ts-ignore + globalThis.File = undefined; + require('node:buffer').File = undefined; + }); - await expect( - toFile(mockResponse({ url: 'https://example.com/my/audio.mp3' })), - ).rejects.toMatchInlineSnapshot( - `[Error: \`File\` is not defined as a global which is required for file uploads]`, - ); + test('is thrown', async () => { + const uploads = await import('@gitpod/sdk/uploads'); + await expect( + uploads.toFile(mockResponse({ url: 'https://example.com/my/audio.mp3' })), + ).rejects.toMatchInlineSnapshot( + `[Error: \`File\` is not defined as a global which is required for file uploads]`, + ); + }); });