Implement Blob and File ponyfills to work in both browser and Node, as well as keep test coverage at acceptable percentage.master
@@ -1,5 +1,5 @@ | |||||
{ | { | ||||
"version": "1.0.2", | |||||
"version": "1.0.3", | |||||
"license": "MIT", | "license": "MIT", | ||||
"main": "dist/index.js", | "main": "dist/index.js", | ||||
"typings": "dist/index.d.ts", | "typings": "dist/index.d.ts", | ||||
@@ -23,3 +23,27 @@ it('should throw an error for invalid parameters', () => { | |||||
) | ) | ||||
) | ) | ||||
}) | }) | ||||
it('should return a Blob for valid parameters', () => { | |||||
fc.assert( | |||||
fc.property(fc.base64String(), binaryBase64 => { | |||||
const dataUri = `data:application/octet-stream;base64,${binaryBase64}` | |||||
expect(dataUriToBlob(dataUri).constructor.name).toBe('Blob') | |||||
}) | |||||
) | |||||
}) | |||||
it('should return a File for named Blobs', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.base64String(), | |||||
fc.string().filter(s => /^[a-zA-Z0-9._-]+$/.test(s)) | |||||
), | |||||
([binaryBase64, fileName]) => { | |||||
const dataUri = `data:application/octet-stream;base64,${binaryBase64}` | |||||
expect(dataUriToBlob(dataUri, fileName).constructor.name).toBe('File') | |||||
} | |||||
) | |||||
) | |||||
}) |
@@ -1,4 +1,5 @@ | |||||
import NodeBlob from 'node-blob' | |||||
import File from './utilities/File' | |||||
import Blob from './utilities/Blob' | |||||
type DataUriToBlob = (dataUri: string, name?: string) => Blob | type DataUriToBlob = (dataUri: string, name?: string) => Blob | ||||
@@ -6,20 +7,6 @@ const DATA_URI_PREFIX = 'data:' | |||||
const DATA_TYPE_DELIMITER = ';' | const DATA_TYPE_DELIMITER = ';' | ||||
const DATA_START = ',' | const DATA_START = ',' | ||||
interface NodeFileConfig { | |||||
lastModified: number | |||||
} | |||||
class NodeFile extends NodeBlob { | |||||
public readonly name: string | |||||
public readonly lastModified: number | |||||
constructor(blobParts: unknown[], name: string, config: Partial<NodeFileConfig>) { | |||||
super(blobParts, config) | |||||
this.name = name | |||||
this.lastModified = typeof config.lastModified! === 'number' ? config.lastModified : Date.now() | |||||
} | |||||
} | |||||
const dataUriToBlob: DataUriToBlob = (dataUri, name?) => { | const dataUriToBlob: DataUriToBlob = (dataUri, name?) => { | ||||
if ((typeof dataUri as unknown) !== 'string') { | if ((typeof dataUri as unknown) !== 'string') { | ||||
throw TypeError('Argument should be a string.') | throw TypeError('Argument should be a string.') | ||||
@@ -34,11 +21,9 @@ const dataUriToBlob: DataUriToBlob = (dataUri, name?) => { | |||||
ia[i] = binary.charCodeAt(i) | ia[i] = binary.charCodeAt(i) | ||||
} | } | ||||
if (typeof name! === 'string') { | if (typeof name! === 'string') { | ||||
const FileCtor = typeof window !== 'undefined' ? window.File : NodeFile | |||||
return new FileCtor([ab], name, { type }) | |||||
return new File([ab], name, { type }) | |||||
} | } | ||||
const BlobCtor = typeof window !== 'undefined' ? window.Blob : NodeBlob | |||||
return new BlobCtor([ab], { type }) | |||||
return new Blob([ab], { type }) | |||||
} | } | ||||
export default dataUriToBlob | export default dataUriToBlob |
@@ -9,80 +9,68 @@ it('should be a function', () => { | |||||
expect(typeof formatFileSize).toBe('function') | expect(typeof formatFileSize).toBe('function') | ||||
}) | }) | ||||
it('should accept 2 arguments', () => { | |||||
expect(formatFileSize).toHaveLength(2) | |||||
it('should accept minimum of 1 argument', () => { | |||||
expect(formatFileSize).toHaveLength(1) | |||||
}) | }) | ||||
describe('on numeric arguments', () => { | |||||
it('should format values of |x| < 1000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => Math.abs(v) < 1000), | |||||
v => { | |||||
expect(formatFileSize(v)).toMatch(/^-?\d+ B$/) | |||||
} | |||||
describe.each` | |||||
raw | largeValuePattern | |||||
${false} | ${/^-?\d+\.\d\d .B$/} | |||||
${true} | ${/^-?\d[,\d]* .?B$/} | |||||
`('on raw mode = $raw', ({ raw, largeValuePattern }) => { | |||||
describe('on numeric arguments', () => { | |||||
it('should format values of |x| < 1000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => v >= 0 && Math.abs(v) < 1000), | |||||
v => { | |||||
expect(formatFileSize(v, raw)).toMatch(/^-?\d+ B$/) | |||||
} | |||||
) | |||||
) | ) | ||||
) | |||||
}) | |||||
}) | |||||
it('should format values of |x| >= 1000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => Math.abs(v) >= 1000), | |||||
v => { | |||||
expect(formatFileSize(v)).toMatch(/^-?\d+\.\d\d .B$/) | |||||
} | |||||
it('should format values of |x| >= 1000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => v >= 0 && Math.abs(v) >= 1000), | |||||
v => { | |||||
expect(formatFileSize(v, raw)).toMatch(largeValuePattern) | |||||
} | |||||
) | |||||
) | ) | ||||
) | |||||
}) | |||||
it('should throw an error on NaN', () => { | |||||
expect(() => formatFileSize(NaN)).toThrow(RangeError) | |||||
}) | |||||
}) | |||||
}) | |||||
describe('on non-numeric arguments', () => { | |||||
it('should throw an error', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(v => typeof v !== 'number'), | |||||
v => { | |||||
expect(() => formatFileSize(v as number)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
it('should throw an error on NaN', () => { | |||||
expect(() => formatFileSize(NaN)).toThrow(RangeError) | |||||
}) | |||||
describe('on raw', () => { | |||||
it('should throw an error on non-numeric arguments', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(v => typeof v !== 'number'), | |||||
v => { | |||||
expect(() => formatFileSize(v as number, true)).toThrow(TypeError) | |||||
} | |||||
it('should throw an error on negative values', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.nat().filter(v => v !== 0), | |||||
v => { | |||||
expect(() => formatFileSize(-v, raw)).toThrow(RangeError) | |||||
} | |||||
) | |||||
) | ) | ||||
) | |||||
}) | |||||
}) | |||||
it('should throw an error on NaN', () => { | |||||
expect(() => formatFileSize(NaN, true)).toThrow(RangeError) | |||||
it('should throw an error on positive infinity', () => { | |||||
expect(() => formatFileSize(Number.POSITIVE_INFINITY, raw)).toThrow(RangeError) | |||||
}) | |||||
}) | }) | ||||
it('should return string on numeric values', () => { | |||||
fc.assert( | |||||
fc.property(fc.integer(), v => { | |||||
expect(typeof formatFileSize(v, true)).toBe('string') | |||||
}) | |||||
) | |||||
}) | |||||
it('should format numeric values', () => { | |||||
fc.assert( | |||||
fc.property(fc.integer(), v => { | |||||
expect(formatFileSize(v, true)).toMatch(/^-?\d[,\d]* .?B$/) | |||||
}) | |||||
) | |||||
describe('on non-numeric arguments', () => { | |||||
it('should throw an error', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(v => typeof v !== 'number'), | |||||
v => { | |||||
expect(() => formatFileSize(v as number, raw)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | }) | ||||
}) | }) |
@@ -11,15 +11,22 @@ const formatFileSize: FormatFileSize = (n, raw = false) => { | |||||
throw RangeError('Cannot format NaN.') | throw RangeError('Cannot format NaN.') | ||||
} | } | ||||
if (n < 0) { | |||||
throw RangeError('Cannot format negative values.') | |||||
} | |||||
if (n === Number.POSITIVE_INFINITY) { | |||||
throw RangeError('Cannot format infinity.') | |||||
} | |||||
if (raw) { | if (raw) { | ||||
const absValue = Math.abs(n) | const absValue = Math.abs(n) | ||||
const base = numeral(absValue < 1000 ? absValue : 999).format('0 b') | const base = numeral(absValue < 1000 ? absValue : 999).format('0 b') | ||||
const suffix = base.slice(base.indexOf(' ') + ' '.length) | const suffix = base.slice(base.indexOf(' ') + ' '.length) | ||||
return `${n < 0 ? '-' : ''}${numeral(absValue).format('0,0')} ${suffix}` | |||||
return `${numeral(absValue).format('0,0')} ${suffix}` | |||||
} | } | ||||
const base = numeral(Math.abs(n)).format(Math.abs(n) < 1000 ? '0 b' : '0.00 b') | |||||
return `${n < 0 ? '-' : ''}${base}` | |||||
return numeral(Math.abs(n)).format(Math.abs(n) < 1000 ? '0 b' : '0.00 b') | |||||
} | } | ||||
export default formatFileSize | export default formatFileSize |
@@ -0,0 +1,43 @@ | |||||
import * as fc from 'fast-check' | |||||
import getUrlScheme, { UrlScheme } from './getUrlScheme' | |||||
it('should exist', () => { | |||||
expect(getUrlScheme).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof getUrlScheme).toBe('function') | |||||
}) | |||||
it('should accept 1 argument', () => { | |||||
expect(getUrlScheme).toHaveLength(1) | |||||
}) | |||||
test.each` | |||||
scheme | kind | |||||
${'http'} | ${'HTTP'} | |||||
${'https'} | ${'HTTPS'} | |||||
`('should detect $kind URLs', ({ scheme, kind }) => { | |||||
fc.assert( | |||||
fc.property(fc.webUrl({ validSchemes: [scheme] }), url => { | |||||
expect(getUrlScheme(url)).toBe(UrlScheme[kind as keyof typeof UrlScheme]) | |||||
}) | |||||
) | |||||
}) | |||||
it('should fall back to local URLs', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.array( | |||||
fc.oneof( | |||||
fc.string().filter(s => /^[a-zA-Z0-9._-]+$/.test(s)), | |||||
fc.constant('..'), | |||||
fc.constant('.') | |||||
) | |||||
), | |||||
pathFragments => { | |||||
expect(getUrlScheme(pathFragments.join('/'))).toBe(UrlScheme.FILE) | |||||
} | |||||
) | |||||
) | |||||
}) |
@@ -0,0 +1,19 @@ | |||||
export enum UrlScheme { | |||||
HTTP = 'http', | |||||
HTTPS = 'https', | |||||
FILE = 'file', | |||||
} | |||||
type GetUrlScheme = (url: string) => UrlScheme | |||||
const getUrlScheme: GetUrlScheme = url => { | |||||
if (url.startsWith('http://')) { | |||||
return UrlScheme.HTTP | |||||
} | |||||
if (url.startsWith('https://')) { | |||||
return UrlScheme.HTTPS | |||||
} | |||||
return UrlScheme.FILE | |||||
} | |||||
export default getUrlScheme |
@@ -18,6 +18,12 @@ describe('formatFileSize', () => { | |||||
}) | }) | ||||
}) | }) | ||||
describe('getUrlScheme', () => { | |||||
it('should exist', () => { | |||||
expect(index.getUrlScheme).toBeDefined() | |||||
}) | |||||
}) | |||||
describe('isValidFileName', () => { | describe('isValidFileName', () => { | ||||
it('should exist', () => { | it('should exist', () => { | ||||
expect(index.isValidFileName).toBeDefined() | expect(index.isValidFileName).toBeDefined() | ||||
@@ -1,7 +1,8 @@ | |||||
import calculateHash from './calculateHash' | import calculateHash from './calculateHash' | ||||
import dataUriToBlob from './dataUriToBlob' | import dataUriToBlob from './dataUriToBlob' | ||||
import formatFileSize from './formatFileSize' | import formatFileSize from './formatFileSize' | ||||
import getUrlScheme from './getUrlScheme' | |||||
import isValidFileName from './isValidFileName' | import isValidFileName from './isValidFileName' | ||||
import isValidMimeType from './isValidMimeType' | import isValidMimeType from './isValidMimeType' | ||||
export { calculateHash, dataUriToBlob, formatFileSize, isValidFileName, isValidMimeType } | |||||
export { calculateHash, dataUriToBlob, formatFileSize, getUrlScheme, isValidFileName, isValidMimeType } |
@@ -114,4 +114,30 @@ describe('on main callable', () => { | |||||
) | ) | ||||
) | ) | ||||
}) | }) | ||||
it('should return a boolean for string params with multiple extensions enabled', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.record<IsValidFilenameConfig>({ | |||||
validExtensions: fc.array( | |||||
fc.string().filter(s => /^[a-zA-Z0-9]{1,8}$/.test(s)), | |||||
1, | |||||
20 | |||||
), | |||||
allowMultipleExtensions: fc.constant(true), | |||||
}), | |||||
fc.string().filter(s => /^[a-zA-Z0-9]+$/.test(s)), | |||||
fc.string().filter(s => /^[a-zA-Z0-9]{1,8}$/.test(s)) | |||||
), | |||||
([params, maybeFileName, excludedFileName]) => { | |||||
const { validExtensions } = params | |||||
const chosenExtension = validExtensions[Math.floor(Math.random() * validExtensions.length)] | |||||
const theFileName = maybeFileName + '.' + chosenExtension + '.' + excludedFileName | |||||
expect(isValidFilename(params)(theFileName)).toBe(true) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | }) |
@@ -0,0 +1,38 @@ | |||||
import * as fc from 'fast-check' | |||||
import Blob from './Blob' | |||||
jest.mock('./isBrowser', function IsBrowser() { | |||||
return () => false | |||||
}) | |||||
it('should exist', () => { | |||||
expect(Blob).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof Blob).toBe('function') | |||||
}) | |||||
describe('instances', () => { | |||||
it('should contain a type', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.oneof( | |||||
fc.constant('application'), | |||||
fc.constant('font'), | |||||
fc.constant('audio'), | |||||
fc.constant('model'), | |||||
fc.constant('video'), | |||||
fc.constant('text') | |||||
), | |||||
fc.string().filter(s => /^[a-zA-Z0-9._-]+$/.test(s)) | |||||
), | |||||
mimeTypeFragments => { | |||||
const file = new Blob([], { type: mimeTypeFragments.join('/') }) | |||||
expect(file).toHaveProperty('type', mimeTypeFragments.join('/').toLowerCase()) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,4 @@ | |||||
import NodeBlob from 'node-blob' | |||||
import isBrowser from './isBrowser' | |||||
export default isBrowser() ? window.Blob : NodeBlob |
@@ -0,0 +1,113 @@ | |||||
import * as fc from 'fast-check' | |||||
import File from './File' | |||||
jest.mock('./isBrowser', function IsBrowser() { | |||||
return () => false | |||||
}) | |||||
it('should exist', () => { | |||||
expect(File).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof File).toBe('function') | |||||
}) | |||||
describe('instances', () => { | |||||
it('should contain a name', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.string().filter(s => /^[a-zA-Z0-9._-]+$/.test(s)), | |||||
fileName => { | |||||
const file = new File([], fileName, { type: 'application/octet-stream' }) | |||||
expect(file).toHaveProperty('name', fileName) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should contain a last modified timestamp', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.string().filter(s => /^[a-zA-Z0-9._-]+$/.test(s)), | |||||
fc.nat() | |||||
), | |||||
([fileName, lastModified]) => { | |||||
const file = new File([], fileName, { type: 'application/octet-stream', lastModified }) | |||||
expect(file).toHaveProperty('lastModified', lastModified) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should contain a type', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.oneof( | |||||
fc.constant('application'), | |||||
fc.constant('font'), | |||||
fc.constant('audio'), | |||||
fc.constant('model'), | |||||
fc.constant('video'), | |||||
fc.constant('text') | |||||
), | |||||
fc.string().filter(s => /^[a-zA-Z0-9._-]+$/.test(s)) | |||||
), | |||||
mimeTypeFragments => { | |||||
const file = new File([], 'foo', { type: mimeTypeFragments.join('/') }) | |||||
expect(file).toHaveProperty('type', mimeTypeFragments.join('/').toLowerCase()) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
describe('on cases for last modified date', () => { | |||||
// We put lastModified as `any` because native DOM types classify | |||||
// `lastModified` as number only under FilePropertyBag (but browser | |||||
// accepts both Date and number objects equally. | |||||
it('should be 0 given invalid date objects', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter( | |||||
s => | |||||
// `new Date(Number.MAX_SAFE_INTEGER)` returns Invalid Date, but | |||||
// is ok with File instances on browsers. Weird. This is why we | |||||
// exclude numbers that will generate invalid dates (we already | |||||
// covered numbers previously anyway). | |||||
typeof s !== 'number' && isNaN(new Date(s as Date).getTime()) | |||||
), | |||||
v => { | |||||
const lastModified = new Date(v as Date) | |||||
const file = new File([], 'foo', { type: 'application/octet-stream', lastModified: lastModified as any }) | |||||
expect(file.lastModified).toBe(0) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should be 0 given NaN', () => { | |||||
const file = new File([], 'foo', { type: 'application/octet-stream', lastModified: NaN }) | |||||
expect(file.lastModified).toBe(0) | |||||
}) | |||||
it('should be 0 given generic object values', () => { | |||||
fc.assert( | |||||
fc.property(fc.object(), o => { | |||||
const file = new File([], 'foo', { type: 'application/octet-stream', lastModified: o as any }) | |||||
expect(file.lastModified).toBe(0) | |||||
}) | |||||
) | |||||
}) | |||||
it('should be a corresponding integer timestamp given valid date objects', () => { | |||||
fc.assert( | |||||
fc.property(fc.date(), lastModified => { | |||||
const file = new File([], 'foo', { type: 'application/octet-stream', lastModified: lastModified as any }) | |||||
expect(file.lastModified).toBe(lastModified.getTime()) | |||||
}) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,41 @@ | |||||
import NodeBlob from 'node-blob' | |||||
import isBrowser from './isBrowser' | |||||
interface NodeFileConfig { | |||||
type: string | |||||
lastModified?: number | |||||
} | |||||
class File extends NodeBlob { | |||||
public readonly name: string | |||||
public readonly lastModified: number | |||||
constructor(blobParts: unknown[], name: string, config: Partial<NodeFileConfig>) { | |||||
super(blobParts, config) | |||||
this.name = name | |||||
switch (typeof config.lastModified!) { | |||||
case 'number': | |||||
if (isNaN(config.lastModified as number)) { | |||||
break | |||||
} | |||||
this.lastModified = Math.floor(config.lastModified as number) | |||||
return | |||||
case 'object': | |||||
// force native Date objects only! | |||||
if (((config.lastModified as unknown) as Record<string, unknown>).constructor.name !== 'Date') { | |||||
break | |||||
} | |||||
const tryDate = new Date(config.lastModified!).getTime() | |||||
if (isNaN(tryDate)) { | |||||
break | |||||
} | |||||
this.lastModified = tryDate | |||||
return | |||||
default: | |||||
break | |||||
} | |||||
this.lastModified = 0 | |||||
} | |||||
} | |||||
export default isBrowser() ? window.File : File |
@@ -0,0 +1,19 @@ | |||||
import isBrowser from './isBrowser' | |||||
describe('isBrowser', () => { | |||||
it('should exist', () => { | |||||
expect(isBrowser).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof isBrowser).toBe('function') | |||||
}) | |||||
it('should accept no arguments', () => { | |||||
expect(isBrowser).toHaveLength(0) | |||||
}) | |||||
it('should return a boolean', () => { | |||||
expect(typeof isBrowser()).toBe('boolean') | |||||
}) | |||||
}) |
@@ -0,0 +1,5 @@ | |||||
type IsBrowser = () => boolean | |||||
const isBrowser: IsBrowser = () => typeof window !== 'undefined' | |||||
export default isBrowser |
@@ -2,6 +2,7 @@ | |||||
"include": ["src", "types"], | "include": ["src", "types"], | ||||
"compilerOptions": { | "compilerOptions": { | ||||
"module": "esnext", | "module": "esnext", | ||||
"target": "ES6", | |||||
"lib": ["dom", "esnext"], | "lib": ["dom", "esnext"], | ||||
"importHelpers": true, | "importHelpers": true, | ||||
"declaration": true, | "declaration": true, | ||||