jsdom icon indicating copy to clipboard operation
jsdom copied to clipboard

Expose some method add files to a FileList

Open Sebmaster opened this issue 9 years ago • 37 comments

FileList's are not writeable in the specs, but to make input.files testable, we'll need to implement a util method to modify it.

/cc @cpojer

Sebmaster avatar Oct 23 '15 19:10 Sebmaster

input.files = createFileList(file1, file2, ...) would be nice.

cpojer avatar Oct 23 '15 19:10 cpojer

@cpojer Are you generating actual File objects to put in there?

Making input.files writeable is probably bad, we can probably either return the raw array to fill yourself, or do something like fillFileList(input.files, [file]).

Sebmaster avatar Oct 23 '15 19:10 Sebmaster

We actually only use mock data, so we populate .files with an array of objects. But requiring it to be a File object would be reasonable I think.

cpojer avatar Oct 23 '15 19:10 cpojer

It seems like defineProperty would be fine here? DOM properties are reconfigurable for a reason...

domenic avatar Oct 23 '15 19:10 domenic

I'd much rather create a real FileList with real data, though.

cpojer avatar Oct 23 '15 19:10 cpojer

It should also be possible to access FileList items using their array-indices. .item is not the only way to access their fields.

cpojer avatar Oct 23 '15 19:10 cpojer

OK, so it sounds like there are a few potential issues:

  • FileList doesn't properly have indexed access working (bug)
  • There's no way to create FileList objects for testing (web platform feature gap).

But modifying inputEl.files is not the problem.

domenic avatar Oct 23 '15 20:10 domenic

Yeah, I mean it isn't awesome to tell engineers to use Object.defineProperty over a regular assignment but I can live with it.

cpojer avatar Oct 23 '15 20:10 cpojer

Well, they have to do that anyway in a real browser, so it seems reasonable to me...

domenic avatar Oct 23 '15 20:10 domenic

Hi, I see this is over a year old, I would like to ask if there's any progress done concerning this issue? I use Jest framework to test my React/Redux app which internally uses jsdom. I have an issue where I need to dynamically create FileList with one or more File objects.

Looking at lib/jsdom/living/filelist.js I can see there is a constructor for FileList but there isn't any option to pass files to it. I do understand that FileList and File don't have constructor according to specifications due to security reasons but is there any intention to allow constructor to accept array of File objects or at least additional method (let's say setItem) that would allow us to add File objects into list specifically for testing purposes?

I also see one other issue with FileList. If I'm not mistaking it should be Array-like object, same as NodeList (lib/jsdom/living/node-list.js) meaning there should be a possibility to access File objects in two ways:

var fileList = document.getElementById("myfileinput").files;

fileList[0];
fileList.item(0);

Currently, it is only possible to access through method. This means same logic as in NodeList should be applied here:

FileList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];

and files should be stored something like this if we would allow passing array of Files to constructor:

for (let i = 0; i < files.length; ++i) {
  this[i] = files[i];
}

I don't have any strong opinions about how this should be done, these are just examples that I use to explain better what I'm trying to point out.

Extra questions (I don't want to open unnecessary issues before asking here):

1.) Would it be beneficial to add File constructor for testing purposes that creates File object from an actual file using path provided as string? I found library (I'm sorry, I can't find link anymore) that allowed this:

const file = new File('../fixtures/files/test-image.png');

This created File for me with properties (size, lastModified, type...) without me having to create it manually:

const file = new File([''], 'test-image.png', {
  lastModified: 1449505890000,
  lastModifiedDate: new Date(1449505890000),
  name: "ecp-logo.png",
  size: 44320,
  type: "image/png",
});

I don't know how this library worked all I know is that it was not maintained for over a year and we stopped using it. I can't seem to find it anymore.

2.) window.URL.createObjectURL isn't supported by jsdom. Not sure if it should be reported.

niksajanjic avatar Nov 29 '16 21:11 niksajanjic

