Ver código fonte

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.
master
TheoryOfNekomata 3 anos atrás
pai
commit
8f969cd4e2
17 arquivos alterados com 408 adições e 88 exclusões
  1. +1
    -1
      package.json
  2. +24
    -0
      src/dataUriToBlob.test.ts
  3. +4
    -19
      src/dataUriToBlob.ts
  4. +52
    -64
      src/formatFileSize.test.ts
  5. +10
    -3
      src/formatFileSize.ts
  6. +43
    -0
      src/getUrlScheme.test.ts
  7. +19
    -0
      src/getUrlScheme.ts
  8. +6
    -0
      src/index.test.ts
  9. +2
    -1
      src/index.ts
  10. +26
    -0
      src/isValidFileName.test.ts
  11. +38
    -0
      src/utilities/Blob.test.ts
  12. +4
    -0
      src/utilities/Blob.ts
  13. +113
    -0
      src/utilities/File.test.ts
  14. +41
    -0
      src/utilities/File.ts
  15. +19
    -0
      src/utilities/isBrowser.test.ts
  16. +5
    -0
      src/utilities/isBrowser.ts
  17. +1
    -0
      tsconfig.json

+ 1
- 1
package.json Ver arquivo

@@ -1,5 +1,5 @@
{
"version": "1.0.2",
"version": "1.0.3",
"license": "MIT",
"main": "dist/index.js",
"typings": "dist/index.d.ts",


+ 24
- 0
src/dataUriToBlob.test.ts Ver arquivo

@@ -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')
}
)
)
})

+ 4
- 19
src/dataUriToBlob.ts Ver arquivo

@@ -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

+ 52
- 64
src/formatFileSize.test.ts Ver arquivo

@@ -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)
}
)
)
})
})
})

+ 10
- 3
src/formatFileSize.ts Ver arquivo

@@ -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

+ 43
- 0
src/getUrlScheme.test.ts Ver arquivo

@@ -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)
}
)
)
})

+ 19
- 0
src/getUrlScheme.ts Ver arquivo

@@ -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

+ 6
- 0
src/index.test.ts Ver arquivo

@@ -18,6 +18,12 @@ describe('formatFileSize', () => {
})
})

describe('getUrlScheme', () => {
it('should exist', () => {
expect(index.getUrlScheme).toBeDefined()
})
})

describe('isValidFileName', () => {
it('should exist', () => {
expect(index.isValidFileName).toBeDefined()


+ 2
- 1
src/index.ts Ver arquivo

@@ -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 }

+ 26
- 0
src/isValidFileName.test.ts Ver arquivo

@@ -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)
}
)
)
})
})

+ 38
- 0
src/utilities/Blob.test.ts Ver arquivo

@@ -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())
}
)
)
})
})

+ 4
- 0
src/utilities/Blob.ts Ver arquivo

@@ -0,0 +1,4 @@
import NodeBlob from 'node-blob'
import isBrowser from './isBrowser'

export default isBrowser() ? window.Blob : NodeBlob

+ 113
- 0
src/utilities/File.test.ts Ver arquivo

@@ -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())
})
)
})
})

+ 41
- 0
src/utilities/File.ts Ver arquivo

@@ -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

+ 19
- 0
src/utilities/isBrowser.test.ts Ver arquivo

@@ -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')
})
})

+ 5
- 0
src/utilities/isBrowser.ts Ver arquivo

@@ -0,0 +1,5 @@
type IsBrowser = () => boolean

const isBrowser: IsBrowser = () => typeof window !== 'undefined'

export default isBrowser

+ 1
- 0
tsconfig.json Ver arquivo

@@ -2,6 +2,7 @@
"include": ["src", "types"],
"compilerOptions": {
"module": "esnext",
"target": "ES6",
"lib": ["dom", "esnext"],
"importHelpers": true,
"declaration": true,


Carregando…
Cancelar
Salvar