cypress-io / cypress

Fast, easy and reliable testing for anything that runs in a browser.
https://cypress.io
MIT License
47.05k stars 3.18k forks source link

File's instanceof File is false. Two windows have different File classes. #933

Closed KrzysztofKarol closed 6 years ago

KrzysztofKarol commented 6 years ago

Is this a Feature or Bug?

Bug

Current behavior:

File class is not shared with window's children.

Desired behavior:

File class is somehow shared.

How to reproduce:

  1. Write test and create a file in it.
  2. Use created file in app code (instanceof File)

Test code:

describe('Create new File', function() {
  it('should has same File class', function() {
    const file = new File(/* arguments */);
    console.log(file instanceof File); // true

    // test runs in new window with different File
    doSomethingWithFile(file); // false
  });
});
// App code
const doSomethingWithFile = file => console.log(file instanceof File);

Additional Info (images, stack traces, etc)

Where did I encounter a problem: https://github.com/jaydenseric/extract-files/blob/10236ac78548a3f9c64d148aa79e1444e8dff285/src/index.js#L33

Object.prototype.toString.call(node[key]) === "[object File]" // works

SO topic: https://stackoverflow.com/questions/30040096/a-file-object-in-chrome-passed-between-windows-is-no-longer-an-instanceof-file

Temporary hack

// vendor/extract-files/index.js

// https://github.com/jaydenseric/extract-files/blob/master/src/index.js
// https://github.com/cypress-io/cypress/issues/933
/**
 * Checks a node is an enumerable object.
 * @param {*} node - A node to check.
 * @returns {Boolean} Is the node an enumerable object.
 */
export const isObject = node => typeof node === 'object' && node !== null

/**
 * A file extraction.
 * @typedef {Object} ExtractedFile
 * @property {String} path - Original location in the object tree.
 * @property {String} file - The actual file.
 */

/**
 * Reversibly extracts files from an object tree.
 * @param {object} tree - An object tree to extract files from.
 * @param {string} [treePath=''] - Optional tree path to prefix file paths.
 * @returns {ExtractedFile[]} Extracted files.
 */
export function extractFiles(tree, treePath = '') {
  const files = []
  const recurse = (node, nodePath) => {
    // Iterate enumerable properties of the node
    Object.keys(node).forEach(key => {
      // Skip non-object
      if (!isObject(node[key])) return

      const path = `${nodePath}${key}`

      if (
        // Node is a File
      (typeof File !== 'undefined' &&
        Object.prototype.toString.call(node[key]) === '[object File]') ||
      // Node is a ReactNativeFile
      node[key] instanceof ReactNativeFile
      ) {
        // Extract the file and it's object tree path
        files.push({ path, file: node[key] })

        // Delete the file. Array items must be deleted without reindexing to
        // allow repopulation in a reverse operation.
        delete node[key]

        // No further checks or recursion
        return
      }

      if (typeof FileList !== 'undefined' && node[key] instanceof FileList)
      // Convert read-only FileList to an array for manipulation
        node[key] = Array.from(node[key])

      // Recurse into child node
      recurse(node[key], `${path}.`)
    })
  }

  if (isObject(tree))
  // Recurse object tree
    recurse(
      tree,
      // If a tree path was provided, append a dot
      treePath === '' ? treePath : `${treePath}.`
    )

  return files
}

/**
 * A React Native FormData file object.
 * @see {@link https://github.com/facebook/react-native/blob/v0.45.1/Libraries/Network/FormData.js#L34}
 * @typedef {Object} ReactNativeFileObject
 * @property {String} uri - File system path.
 * @property {String} [type] - File content type.
 * @property {String} [name] - File name.
 */

/**
 * A React Native file.
 */
export class ReactNativeFile {
  /**
   * Constructs a new file.
   * @param {ReactNativeFileObject} file
   * @example
   * const file = new ReactNativeFile({
   *  uri: uriFromCameraRoll,
   *  type: 'image/jpeg',
   *  name: 'photo.jpg'
   * })
   */
  constructor({ uri, type, name }) {
    this.uri = uri
    this.type = type
    this.name = name
  }