I managed to create FileList without having to alter any of the jsdom library code:

const createFile = (size = 44320, name = 'ecp-logo.png', type = 'image/png') =>
  new File([new ArrayBuffer(size)], name , {
    type: type,
  });

const createFileList = (file) => {
  const fileList = new FileList();
  fileList[0] = file;
  return fileList;
}

const fileList = createFileList(createFile());

In this case I'm creating FileList object calling constructor on FileList provided by jsdom. After that I'm just adding file to that list using Array notation. In my case I only need 1 File in array but this can also be changed to for loop to add multiple files to FileList. I'm creating file with custom name/type/size through File constructor also provided by jsdom which functionality is following specification.

This might be helpful for someone who is looking how to mock FileList in jsdom environment, but one issue still stands. Creating FileList with array of Files using this method wouldn't allow getting items from array using FileList.item(index) method. But, even that can be fixed by overriding it's method something like this:

const createFileList = (file) => {
  const fileList = new FileList();
  fileList[0] = file;
  fileList.item = index => fileList[index]; // override method functionality
  return fileList;
}

I still feel it might be better if jsdom could offer these functionalities for testing purposes out of the box.

niksajanjic avatar Dec 05 '16 13:12 niksajanjic

Hi Guys , I am facing some issue for while mocking $("#selectorID of Upload file")[0].files[0] object which is returning FileList. Can someone help me to create FileList Object? Because I can't find any reference link anywhere in WWW

anuraggautam77 avatar Sep 27 '17 16:09 anuraggautam77

It's not currently possible.

domenic avatar Sep 27 '17 17:09 domenic

Thanks Domenic,

I need to write a test case for File upload change event. any alternate way to execute that test scenario .

anuraggautam77 avatar Sep 27 '17 19:09 anuraggautam77

Any progress on this?

BebeSparkelSparkel avatar Jan 28 '18 21:01 BebeSparkelSparkel

https://mobile.twitter.com/slicknet/status/782274190451671040

domenic avatar Jan 28 '18 21:01 domenic

@niksajanjic Did you manage to implement the the best solution presented in this thread? Ref: const file = new File('../fixtures/files/test-image.png'); Or something similar to it? Best

BebeSparkelSparkel avatar Jan 28 '18 21:01 BebeSparkelSparkel

@domenic Seems to be a bit of dissent in that twitter post on the issue

BebeSparkelSparkel avatar Jan 28 '18 21:01 BebeSparkelSparkel

@domenic Ok, I've set up the basic start to this. Not all the checks are there but it is essentially what @niksajanjic was talking about.

createFile

function createFile(file_path) {
  const { mtimeMs: lastModified, size } = fs.statSync(file_path)

  return new File(
    [new fs.readFileSync(file_path)],
    path.basename(file_path),
    {
      lastModified,
      type: mime.lookup(file_path) || '',
    }
  )
}

addFileList

function addFileList(input, file_paths) {
  if (typeof file_paths === 'string')
    file_paths = [file_paths]
  else if (!Array.isArray(file_paths)) {
    throw new Error('file_paths needs to be a file path string or an Array of file path strings')
  }

  const file_list = file_paths.map(fp => createFile(fp))
  file_list.__proto__ = Object.create(FileList.prototype)

  Object.defineProperty(input, 'files', {
    value: file_list,
    writeable: false,
  })

  return input
}

Demo File

/*eslint-disable no-console, no-unused-vars */

/*
https://github.com/jsdom/jsdom/issues/1272
*/

const fs = require('fs')
const path = require('path')
const mime = require('mime-types')

const { JSDOM } = require('jsdom')
const dom = new JSDOM(`
<!DOCTYPE html>
<body>
  <input type="file">
</body>
`)

const { window } = dom
const { document, File, FileList } = window


