Pārlūkot izejas kodu

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 pirms 3 gadiem
vecāks
revīzija
8f969cd4e2
17 mainītis faili ar 408 papildinājumiem un 88 dzēšanām
  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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

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

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

export default isBrowser

+ 1
- 0
tsconfig.json Parādīt failu

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


Notiek ielāde…
Atcelt
Saglabāt