  /**
   * Creates an array of file instances.
   * @param {ReactNativeFileObject[]} files
   * @example
   * const files = ReactNativeFile.list([{
   *   uri: uriFromCameraRoll1,
   *   type: 'image/jpeg',
   *   name: 'photo-1.jpg'
   * }, {
   *   uri: uriFromCameraRoll2,
   *   type: 'image/jpeg',
   *   name: 'photo-2.jpg'
   * }])
   */
  static list = files => files.map(file => new ReactNativeFile(file))
}
// package.json (this works only for yarn)

"resolutions": {
  "apollo-upload-client/extract-files": "file:./vendor/extract-files"
}
// webpack.config.js

module: {
  rules: [
    {
      test: /\.(js|jsx|mjs)$/,
      include: [
        path.resolve(__dirname, '../node_modules/extract-files'),
        // ...
      ],
    }
  ]
}
brian-mann commented 6 years ago

This should be fairly simple to fix: you simply need to instantiate Files from your application's window, and not from the test spec window.

// nope
// this is using the global window in the test spec
new File() 

cy.window().then((win) => {
  // yup
  // this is using the File constructor from the application window
  new win.File()
})
tricoder42 commented 5 years ago

This isn't related to Windows OS. It affects all usecases where File/Blob/Filelist instances are created in cypress test files. I use MacOS, for example.

I've added comment to recently updated issue: https://github.com/cypress-io/cypress/issues/170#issuecomment-442843559

tnrich commented 3 years ago

@brian-mann do you have any suggestions for how to fix this issue if the code instantiating the File is from a 3rd party module? In my case I'm seeing it happen in a call from papaparse:

error: TypeError: Cannot read property 'stream' of null
    at parse (VM13 main.demo.js:684)
    at VM13 main.demo.js:288
    at parseCsvFile (VM13 main.demo.js:287)
    at VM13 main.demo.js:588
    at doSubmit (VM13 main.demo.js:189182)
    at handleSubmit (VM13 main.demo.js:189244)

that parse call is coming from papaparse. I can't (easily) change that code.

Any suggestions would be appreciated. Thanks!

bahmutov commented 3 years ago

@tnrich I do not see a problem when using cypress-file-upload v5 here https://github.com/cypress-io/cypress-example-recipes/pull/707 If you are hitting a problem using the latest versions of Cypress and plugins, please create a small reproducible example we can run to see the problem.

tnrich commented 3 years ago

@bahmutov it was caused by using the wrong File object. Personally, I think cypress should ship with a default cy.upload() command (or something similar) to avoid this issue in the future.

Here was my original code:

Cypress.Commands.add("uploadFile", (selector, fileUrl, type = "") => {
  return cy.fixture(fileUrl, "base64").then((input) => {
    const blob = Cypress.Blob.base64StringToBlob(input);
    const name = fileUrl.split("/").pop();
    const testFile = new File([blob], name, { type });
    const event = { dataTransfer: { files: [testFile] } };
    return cy.get(selector).trigger("drop", event);
  });
});

Changing it to:

Cypress.Commands.add("uploadFile", (selector, fileUrl, type = "") => {
  return cy.fixture(fileUrl, "base64").then((input) => {
    const blob = Cypress.Blob.base64StringToBlob(input);
    const name = fileUrl.split("/").pop();
    return cy.window().then((win) => {
      // this is using the File constructor from the application window
      const testFile = new win.File([blob], name, { type });
      const event = { dataTransfer: { files: [testFile] } };
      return cy.get(selector).trigger("drop", event);
    })
  });
});

fixed it

jennifer-shehane commented 3 years ago

@tnrich I agree, this would be great. You can express your interest in this feature here: https://github.com/cypress-io/cypress/issues/170