const file_paths = [
  '/Users/williamrusnack/Documents/form_database/test/try-input-file.html',
  '/Users/williamrusnack/Documents/form_database/test/try-jsdom-input-file.js',
]

function createFile(file_path) {
  const { mtimeMs: lastModified, size } = fs.statSync(file_path)

  return new File(
    [new fs.readFileSync(file_path)],
    path.basename(file_path),
    {
      lastModified,
      type: mime.lookup(file_path) || '',
    }
  )
}

function addFileList(input, file_paths) {
  if (typeof file_paths === 'string')
    file_paths = [file_paths]
  else if (!Array.isArray(file_paths)) {
    throw new Error('file_paths needs to be a file path string or an Array of file path strings')
  }

  const file_list = file_paths.map(fp => createFile(fp))
  file_list.__proto__ = Object.create(FileList.prototype)

  Object.defineProperty(input, 'files', {
    value: file_list,
    writeable: false,
  })

  return input
}



const input = document.querySelector('input')

addFileList(input, file_paths)

for (let i = 0; i < input.files.length; ++i) {
  const file = input.files[i]
  console.log('file', file)
  console.log('file.name', file.name)
  console.log('file.size', file.size)
  console.log('file.type', file.type)
  console.log('file.lastModified', file.lastModified)
  console.log()
}

BebeSparkelSparkel avatar Jan 28 '18 23:01 BebeSparkelSparkel

@BebeSparkelSparkel I made the test in a way I explained in my later post up there. Unfortunately, a few months later it stopped working on newer versions of jsdom when one of my colleagues tried to copy that code. So, we had to comment out the tests for FileList at that point. From there on, we couldn't find a way to write those tests and in last few months nobody had the time to take another look at it and try to find a way.

niksajanjic avatar Jan 29 '18 12:01 niksajanjic

@niksajanjic Thanks for your update. I'm working on the solution that you proposed and it seems to be working (see above code), but I think it unlikely to be added to jsdom since it would be very hard to figure out how to add it. Feel free to take a look and use it if you want though

BebeSparkelSparkel avatar Jan 29 '18 12:01 BebeSparkelSparkel

Created a simple helper script that solves this issue until the jsdom project comes up with a solution. https://bitbucket.org/william_rusnack/addfilelist/src/master/

Add one file:

const input = document.querySelector('input[type=file]')
addFileList(input, 'path/to/file')

Add multiple files:

const input = document.querySelector('input[type=file]')
addFileList(input, [
  'path/to/file',
  'path/to/another/file',
  // add as many as you want
])

Install and Require

npm install https://github.com/BebeSparkelSparkel/addFileList.git
const { addFileList } = require('addFileList')

Functions

addFileList(input, file_paths)
Effects: puts the file_paths as File object into input.files as a FileList
Returns: input
Arguments:

  • input: HTML input element
  • file_paths: String or Array of string file paths to put in input.files
    const { addFileList } = require('addFileList')

Example

Extract from example.js

// add a single file
addFileList(input, 'example.js')

// log input's FileList
console.log(input.files)

// log file properties
const [ file ] = input.files
console.log(file)
console.log(
  '\nlastModified', file.lastModified,
  '\nname', file.name,
  '\nsize', file.size,
  '\ntype', file.type,
  '\n'
)

Result

$ node example.js 
FileList [ File {} ]
File {}

lastModified 1518523506000 
name example.js 
size 647 
type application/javascript 

BebeSparkelSparkel avatar Feb 13 '18 12:02 BebeSparkelSparkel

@BebeSparkelSparkel your repo seems to have been deleted?

TheCycoONE avatar Jul 31 '18 15:07 TheCycoONE

@TheCycoONE https://bitbucket.org/william_rusnack/addfilelist/src/master/

BebeSparkelSparkel avatar Aug 30 '18 10:08 BebeSparkelSparkel

