Ver a proveniência

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 há 4 anos
ascendente
cometimento
8f969cd4e2
17 ficheiros alterados com 408 adições e 88 eliminaçõ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 ficheiro

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

export default isBrowser

+ 1
- 0
tsconfig.json Ver ficheiro

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


Carregando…
Cancelar
Guardar