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", | |||
"main": "dist/index.js", | |||
"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 | |||
@@ -6,20 +7,6 @@ const DATA_URI_PREFIX = 'data:' | |||
const DATA_TYPE_DELIMITER = ';' | |||
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?) => { | |||
if ((typeof dataUri as unknown) !== 'string') { | |||
throw TypeError('Argument should be a string.') | |||
@@ -34,11 +21,9 @@ const dataUriToBlob: DataUriToBlob = (dataUri, name?) => { | |||
ia[i] = binary.charCodeAt(i) | |||
} | |||
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 |
@@ -9,80 +9,68 @@ it('should be a 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.') | |||
} | |||
if (n < 0) { | |||
throw RangeError('Cannot format negative values.') | |||
} | |||
if (n === Number.POSITIVE_INFINITY) { | |||
throw RangeError('Cannot format infinity.') | |||
} | |||
if (raw) { | |||
const absValue = Math.abs(n) | |||
const base = numeral(absValue < 1000 ? absValue : 999).format('0 b') | |||
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 |
@@ -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', () => { | |||
it('should exist', () => { | |||
expect(index.isValidFileName).toBeDefined() | |||
@@ -1,7 +1,8 @@ | |||
import calculateHash from './calculateHash' | |||
import dataUriToBlob from './dataUriToBlob' | |||
import formatFileSize from './formatFileSize' | |||
import getUrlScheme from './getUrlScheme' | |||
import isValidFileName from './isValidFileName' | |||
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"], | |||
"compilerOptions": { | |||
"module": "esnext", | |||
"target": "ES6", | |||
"lib": ["dom", "esnext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||