-
Notifications
You must be signed in to change notification settings - Fork 3.4k
breaking: upgrade cy.readFile() to be a query command #25595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
82425e6
3345971
1cd59da
7c50dfb
1616186
29b1b7e
25c0d55
58f6fa7
ee4e87c
b3a0b57
fa514d5
96e75d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,14 @@ describe('src/cy/commands/files', () => { | |
| }) | ||
|
|
||
| describe('#readFile', () => { | ||
| it('really works', () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved the simplest tests higher up in the file. It felt off that we started with detailed assertions about the interaction with the backend before just using the command plainly. |
||
| cy.readFile('./cypress/fixtures/fileSpec.json').its('baseUrl').should('eq', 'http://localhost:3500') | ||
| }) | ||
|
|
||
| it('works when contents are supposed to be null', () => { | ||
| cy.readFile('does-not-exist').should('be.null') | ||
| }) | ||
|
|
||
| it('triggers \'read:file\' with the right options', () => { | ||
| Cypress.backend.resolves(okResponse) | ||
|
|
||
|
|
@@ -80,7 +88,7 @@ describe('src/cy/commands/files', () => { | |
| .resolves(okResponse) | ||
|
|
||
| cy.readFile('foo.json').then(() => { | ||
| expect(retries).to.eq(1) | ||
| expect(retries).to.eq(2) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When mocked, the new version always retries one more time than the non-query version. This occurs because the first invocation triggers the promise which reads the file from disk and throws an error; it is always asynchronous, even when mocked. |
||
| }) | ||
| }) | ||
|
|
||
|
|
@@ -102,18 +110,12 @@ describe('src/cy/commands/files', () => { | |
| }) | ||
|
|
||
| cy.readFile('foo.json').should('eq', 'quux').then(() => { | ||
| expect(retries).to.eq(1) | ||
| // Two retries: The first one triggers a backend request and throws a 'not ready' error. | ||
| // The second gets foobarbaz, triggering another request to the backend. | ||
| expect(retries).to.eq(2) | ||
| }) | ||
| }) | ||
|
|
||
| it('really works', () => { | ||
| cy.readFile('./cypress/fixtures/fileSpec.json').its('baseUrl').should('eq', 'http://localhost:3500') | ||
| }) | ||
|
|
||
| it('works when contents are supposed to be null', () => { | ||
| cy.readFile('does-not-exist').should('be.null') | ||
| }) | ||
|
|
||
| describe('.log', () => { | ||
| beforeEach(function () { | ||
| this.logs = [] | ||
|
|
@@ -176,10 +178,6 @@ describe('src/cy/commands/files', () => { | |
|
|
||
| this.logs = [] | ||
|
|
||
| cy.on('fail', () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Having an The only change this makes is that in some tests we see three logs rather than two, because "the stub has been called" log is included (where previously it would have been ignored because of this handler-removal). |
||
| cy.off('log:added', collectLogs) | ||
| }) | ||
|
|
||
| return null | ||
| }) | ||
|
|
||
|
|
@@ -243,7 +241,7 @@ describe('src/cy/commands/files', () => { | |
| cy.on('fail', (err) => { | ||
| const { fileLog } = this | ||
|
|
||
| assertLogLength(this.logs, 2) | ||
| assertLogLength(this.logs, 3) | ||
| expect(fileLog.get('error')).to.eq(err) | ||
| expect(fileLog.get('state')).to.eq('failed') | ||
| expect(err.message).to.eq(stripIndent`\ | ||
|
|
@@ -388,7 +386,7 @@ describe('src/cy/commands/files', () => { | |
| expect(fileLog.get('error')).to.eq(err) | ||
| expect(fileLog.get('state')).to.eq('failed') | ||
| expect(err.message).to.eq(stripIndent`\ | ||
| \`cy.readFile("foo")\` timed out after waiting \`10ms\`. | ||
| Timed out retrying after 10ms: \`cy.readFile("foo")\` timed out. | ||
| `) | ||
|
|
||
| expect(err.docsUrl).to.eq('https://on.cypress.io/readfile') | ||
|
|
@@ -412,7 +410,7 @@ describe('src/cy/commands/files', () => { | |
| expect(fileLog.get('error')).to.eq(err) | ||
| expect(fileLog.get('state')).to.eq('failed') | ||
| expect(err.message).to.eq(stripIndent`\ | ||
| \`cy.readFile("foo")\` timed out after waiting \`42ms\`. | ||
| Timed out retrying after 42ms: \`cy.readFile("foo")\` timed out. | ||
| `) | ||
|
|
||
| expect(err.docsUrl).to.eq('https://on.cypress.io/readfile') | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,11 +64,11 @@ context('cy.origin files', { browser: '!webkit' }, () => { | |
| }) | ||
|
|
||
| cy.shouldWithTimeout(() => { | ||
| const { consoleProps } = findCrossOriginLogs('readFile', logs, 'foobar.com') | ||
| const log = findCrossOriginLogs('readFile', logs, 'foobar.com') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No functional change here. While working on these changes, I found it easier to read test failures when they were of the form "unable to read |
||
|
|
||
| expect(consoleProps.Command).to.equal('readFile') | ||
| expect(consoleProps['File Path']).to.include('cypress/fixtures/example.json') | ||
| expect(consoleProps.Contents).to.deep.equal({ example: true }) | ||
| expect(log.consoleProps.Command).to.equal('readFile') | ||
| expect(log.consoleProps['File Path']).to.include('cypress/fixtures/example.json') | ||
| expect(log.consoleProps.Contents).to.deep.equal({ example: true }) | ||
| }) | ||
| }) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,118 +4,132 @@ import { basename } from 'path' | |
| import $errUtils from '../../cypress/error_utils' | ||
| import type { Log } from '../../cypress/log' | ||
|
|
||
| interface InternalReadFileOptions extends Partial<Cypress.Loggable & Cypress.Timeoutable> { | ||
| _log?: Log | ||
| encoding: Cypress.Encodings | ||
| interface ReadFileOptions extends Partial<Cypress.Loggable & Cypress.Timeoutable> { | ||
| encoding?: Cypress.Encodings | ||
| } | ||
|
|
||
| interface InternalWriteFileOptions extends Partial<Cypress.WriteFileOptions & Cypress.Timeoutable> { | ||
| _log?: Log | ||
| } | ||
|
|
||
| export default (Commands, Cypress, cy, state) => { | ||
| Commands.addAll({ | ||
| readFile (file, encoding, userOptions: Partial<Cypress.Loggable & Cypress.Timeoutable> = {}) { | ||
| if (_.isObject(encoding)) { | ||
| userOptions = encoding | ||
| encoding = undefined | ||
| Commands.addQuery('readFile', function readFile (file, encoding, options: ReadFileOptions = {}) { | ||
| if (_.isObject(encoding)) { | ||
| options = encoding | ||
| encoding = options.encoding | ||
| } | ||
|
|
||
| encoding = encoding === undefined ? 'utf8' : encoding | ||
|
|
||
| const timeout = options.timeout ?? Cypress.config('defaultCommandTimeout') | ||
|
|
||
| this.set('timeout', timeout) | ||
| this.set('ensureExistenceFor', 'subject') | ||
|
|
||
| const log = options.log !== false && Cypress.log({ message: file, timeout }) | ||
|
|
||
| if (!file || !_.isString(file)) { | ||
| $errUtils.throwErrByPath('files.invalid_argument', { | ||
| args: { cmd: 'readFile', file }, | ||
| }) | ||
| } | ||
|
|
||
| let fileResult: any = null | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way this now works is basically a state machine, with three pieces of state:
The state can be updated in three different places.
The end result is that we query the disk for a file, and throw a "timed out" error until we get back a result (including "no file exists"); if the server responds with an unexpected error, we start throwing that instead of "timed out" and retry. If it succeeds, we start returning that file as the result. If an upcoming assertion fails (including the implicit "file should exist" assertion), we clear the result and head back into the retry loop. |
||
| let filePromise: Promise<void> | null = null | ||
| let mostRecentError = $errUtils.cypressErrByPath('files.read_timed_out', { | ||
| args: { file }, | ||
chrisbreiding marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
|
|
||
| const createFilePromise = () => { | ||
| // If we already have a pending request to the backend, we'll wait | ||
| // for that one to resolve instead of creating a new one. | ||
| if (filePromise) { | ||
| return | ||
| } | ||
|
|
||
| const options: InternalReadFileOptions = _.defaults({}, userOptions, { | ||
| fileResult = null | ||
| filePromise = Cypress.backend('read:file', file, { encoding }) | ||
| .timeout(timeout) | ||
| .then((result) => { | ||
| // https://github.com/cypress-io/cypress/issues/1558 | ||
| // If no encoding is specified, then Cypress has historically defaulted | ||
| // to `utf8`, because of it's focus on text files. This is in contrast to | ||
| // NodeJs, which defaults to binary. We allow users to pass in `null` | ||
| // to restore the default node behavior. | ||
| encoding: encoding === undefined ? 'utf8' : encoding, | ||
| log: true, | ||
| timeout: Cypress.config('defaultCommandTimeout'), | ||
| // https://github.com/cypress-io/cypress/issues/20683 | ||
| // We invoke Buffer.from() in order to transform this from an ArrayBuffer - | ||
| // which socket.io uses to transfer the file over the websocket - into a `Buffer`. | ||
| if (encoding === null && result.contents !== null) { | ||
| result.contents = Buffer.from(result.contents) | ||
| } | ||
|
|
||
| // Add the filename to the current command, in case we need it later (such as when storing an alias) | ||
| state('current').set('fileName', basename(result.filePath)) | ||
|
|
||
| fileResult = result | ||
| }) | ||
| .catch((err) => { | ||
| if (err.name === 'TimeoutError') { | ||
| $errUtils.throwErrByPath('files.timed_out', { | ||
| args: { cmd: 'readFile', file, timeout }, | ||
| retry: false, | ||
| }) | ||
| } | ||
|
|
||
| const consoleProps = {} | ||
| // Non-ENOENT errors are not retried | ||
| if (err.code !== 'ENOENT') { | ||
| $errUtils.throwErrByPath('files.unexpected_error', { | ||
| args: { cmd: 'readFile', action: 'read', file, filePath: err.filePath, error: err.message }, | ||
| errProps: { retry: false }, | ||
| }) | ||
| } | ||
|
|
||
| if (options.log) { | ||
| options._log = Cypress.log({ | ||
| message: file, | ||
| timeout: options.timeout, | ||
| consoleProps () { | ||
| return consoleProps | ||
| }, | ||
| // We have a ENOENT error - the file doesn't exist. Whether this is an error or not is deterimened | ||
| // by verifyUpcomingAssertions, when the command_queue receives the null file contents. | ||
| fileResult = { contents: null, filePath: err.filePath } | ||
| }) | ||
| .catch((err) => mostRecentError = err) | ||
| // Pass or fail, we always clear the filePromise, so future retries know there's no | ||
| // live request to the server. | ||
| .finally(() => filePromise = null) | ||
| } | ||
|
|
||
| // When an assertion attached to this command fails, then we want to throw away the existing result | ||
| // and create a new promise to read a new one. | ||
| this.set('onFail', (err, timedOut) => { | ||
| if (err.type === 'existence') { | ||
| // file exists but it shouldn't - or - file doesn't exist but it should | ||
| const errPath = fileResult.contents ? 'files.existent' : 'files.nonexistent' | ||
| const { message, docsUrl } = $errUtils.cypressErrByPath(errPath, { | ||
| args: { cmd: 'readFile', file, filePath: fileResult.filePath }, | ||
| }) | ||
| } | ||
|
|
||
| if (!file || !_.isString(file)) { | ||
| $errUtils.throwErrByPath('files.invalid_argument', { | ||
| onFail: options._log, | ||
| args: { cmd: 'readFile', file }, | ||
| }) | ||
| err.message = message | ||
| err.docsUrl = docsUrl | ||
| } | ||
|
|
||
| // We clear the default timeout so we can handle | ||
| // the timeout ourselves | ||
| cy.clearTimeout() | ||
| createFilePromise() | ||
| }) | ||
|
|
||
| const verifyAssertions = () => { | ||
| return Cypress.backend('read:file', file, _.pick(options, 'encoding')).timeout(options.timeout) | ||
| .catch((err) => { | ||
| if (err.name === 'TimeoutError') { | ||
| return $errUtils.throwErrByPath('files.timed_out', { | ||
| onFail: options._log, | ||
| args: { cmd: 'readFile', file, timeout: options.timeout }, | ||
| }) | ||
| } | ||
|
|
||
| // Non-ENOENT errors are not retried | ||
| if (err.code !== 'ENOENT') { | ||
| return $errUtils.throwErrByPath('files.unexpected_error', { | ||
| onFail: options._log, | ||
| args: { cmd: 'readFile', action: 'read', file, filePath: err.filePath, error: err.message }, | ||
| }) | ||
| } | ||
|
|
||
| return { | ||
| contents: null, | ||
| filePath: err.filePath, | ||
| } | ||
| }).then(({ filePath, contents }) => { | ||
| // https://github.com/cypress-io/cypress/issues/1558 | ||
| // https://github.com/cypress-io/cypress/issues/20683 | ||
| // We invoke Buffer.from() in order to transform this from an ArrayBuffer - | ||
| // which socket.io uses to transfer the file over the websocket - into a `Buffer`. | ||
| if (options.encoding === null && contents !== null) { | ||
| contents = Buffer.from(contents) | ||
| } | ||
|
|
||
| // Add the filename as a symbol, in case we need it later (such as when storing an alias) | ||
| state('current').set('fileName', basename(filePath)) | ||
|
|
||
| consoleProps['File Path'] = filePath | ||
| consoleProps['Contents'] = contents | ||
|
|
||
| return cy.verifyUpcomingAssertions(contents, options, { | ||
| ensureExistenceFor: 'subject', | ||
| onFail (err) { | ||
| if (err.type !== 'existence') { | ||
| return | ||
| } | ||
|
|
||
| // file exists but it shouldn't - or - file doesn't exist but it should | ||
| const errPath = contents ? 'files.existent' : 'files.nonexistent' | ||
| const { message, docsUrl } = $errUtils.cypressErrByPath(errPath, { | ||
| args: { cmd: 'readFile', file, filePath }, | ||
| }) | ||
|
|
||
| err.message = message | ||
| err.docsUrl = docsUrl | ||
| }, | ||
| onRetry: verifyAssertions, | ||
| }) | ||
| }) | ||
| return () => { | ||
| // Once we've read a file, that remains the result, unless it's cleared | ||
| // because of a failed assertion in `onFail` above. | ||
| if (fileResult) { | ||
| const props = { | ||
| 'Contents': fileResult?.contents, | ||
| 'File Path': fileResult?.filePath, | ||
| } | ||
|
|
||
| log && state('current') === this && log.set('consoleProps', () => props) | ||
|
|
||
| return fileResult.contents | ||
| } | ||
|
|
||
| return verifyAssertions() | ||
| }, | ||
| createFilePromise() | ||
|
|
||
| // If we don't have a result, then the promise is pending. | ||
| // Throw an error and wait for the promise to eventually resolve on a future retry. | ||
| throw mostRecentError | ||
| } | ||
| }) | ||
|
|
||
| Commands.addAll({ | ||
| writeFile (fileName, contents, encoding, userOptions: Partial<Cypress.WriteFileOptions & Cypress.Timeoutable> = {}) { | ||
| if (_.isObject(encoding)) { | ||
| userOptions = encoding | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the other test changes, this one relied on the state after the first retry; we no longer wait for a server response before "retrying" - so the test needs to be more patient.