diff --git a/cli/package.json b/cli/package.json index 643f4227e8cb..78c053a777f2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -28,7 +28,8 @@ "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", - "bluebird": "3.7.2", + "bluebird": "^3.7.2", + "buffer": "^5.6.0", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", diff --git a/cli/types/cypress-eventemitter.d.ts b/cli/types/cypress-eventemitter.d.ts index 9eab941147e8..6f0347f3b32b 100644 --- a/cli/types/cypress-eventemitter.d.ts +++ b/cli/types/cypress-eventemitter.d.ts @@ -27,3 +27,7 @@ interface NodeEventEmitter { prependOnceListener(event: string | symbol, listener: (...args: any[]) => void): this eventNames(): Array } + +// We use the Buffer class for dealing with binary data, especially around the +// selectFile interface. +type BufferType = import("buffer/").Buffer diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index b2091224da48..4546e2f0ca47 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -231,6 +231,15 @@ declare namespace Cypress { * Cypress.Blob.method() */ Blob: BlobUtil.BlobUtilStatic + /** + * Cypress automatically includes a Buffer library and exposes it as Cypress.Buffer. + * + * @see https://on.cypress.io/buffer + * @see https://github.com/feross/buffer + * @example + * Cypress.Buffer.method() + */ + Buffer: BufferType /** * Cypress automatically includes minimatch and exposes it as Cypress.minimatch. * @@ -662,6 +671,20 @@ declare namespace Cypress { */ as(alias: string): Chainable + /** + * Select a file with the given element, or drag and drop a file over any DOM subject. + * + * @param {FileReference} files - The file(s) to select or drag onto this element. + * @see https://on.cypress.io/selectfile + * @example + * cy.get('input[type=file]').selectFile(Cypress.Buffer.from('text')) + * cy.get('input[type=file]').selectFile({ + * fileName: 'users.json', + * fileContents: [{name: 'John Doe'}] + * }) + */ + selectFile(files: FileReference | FileReference[], options?: Partial): Chainable + /** * Blur a focused element. This element must currently be in focus. * If you want to ensure an element is focused before blurring, @@ -2466,6 +2489,17 @@ declare namespace Cypress { scrollBehavior: scrollBehaviorOptions } + interface SelectFileOptions extends Loggable, Timeoutable, ActionableOptions { + /** + * Which user action to perform. `select` matches selecting a file while + * `drag-drop` matches dragging files from the operating system into the + * document. + * + * @default 'select' + */ + action: 'select' | 'drag-drop' + } + interface BlurOptions extends Loggable, Forceable { } interface CheckOptions extends Loggable, Timeoutable, ActionableOptions { @@ -5641,6 +5675,17 @@ declare namespace Cypress { stderr: string } + type FileReference = string | BufferType | FileReferenceObject + interface FileReferenceObject { + /* + * Buffers will be used as-is, while strings will be interpreted as an alias or a file path. + * All other types will have `Buffer.from(JSON.stringify())` applied. + */ + contents: any + fileName?: string + lastModified?: number + } + interface LogAttrs { url: string consoleProps: ObjectLike diff --git a/packages/driver/cypress/fixtures/files-form.html b/packages/driver/cypress/fixtures/files-form.html new file mode 100644 index 000000000000..44bdde59a05a --- /dev/null +++ b/packages/driver/cypress/fixtures/files-form.html @@ -0,0 +1,110 @@ + + + + Generic File Inputs + + + + + +
+ + + + +
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + + + +
+ +
+
+ +
+ +
+ + +
+ +
+
+ + +
+ + diff --git a/packages/driver/cypress/integration/commands/actions/selectFile_spec.js b/packages/driver/cypress/integration/commands/actions/selectFile_spec.js new file mode 100644 index 000000000000..8fb08b49176a --- /dev/null +++ b/packages/driver/cypress/integration/commands/actions/selectFile_spec.js @@ -0,0 +1,675 @@ +const { _, $ } = Cypress + +// Reading and decoding files from an input element would, in the real world, +// be handled by the application under test, and they would assert on their +// application state. We want to assert on how selectFile behaves directly +// though, and getting the files associated with an as strings is +// handy. +function getFileContents (subject) { + const decoder = new TextDecoder('utf8') + + const fileContents = _.map(subject[0].files, (f) => { + return f + .arrayBuffer() + .then((c) => decoder.decode(c)) + }) + + return Promise.all(fileContents) +} + +describe('src/cy/commands/actions/selectFile', () => { + beforeEach(() => { + cy.visit('/fixtures/files-form.html') + cy.wrap(Cypress.Buffer.from('foo')).as('foo') + }) + + context('#selectFile', () => { + it('selects a single file', () => { + cy.get('#basic') + .selectFile({ contents: '@foo', fileName: 'foo.txt' }) + + cy.get('#basic') + .then((input) => { + expect(input[0].files.length).to.eq(1) + expect(input[0].files[0].name).to.eq('foo.txt') + expect(input[0].files[0].type).to.eq('') + expect(input[0].files[0].lastModified).to.be.closeTo(Date.now(), 1000) + }) + + cy.get('#basic') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql('foo') + }) + }) + + it('selects multiple files', () => { + cy.get('#multiple') + .selectFile([ + { + contents: '@foo', + fileName: 'foo.txt', + }, { + contents: Cypress.Buffer.from('{"a":"bar"}'), + fileName: 'bar.json', + }, + Cypress.Buffer.from('baz'), + ]) + + cy.get('#multiple') + .should('include.value', 'foo.txt') + .then((input) => { + expect(input[0].files[0].name).to.eq('foo.txt') + expect(input[0].files[1].name).to.eq('bar.json') + expect(input[0].files[2].name).to.eq('') + }) + + cy.get('#multiple') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eq('foo') + expect(contents[1]).to.eq('{"a":"bar"}') + expect(contents[2]).to.eq('baz') + }) + }) + + it('allows custom lastModified', () => { + cy.get('#basic').selectFile({ + contents: '@foo', + lastModified: 1234, + }) + + cy.get('#basic').then((input) => { + expect(input[0].files[0].lastModified).to.eq(1234) + }) + }) + + it('selects files with an input from a label', () => { + cy.get('#basic-label').selectFile({ contents: '@foo' }) + + cy.get('#basic') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql('foo') + }) + }) + + it('selects files with an input from a containing label', () => { + cy.get('#containing-label').selectFile({ contents: '@foo' }) + + cy.get('#contained') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql('foo') + }) + }) + + it('invokes change and input events on the input', (done) => { + const $input = cy.$$('#basic') + + $input.on('input', (e) => { + const obj = _.pick(e.originalEvent, 'bubbles', 'cancelable', 'composed', 'target', 'type') + + expect(obj).to.deep.eq({ + bubbles: true, + cancelable: false, + composed: true, + target: $input.get(0), + type: 'input', + }) + + $input.on('change', (e) => { + const obj = _.pick(e.originalEvent, 'bubbles', 'cancelable', 'composed', 'target', 'type') + + expect(obj).to.deep.eq({ + bubbles: true, + cancelable: false, + composed: false, + target: $input.get(0), + type: 'change', + }) + + done() + }) + }) + + cy.get('#basic').selectFile({ contents: '@foo' }) + }) + + it('bubbles events', (done) => { + cy.window().then((win) => { + $(win).on('input', () => { + done() + }) + }) + + cy.get('#basic').selectFile({ contents: '@foo' }) + }) + + it('invokes events on the input without changing subject when passed a label', (done) => { + cy.$$('#basic-label').on('input', () => { + throw new Error('shouldn\'t happen') + }) + + cy.$$('#basic').on('input', () => { + done() + }) + + cy.get('#basic-label').selectFile({ contents: '@foo' }) + .should('have.id', 'basic-label') + }) + + it('can empty previously filled input', () => { + cy.get('#basic').selectFile({ contents: '@foo' }) + cy.get('#basic').selectFile([]) + .then((input) => { + expect(input[0].files.length).to.eq(0) + }) + }) + + it('works with shadow DOMs', () => { + cy.get('#shadow') + .shadow() + .find('input') + .as('shadowInput') + .selectFile('@foo') + + cy.get('@shadowInput') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql('foo') + }) + }) + + describe('shorthands', () => { + const validJsonString = `{ + "foo": 1, + "bar": { + "baz": "cypress" + } +} +` + + it('works with aliased strings', () => { + cy.wrap('foobar').as('alias') + + cy.get('#basic').selectFile('@alias') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql('foobar') + }) + }) + + it('works with aliased objects', () => { + cy.wrap({ foo: 'bar' }).as('alias') + + cy.get('#basic').selectFile('@alias') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql('{"foo":"bar"}') + }) + }) + + it('works with aliased fixtures', () => { + cy.fixture('valid.json').as('myFixture') + + cy.get('#basic').selectFile('@myFixture') + .then(getFileContents) + .then((contents) => { + // Because json files are loaded as objects, they get reencoded before + // being used, stripping spaces and newlines + expect(contents[0]).to.eql('{"foo":1,"bar":{"baz":"cypress"}}') + }) + }) + + // Because this is such an important recipe for users, it gets a separate test + // even though readFile already has unit tests around reading files as buffers. + it('works with files read with null encoding', () => { + cy.readFile('cypress/fixtures/valid.json', { encoding: null }).as('myFile') + + cy.get('#basic').selectFile('@myFile') + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql(validJsonString) + }) + }) + + it('works with passed in paths', () => { + cy.get('#multiple').selectFile(['cypress/fixtures/valid.json', 'cypress/fixtures/app.js']) + .then(getFileContents) + .then((contents) => { + expect(contents[0]).to.eql(validJsonString) + expect(contents[1]).to.eql('{ \'bar\' }\n') + }) + + cy.get('#multiple') + .should('include.value', 'valid.json') + .then((input) => { + expect(input[0].files[0].name).to.eq('valid.json') + expect(input[0].files[1].name).to.eq('app.js') + }) + }) + }) + + describe('errors', { + defaultCommandTimeout: 50, + }, () => { + it('is a child command', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('A child command must be chained after a parent because it operates on a previous subject.') + done() + }) + + cy.selectFile({ contents: '@foo' }) + }) + + it('throws when non dom subject', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.selectFile()` failed because it requires a DOM element.') + done() + }) + + cy.noop({}).selectFile({ contents: '@foo' }) + }) + + it('throws when non-input subject', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.selectFile()` can only be called on an `` or a `