Skip to content
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

breaking: upgrade cy.readFile() to be a query command #25595

Merged
merged 12 commits into from
Feb 1, 2023
10 changes: 9 additions & 1 deletion cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 13.0.0

_Released 01/31/2023 (PENDING)_

**Breaking Changes:**

- The [`cy.readFile()`](/api/commands/readfile) command is now a [query command](https://on.cypress.io/retry-ability). This should not affect any tests using it; the functionality is unchanged. However, it can no longer be overwritten using [`Cypress.Commands.overwrite()`](/api/cypress-api/custom-commands#Overwrite-Existing-Commands). Addressed in [#25595](https://github.com/cypress-io/cypress/pull/25595).
BlueWinds marked this conversation as resolved.
Show resolved Hide resolved

## 12.4.1

_Released 01/27/2023_
Expand Down
11 changes: 7 additions & 4 deletions packages/driver/cypress/e2e/commands/assertions.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -652,18 +652,21 @@ describe('src/cy/commands/assertions', () => {
cy.get('button:first', { timeout: 500 }).should('have.class', 'does-not-have-class')
})

it('has a pending state while retrying for commands with onFail', (done) => {
it('has a pending state while retrying for commands with onFail', function (done) {
Copy link
Contributor Author

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.

cy.on('command:retry', () => {
const [readFileLog, shouldLog] = cy.state('current').get('logs')
// Wait for the readFile response to come back from the server
if (this.logs.length < 2) {
return
}

const [readFileLog, shouldLog] = this.logs

expect(readFileLog.get('state')).to.eq('pending')
expect(shouldLog.get('state')).to.eq('pending')

done()
})

cy.on('fail', () => {})

cy.readFile('does-not-exist.json', { timeout: 500 }).should('exist')
})

Expand Down
32 changes: 15 additions & 17 deletions packages/driver/cypress/e2e/commands/files.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ describe('src/cy/commands/files', () => {
})

describe('#readFile', () => {
it('really works', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)

Expand Down Expand Up @@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

})
})

Expand All @@ -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 = []
Expand Down Expand Up @@ -176,10 +178,6 @@ describe('src/cy/commands/files', () => {

this.logs = []

cy.on('fail', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having an on('fail') handler in the beforeEach() block makes it hard to debug actual failures, since if you comment out a test's onFail block, the test will suddenly pass.

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
})

Expand Down Expand Up @@ -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`\
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand Down
8 changes: 4 additions & 4 deletions packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 consoleProps from null" rather than "unable to destructure intermediate object" (the actual errors are significantly more verbose and harder to read than the above).


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 })
})
})

Expand Down
196 changes: 105 additions & 91 deletions packages/driver/src/cy/commands/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

@BlueWinds BlueWinds Jan 26, 2023

Choose a reason for hiding this comment

The 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:

  1. The contents of the file read from disk (can be null or have a value)
  2. The currently active promise, waiting for a response from the server (can be null or be a promise)
  3. The most recent error (always has a value)

The state can be updated in three different places.

  1. When the query function executes (return () => {), if we have a fileResult, return it. Otherwise, create a new filePromise if we don't already have one (createFilePromise()) and throw mostRecentError.
  2. When filePromise resolves (.then((result) => {), we set the contents to fileResult, and clear filePromise.
  3. When filePromise rejects:
    a. If it rejects ((.catch((err) => {)) with "file doesn't exist", we set fileResult to "no file exists" and clear filePromise.
    b. Otherwise, we set the error to mostRecentError, and clear filePromise.
  4. If an upcoming assertion fails, we clear fileResult.

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
Expand Down
3 changes: 1 addition & 2 deletions packages/driver/src/cy/commands/querying/querying.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ function getAlias (selector, log, cy) {
*/

if (command.get('name') === 'intercept') {
// Intercept aliases are fairly similar, but `getAliasedRequests` does *not* handle indexes
// and we have to do it ourselves here.
// `getAliasedRequests` does *not* handle indexes and we have to do it ourselves here.

const requests = getAliasedRequests(aliasObj.alias, cy.state)

Expand Down
6 changes: 6 additions & 0 deletions packages/driver/src/cypress/error_messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,12 @@ export default {
docsUrl: `https://on.cypress.io/${_.toLower(obj.cmd)}`,
}
},
read_timed_out (obj) {
return {
message: `${cmd('readFile', '"{{file}}"')} timed out.`,
chrisbreiding marked this conversation as resolved.
Show resolved Hide resolved
docsUrl: `https://on.cypress.io/readfile`,
}
},
timed_out (obj) {
return {
message: `${cmd('{{cmd}}', '"{{file}}"')} timed out after waiting \`{{timeout}}ms\`.`,
Expand Down
6 changes: 2 additions & 4 deletions packages/driver/src/cypress/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,8 @@ const defaults = function (state: StateFunc, config, obj) {
return {}
}

const ret = $dom.isElement(current.get('subject')) ?
$dom.getElements(current.get('subject'))
:
current.get('subject')
const subject = current.get('subject')
const ret = $dom.isElement(subject) ? $dom.getElements(subject) : subject

return { Yielded: ret }
},
Expand Down