I had a similar problem— I wrote a function that took in a FileList as input and wanted to write a unit test for it using jest. I was able to use the below helper function to spoof a FileList object enough to work with my function. Syntax is ES6 with Flow annotations. No promises that it will work in all situations as its just faking the functionality of the real FileList class…

const createFileList = (files: Array<File>): FileList => {
  return {
    length: files.length,
    item: (index: number) => files[index],
    * [Symbol.iterator]() {
      for (let i = 0; i < files.length; i++) {
        yield files[i];
      }
    },
    ...files,
  };
};

elijahcarrel avatar Mar 25 '19 07:03 elijahcarrel

In the frontend, I can do something like the following to (in a round about way) construct a 'real' FileList:

export const makeFileList = files => {
  const reducer = (dataTransfer, file) => {
    dataTransfer.items.add(file)
    return dataTransfer
  }

  return files.reduce(reducer, new DataTransfer()).files
}

Ref: https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer

Unfortunately, jsdom doesn't seem to actually support DataTransfer yet, so this doesn't work in my tests:

  • https://github.com/jsdom/jsdom/issues/1568

Other refs:

  • https://github.com/whatwg/html/issues/3269
  • https://stackoverflow.com/questions/47119426/how-to-set-file-objects-and-length-property-at-filelist-object-where-the-files-a/47172409#47172409
  • https://developer.mozilla.org/en-US/docs/Web/API/FileList
  • https://w3c.github.io/FileAPI/#filelist-section

Searching the source a little i found exports.FileList = require("./generated/FileList").interface;

  • https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/index.js#L62

~But it wasn't clear to me from GitHub where to find the source that ends up building ./generated~

  • It seems like this is the relevant location: https://github.com/jsdom/jsdom/blob/master/Contributing.md#architecture
    • yarn prepare: https://github.com/jsdom/jsdom/blob/master/package.json#L86
    • yarn convert-idl: https://github.com/jsdom/jsdom/blob/master/package.json#L104
    • https://github.com/jsdom/jsdom/blob/master/scripts/webidl/convert.js#L26
  • https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/file-api/FileList.webidl
  • https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/file-api/FileList-impl.js

The main export of the npm package is ./lib/api.js and exports a very small public API:

exports.JSDOM = JSDOM;
exports.VirtualConsole = VirtualConsole;
exports.CookieJar = CookieJar;
exports.ResourceLoader = ResourceLoader;
exports.toughCookie = toughCookie;

But looking in my ./node_modules/jsdom/lib/jsdom directory.. i can see that all of the internal/implementation files are there as well, including ./node_modules/jsdom/lib/jsdom/living/file-api:

Blob-impl.js  File-impl.js  FileList-impl.js  FileReader-impl.js

FileList-impl.js here contains the actual JS implementation that backs the exposed FileList api in jsdom.

Now if we look at ./node_modules/jsdom/lib/jsdom/living/generated/FileList.js we see the actual generated 'public API' that we end up seeing through normal usage, including our all too familiar:

class FileList {
  constructor() {
    throw new TypeError("Illegal constructor");
  }

This file exports module.exports = iface;, which contains a lot more functionality than what we end up with through the 'normal' public API exposure, which only uses the iface.interface key. So perhaps we could do something fun if we use require("./generated/FileList") directly. Removing the implementation details, we have an interface that looks something like:

const iface = {
  _mixedIntoPredicates: [],
  is(obj) {..snip..},
  isImpl(obj) {..snip..},
  convert(obj, { context = "The provided value" } = {}) {..snip..},
  create(constructorArgs, privateData) {..snip..},
  createImpl(constructorArgs, privateData) {..snip..},
  _internalSetup(obj) {},
  setup(obj, constructorArgs, privateData) {...snip...},
  interface: FileList,
  expose: {
    Window: { FileList },
    Worker: { FileList }
  }
}; // iface

So now that we know that there is more power to be got.. let's see how other areas of jsdom access it..

Taking a look at HTMLInputElement-impl, it seems to use FileList.createImpl(), though unfortunately it doesn't actually show us how to use the params:

