Quellcode durchsuchen

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 vor 4 Jahren
Ursprung
Commit
8f969cd4e2
17 geänderte Dateien mit 408 neuen und 88 gelöschten Zeilen
  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 Datei anzeigen

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

export default isBrowser

+ 1
- 0
tsconfig.json Datei anzeigen

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


Laden…
Abbrechen
Speichern