From 8f969cd4e25c76b56ad196c6d956b230de5082ee Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sun, 27 Sep 2020 18:32:21 +0800 Subject: [PATCH] Add ponyfills for Blob and File, improve test coverage Implement Blob and File ponyfills to work in both browser and Node, as well as keep test coverage at acceptable percentage. --- package.json | 2 +- src/dataUriToBlob.test.ts | 24 +++++++ src/dataUriToBlob.ts | 23 ++----- src/formatFileSize.test.ts | 116 ++++++++++++++------------------ src/formatFileSize.ts | 13 +++- src/getUrlScheme.test.ts | 43 ++++++++++++ src/getUrlScheme.ts | 19 ++++++ src/index.test.ts | 6 ++ src/index.ts | 3 +- src/isValidFileName.test.ts | 26 +++++++ src/utilities/Blob.test.ts | 38 +++++++++++ src/utilities/Blob.ts | 4 ++ src/utilities/File.test.ts | 113 +++++++++++++++++++++++++++++++ src/utilities/File.ts | 41 +++++++++++ src/utilities/isBrowser.test.ts | 19 ++++++ src/utilities/isBrowser.ts | 5 ++ tsconfig.json | 1 + 17 files changed, 408 insertions(+), 88 deletions(-) create mode 100644 src/getUrlScheme.test.ts create mode 100644 src/getUrlScheme.ts create mode 100644 src/utilities/Blob.test.ts create mode 100644 src/utilities/Blob.ts create mode 100644 src/utilities/File.test.ts create mode 100644 src/utilities/File.ts create mode 100644 src/utilities/isBrowser.test.ts create mode 100644 src/utilities/isBrowser.ts diff --git a/package.json b/package.json index b5c87da..25d956e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/dataUriToBlob.test.ts b/src/dataUriToBlob.test.ts index b17e518..e165f75 100644 --- a/src/dataUriToBlob.test.ts +++ b/src/dataUriToBlob.test.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') + } + ) + ) +}) diff --git a/src/dataUriToBlob.ts b/src/dataUriToBlob.ts index 64a2720..7bcb562 100644 --- a/src/dataUriToBlob.ts +++ b/src/dataUriToBlob.ts @@ -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) { - 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 diff --git a/src/formatFileSize.test.ts b/src/formatFileSize.test.ts index 3bfe1bb..8780042 100644 --- a/src/formatFileSize.test.ts +++ b/src/formatFileSize.test.ts @@ -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) + } + ) + ) + }) }) }) diff --git a/src/formatFileSize.ts b/src/formatFileSize.ts index 41c5873..c585433 100644 --- a/src/formatFileSize.ts +++ b/src/formatFileSize.ts @@ -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 diff --git a/src/getUrlScheme.test.ts b/src/getUrlScheme.test.ts new file mode 100644 index 0000000..09fcf1f --- /dev/null +++ b/src/getUrlScheme.test.ts @@ -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) + } + ) + ) +}) diff --git a/src/getUrlScheme.ts b/src/getUrlScheme.ts new file mode 100644 index 0000000..75d97ff --- /dev/null +++ b/src/getUrlScheme.ts @@ -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 diff --git a/src/index.test.ts b/src/index.test.ts index a1a27cc..b1c9b5d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -18,6 +18,12 @@ describe('formatFileSize', () => { }) }) +describe('getUrlScheme', () => { + it('should exist', () => { + expect(index.getUrlScheme).toBeDefined() + }) +}) + describe('isValidFileName', () => { it('should exist', () => { expect(index.isValidFileName).toBeDefined() diff --git a/src/index.ts b/src/index.ts index 9f422b1..b8a2f31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 } diff --git a/src/isValidFileName.test.ts b/src/isValidFileName.test.ts index 3e619da..0317d7f 100644 --- a/src/isValidFileName.test.ts +++ b/src/isValidFileName.test.ts @@ -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({ + 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) + } + ) + ) + }) }) diff --git a/src/utilities/Blob.test.ts b/src/utilities/Blob.test.ts new file mode 100644 index 0000000..c313a5e --- /dev/null +++ b/src/utilities/Blob.test.ts @@ -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()) + } + ) + ) + }) +}) diff --git a/src/utilities/Blob.ts b/src/utilities/Blob.ts new file mode 100644 index 0000000..88c552d --- /dev/null +++ b/src/utilities/Blob.ts @@ -0,0 +1,4 @@ +import NodeBlob from 'node-blob' +import isBrowser from './isBrowser' + +export default isBrowser() ? window.Blob : NodeBlob diff --git a/src/utilities/File.test.ts b/src/utilities/File.test.ts new file mode 100644 index 0000000..eadafa8 --- /dev/null +++ b/src/utilities/File.test.ts @@ -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()) + }) + ) + }) +}) diff --git a/src/utilities/File.ts b/src/utilities/File.ts new file mode 100644 index 0000000..f75cb10 --- /dev/null +++ b/src/utilities/File.ts @@ -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) { + 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).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 diff --git a/src/utilities/isBrowser.test.ts b/src/utilities/isBrowser.test.ts new file mode 100644 index 0000000..c1af4e0 --- /dev/null +++ b/src/utilities/isBrowser.test.ts @@ -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') + }) +}) diff --git a/src/utilities/isBrowser.ts b/src/utilities/isBrowser.ts new file mode 100644 index 0000000..8eaeeba --- /dev/null +++ b/src/utilities/isBrowser.ts @@ -0,0 +1,5 @@ +type IsBrowser = () => boolean + +const isBrowser: IsBrowser = () => typeof window !== 'undefined' + +export default isBrowser diff --git a/tsconfig.json b/tsconfig.json index 564494b..d720fc2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "include": ["src", "types"], "compilerOptions": { "module": "esnext", + "target": "ES6", "lib": ["dom", "esnext"], "importHelpers": true, "declaration": true,