  • https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/HTMLInputElement-impl.js#L4
  • https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/HTMLInputElement-impl.js#L404-L411

createImpl is just a tiny wrapper around the setup in the exported iface:

createImpl(constructorArgs, privateData) {
    let obj = Object.create(FileList.prototype);
    obj = this.setup(obj, constructorArgs, privateData);
    return utils.implForWrapper(obj);
  },

Playing around with this in a console it seems we have the expressive api of the Array element the FileListImpl is backed by. So we can do things like:

var flist = require('./node_modules/jsdom/lib/jsdom/living/generated/FileList.js')
var myFileListImpl = flist.createImpl()
myFileListImpl.push('aa')

It has a Symbol(wrapper) property on it, which we will need to use ./node_modules/jsdom/lib/jsdom/living/generated/utils.js:37 to access:

var utils = require('./node_modules/jsdom/lib/jsdom/living/generated/utils.js')
var wrapper = myFileListImpl[utils.wrapperSymbol]

The exported iface has a function convert, which will throw new TypeError(${context} is not of type 'FileList'.); when the provided object isn't a FileList. We can use this to test things.

If we call it on the raw myFileListImpl it throws the error:

flist.convert(myFileListImpl)

Whereas using the wrapper we extracted above, we can see that it doesn't throw the error:

flist.convert(myFileListImpl[utils.wrapperSymbol])

With this, we can modify myFileListImpl, and get an acceptable FileList object back, to pass where we need it. A fully worked example (using util.wrapperForImpl() instead of our previous code):

var _FileList = require('./node_modules/jsdom/lib/jsdom/living/generated/FileList.js')
var utils = require('./node_modules/jsdom/lib/jsdom/living/generated/utils.js')

var myMutableFileListImpl = _FileList.createImpl()

myMutableFileListImpl.length // 0
myMutableFileListImpl.push(new File([], 'a.jpg'))
myMutableFileListImpl.length // 1

var myFileList = utils.wrapperForImpl(myMutableFileListImpl)
_FileList.convert(myFileList) // no error

myFileList.length // 1
myFileList[0] // the File{} object

Now with that knowledge, I can implement the jsdom testing version of my original browser workaround hack (implemented on top of ImmutableJS for laziness):

import { Map, Record } from 'immutable'

import jsdomFileList from 'jsdom/lib/jsdom/living/generated/FileList'
import { wrapperForImpl } from 'jsdom/lib/jsdom/living/generated/utils'

// Note: relying on internal API's is super hacky, and will probably break
// As soon as we can, we should use whatever the proper outcome from this issue is:
//   https://github.com/jsdom/jsdom/issues/1272#issuecomment-486088445

export const makeFileList = files => {
  const reducer = (fileListImpl, file) => {
    fileListImpl.push(file)
    return fileListImpl
  }

  const fileListImpl = files.reduce(reducer, jsdomFileList.createImpl())

  return wrapperForImpl(fileListImpl)
}

export class DataTransferStub extends Record({ items: Map() }) {
  get files() {
    return makeFileList(this.items.toList().toArray())
  }
}

export const stubGlobalDataTransfer = () => {
  global.DataTransfer = DataTransferStub
}

export const restoreGlobalDataTransfer = () => {
  global.DataTransfer = undefined
}

And then manually wire it into my global vars so that my ava tests can use it:

import {
  restoreGlobalDataTransfer,
  stubGlobalDataTransfer,
} from ../helpers/jsdom-helpers'

test.before(t => {
  stubGlobalDataTransfer()
})

test.after(t => {
  restoreGlobalDataTransfer()
})

0xdevalias avatar Apr 24 '19 06:04 0xdevalias

All modern browsers (i.e. not IE <= 11) now support setting input.files to a FileList https://stackoverflow.com/a/47522812/2744776

tmorehouse avatar Jul 13 '19 20:07 tmorehouse

This seems to have changed in a recent release. Here's what worked for me:

const jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils');
const jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList');
function makeFileList(...files) {
    const impl = jsdomFileList.createImpl(window);
    const ret = Object.assign([...files], {
        item: (ix) => ret[ix],
        [jsdomUtils.implSymbol]: impl,
    });
    impl[jsdomUtils.wrapperSymbol] = ret;
    Object.setPrototypeOf(ret, FileList.prototype);
    return ret;
}

If you're using JSDOM via Jest, then you have to make sure to require the internals outside of the testing VM. I created a custom test env like this:

const JsdomEnvironment = require('jest-environment-jsdom');

/** See jsdom/jsdom#1272 */
class EnvWithSyntheticFileList extends JsdomEnvironment {
    async setup() {
        await super.setup();
        this.global.jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils');
        this.global.jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList');
    }
}

module.exports = EnvWithSyntheticFileList;

So that I could access the 'outer' imports.

felipeochoa avatar Dec 11 '20 23:12 felipeochoa

This seems to have changed in a recent release. Here's what worked for me:

const jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils');
const jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList');
function makeFileList(...files) {
    const impl = jsdomFileList.createImpl(window);
    const ret = Object.assign([...files], {
        item: (ix) => ret[ix],
        [jsdomUtils.implSymbol]: impl,
    });
    impl[jsdomUtils.wrapperSymbol] = ret;
    Object.setPrototypeOf(ret, FileList.prototype);
    return ret;
}

If you're using JSDOM via Jest, then you have to make sure to require the internals outside of the testing VM. I created a custom test env like this:

const JsdomEnvironment = require('jest-environment-jsdom');

/** See jsdom/jsdom#1272 */
class EnvWithSyntheticFileList extends JsdomEnvironment {
    async setup() {
        await super.setup();
        this.global.jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils');
        this.global.jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList');
    }
}

module.exports = EnvWithSyntheticFileList;

So that I could access the 'outer' imports.

This got me close but I cant get past the internal validation:

exports.is = value => { return utils.isObject(value) && utils.hasOwn(value, implSymbol) && value[implSymbol] instanceof Impl.implementation; };

TypeError: Failed to set the 'files' property on 'HTMLInputElement': The provided value is not of type 'FileList'.

Spent hours. Super frustrating

dadocsis avatar Dec 29 '20 21:12 dadocsis

This seems to have changed in a recent release. Here's what worked for me:

const jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils');
const jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList');
function makeFileList(...files) {
    const impl = jsdomFileList.createImpl(window);
    const ret = Object.assign([...files], {
        item: (ix) => ret[ix],
        [jsdomUtils.implSymbol]: impl,
    });
    impl[jsdomUtils.wrapperSymbol] = ret;
    Object.setPrototypeOf(ret, FileList.prototype);
    return ret;
}

If you're using JSDOM via Jest, then you have to make sure to require the internals outside of the testing VM. I created a custom test env like this:

const JsdomEnvironment = require('jest-environment-jsdom');

/** See jsdom/jsdom#1272 */
class EnvWithSyntheticFileList extends JsdomEnvironment {
    async setup() {
        await super.setup();
        this.global.jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils');
        this.global.jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList');
    }
}

module.exports = EnvWithSyntheticFileList;

So that I could access the 'outer' imports.

This got me close but I cant get past the internal validation:

exports.is = value => { return utils.isObject(value) && utils.hasOwn(value, implSymbol) && value[implSymbol] instanceof Impl.implementation; };

TypeError: Failed to set the 'files' property on 'HTMLInputElement': The provided value is not of type 'FileList'.

Spent hours. Super frustrating

After starting again with a fresh brain I found the issue. Somehow I had 2 separate jsdom environments running which threw off the references to symbols.

dadocsis avatar Dec 30 '20 17:12 dadocsis