Преглед изворни кода

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 пре 4 година
родитељ
комит
8f969cd4e2
17 измењених фајлова са 408 додато и 88 уклоњено
  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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

@@ -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 Прегледај датотеку

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

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

export default isBrowser

+ 1
- 0
tsconfig.json Прегледај датотеку

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


Loading…
Откажи
Сачувај