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

fix: Updating spec watcher to detect directory events #20962

Merged
merged 12 commits into from
Apr 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions packages/app/cypress/e2e/specs.cy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json'
import type { SinonStub } from 'sinon'
import { getPathForPlatform } from '../../src/paths'
import { getRunnerHref } from './support/get-runner-href'

Expand Down Expand Up @@ -827,4 +828,145 @@ describe('App: Index', () => {
})
})
})

describe('Spec Watcher', () => {
beforeEach(() => {
cy.scaffoldProject('no-specs')
cy.openProject('no-specs')
cy.startAppServer('e2e')
cy.visitApp()

cy.findByRole('heading', {
level: 1,
name: defaultMessages.createSpec.page.defaultPatternNoSpecs.title,
}).should('be.visible')
})

it('updates spec list when files are added to/removed from areas matching specPattern', () => {
cy.withCtx(async (ctx) => {
// Directory is added to root so it does not match specPattern
await ctx.actions.file.writeFileInProject('test-1.cy.js', 'it()')
})

// No Specs Found page renders, as the added dir does not match the specPattern
cy.findByRole('heading', {
level: 1,
name: defaultMessages.createSpec.page.defaultPatternNoSpecs.title,
}).should('be.visible')

cy.withCtx(async (ctx) => {
// Directory contents are moved into cypress/e2e dir
await ctx.actions.file.moveFileInProject('test-1.cy.js', 'cypress/e2e/test-1.cy.js')
})

// Specs list should now show, with the spec from the moved dir now matching the specPattern
cy.contains('[data-cy="spec-item"]', 'test-1.cy.js')

cy.withCtx(async (ctx) => {
// Writing more specs to directory
await ctx.actions.file.writeFileInProject('cypress/e2e/test-2.cy.js', 'it()')
await ctx.actions.file.writeFileInProject('cypress/e2e/test-3.cy.js', 'it()')
})

// Specs list should show all added specs
cy.contains('[data-cy="spec-item"]', 'test-1.cy.js')
cy.contains('[data-cy="spec-item"]', 'test-2.cy.js')
cy.contains('[data-cy="spec-item"]', 'test-3.cy.js')

cy.withCtx(async (ctx) => {
// Files are moved back to root, where they no will no longer match specPattern
await ctx.actions.file.moveFileInProject('cypress/e2e/test-1.cy.js', 'test-1.cy.js')
await ctx.actions.file.moveFileInProject('cypress/e2e/test-2.cy.js', 'test-2.cy.js')
await ctx.actions.file.moveFileInProject('cypress/e2e/test-3.cy.js', 'test-3.cy.js')
})

// No Specs Found page now renders, as all previously matching specs were moved
cy.findByRole('heading', {
level: 1,
name: defaultMessages.createSpec.page.defaultPatternNoSpecs.title,
}).should('be.visible')
})

it('updates spec list when directories are added to/removed from areas matching specPattern', () => {
cy.withCtx(async (ctx) => {
// Directory is added to root so it does not match specPattern
await ctx.actions.file.writeFileInProject('testDir/test-1.cy.js', 'it()')
})

// No Specs Found page renders, as the added dir does not match the specPattern
cy.findByRole('heading', {
level: 1,
name: defaultMessages.createSpec.page.defaultPatternNoSpecs.title,
}).should('be.visible')

cy.withCtx(async (ctx) => {
// Directory contents are moved into cypress/e2e dir
await ctx.actions.file.moveFileInProject('testDir', 'cypress/e2e/testDir')
})

// Specs list should now show, with the spec from the moved dir now matching the specPattern
cy.contains('[data-cy="spec-item"]', 'test-1.cy.js')

cy.withCtx(async (ctx) => {
// Writing more specs to directory
await ctx.actions.file.writeFileInProject('cypress/e2e/testDir/test-2.cy.js', 'it()')
await ctx.actions.file.writeFileInProject('cypress/e2e/testDir/test-3.cy.js', 'it()')
})

// Specs list should show all added specs
cy.contains('[data-cy="spec-item"]', 'test-1.cy.js')
cy.contains('[data-cy="spec-item"]', 'test-2.cy.js')
cy.contains('[data-cy="spec-item"]', 'test-3.cy.js')

cy.withCtx(async (ctx) => {
// Directory is moved back to root, where it no will no longer match specPattern
await ctx.actions.file.moveFileInProject('cypress/e2e/testDir', 'testDir')
})

// No Specs Found page now renders, as all previously matching specs were moved
cy.findByRole('heading', {
level: 1,
name: defaultMessages.createSpec.page.defaultPatternNoSpecs.title,
}).should('be.visible')
})

it('debounces spec updates if many additions occur', () => {
const specs = [...Array(20)].map((v, index) => {
return `test-${index}.cy.js`
})

cy.withCtx(async (ctx, o) => {
o.sinon.spy(ctx.actions.project, 'setSpecs')
for (const spec of o.specs) {
await ctx.actions.file.writeFileInProject(`cypress/e2e/${spec}`, 'it()')
}
}, { specs })

cy.contains('20 Matches')

cy.withRetryableCtx((ctx, o) => {
// setSpecs is debounced, the number of calls should be less than the number of files removed
// in such rapid succession.
expect((ctx.actions.project.setSpecs as SinonStub).callCount).to.be.lessThan(20)
})

cy.withCtx(async (ctx, o) => {
(ctx.actions.project.setSpecs as SinonStub).resetHistory()
for (const spec of o.specs) {
await ctx.actions.file.removeFileInProject(`cypress/e2e/${spec}`)
}
}, { specs })

cy.findByRole('heading', {
level: 1,
name: defaultMessages.createSpec.page.defaultPatternNoSpecs.title,
}).should('be.visible')

cy.withRetryableCtx((ctx) => {
// setSpecs is debounced, the number of calls should be less than the number of files removed
// in such rapid succession.
expect((ctx.actions.project.setSpecs as SinonStub).callCount).to.be.lessThan(20)
})
})
})
})
23 changes: 17 additions & 6 deletions packages/data-context/src/sources/ProjectDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,20 +252,31 @@ export class ProjectDataSource {
throw new Error('Cannot start spec watcher without current project')
}

const onSpecsChanged = debounce(async () => {
// When file system changes are detected, we retrieve any spec files matching
// the determined specPattern. This function is debounced to limit execution
// during sequential file operations.
const onProjectFileSystemChange = debounce(async () => {
const specs = await this.findSpecs(projectRoot, testingType, specPattern, excludeSpecPattern, additionalIgnore)

if (isEqual(this.specs, specs)) {
// If no differences are found, we do not need to emit events
return
}

this.ctx.actions.project.setSpecs(specs)
})
}, 250)

this._specWatcher = chokidar.watch(specPattern, {
// We respond to all changes to the project's filesystem when
// files or directories are added and removed that are not explicitly
// ignored by config
this._specWatcher = chokidar.watch('.', {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In 9.x we had integration/component folders defined in config to better scope what we're watching, but no longer. I still worry that this may be too broad/expensive, but debouncing the handler will limit the amount of thrash that can result. We could add more explicit ignored dirs (node_modules)?

Copy link
Member

Choose a reason for hiding this comment

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

So are we saying that providing the specPattern here definitely does not work properly?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From my testing, yes, it does not work correctly when files are introduced with or removed alongside parent directories. No 'add'/'unlink' events for those files get emitted in that case. This could very well be a bug within chokidar, I need to dig into its implementation more fully to figure out why it's behaving this way.

Copy link
Member

Choose a reason for hiding this comment

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

Interesting, it looks like chokidar v4 is dropping globbing entirely: paulmillr/chokidar#1195, so this might be the best approach, to watch the directory and handle matching on our side.

ignoreInitial: true,
cwd: projectRoot,
ignored: ['**/node_modules/**', ...excludeSpecPattern, ...additionalIgnore],
})

this._specWatcher.on('add', onSpecsChanged)
this._specWatcher.on('change', onSpecsChanged)
this._specWatcher.on('unlink', onSpecsChanged)
// the 'all' event includes: add, addDir, change, unlink, unlinkDir
this._specWatcher.on('all', onProjectFileSystemChange)
}

async defaultSpecFileName () {
Expand Down
128 changes: 126 additions & 2 deletions packages/data-context/test/unit/sources/ProjectDataSource.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { expect } from 'chai'
import chai from 'chai'
import { matchedSpecs, transformSpec, SpecWithRelativeRoot, BrowserApiShape, getDefaultSpecFileName } from '../../../src/sources'
import path from 'path'
import { DataContext } from '../../../src'
import sinon from 'sinon'
import chokidar from 'chokidar'
import _ from 'lodash'
import sinonChai from 'sinon-chai'
import { graphqlSchema } from '@packages/graphql/src/schema'
import { FoundSpec, TestingType } from '@packages/types'
import { DataContext } from '../../../src'
import { AppApiShape, AuthApiShape, ElectronApiShape, LocalSettingsApiShape, ProjectApiShape } from '../../../src/actions'
import { InjectedConfigApi } from '../../../src/data'

chai.use(sinonChai)
const { expect } = chai

describe('matchedSpecs', () => {
context('got a single spec pattern from --spec via cli', () => {
it('returns spec name only', () => {
Expand Down Expand Up @@ -336,3 +344,119 @@ describe('getDefaultSpecFileName', () => {
})
})
})

describe('startSpecWatcher', () => {
const temporary = 'tmp'

let ctx: DataContext

beforeEach(async () => {
ctx = new DataContext({
schema: graphqlSchema,
mode: 'run',
modeOptions: {},
appApi: {} as AppApiShape,
localSettingsApi: {} as LocalSettingsApiShape,
authApi: {} as AuthApiShape,
configApi: {} as InjectedConfigApi,
projectApi: {} as ProjectApiShape,
electronApi: {} as ElectronApiShape,
browserApi: {} as BrowserApiShape,
})

ctx.coreData.currentProject = temporary
})

afterEach(async () => {
sinon.restore()
})

it('throws if no current project defined', () => {
ctx.coreData.currentProject = null

expect(() => ctx.project.startSpecWatcher(temporary, 'e2e', ['**/*.{cy,spec}.{ts,js}'], ['**/ignore.spec.ts'], [])).to.throw()
})

it('creates file watcher based on given config properties', () => {
const onStub = sinon.stub()

sinon.stub(chokidar, 'watch').callsFake(() => {
const mockWatcher = {
on: onStub,
close: () => ({ catch: () => {} }),
} as unknown

return mockWatcher as chokidar.FSWatcher
})

let handleFsChange

sinon.stub(_, 'debounce').callsFake((funcToDebounce) => {
handleFsChange = (() => funcToDebounce())

return handleFsChange as _.DebouncedFunc<any>
})

ctx.project.startSpecWatcher(temporary, 'e2e', ['**/*.{cy,spec}.{ts,js}'], ['**/ignore.spec.ts'], ['additional.ignore.cy.js'])

expect(_.debounce).to.have.been.calledWith(sinon.match.func, 250)

expect(chokidar.watch).to.have.been.calledWith('.', {
ignoreInitial: true,
cwd: temporary,
ignored: ['**/node_modules/**', '**/ignore.spec.ts', 'additional.ignore.cy.js'],
})

expect(onStub).to.have.been.calledWith('all', handleFsChange)
})

it('implements change handler with duplicate result handling', async () => {
const mockFoundSpecs = [
{ name: 'test-1.cy.js' },
{ name: 'test-2.cy.js' },
{ name: 'test-3.cy.js' },
] as FoundSpec[]

sinon.stub(ctx.project, 'findSpecs').resolves(mockFoundSpecs)
sinon.stub(ctx.actions.project, 'setSpecs')

sinon.stub(chokidar, 'watch').callsFake(() => {
const mockWatcher = {
on: () => {},
close: () => ({ catch: () => {} }),
} as unknown

return mockWatcher as chokidar.FSWatcher
})

let handleFsChange

sinon.stub(_, 'debounce').callsFake((funcToDebounce) => {
handleFsChange = (() => funcToDebounce())

return handleFsChange as _.DebouncedFunc<any>
})

const watchOptions: [string, TestingType, string[], string[], string[]] = [temporary, 'e2e', ['**/*.{cy,spec}.{ts,js}'], ['**/ignore.spec.ts'], ['additional.ignore.cy.js']]

ctx.project.startSpecWatcher(...watchOptions)

// Set internal specs state to the stubbed found value to simulate irrelevant FS changes
ctx.project.setSpecs(mockFoundSpecs)

await handleFsChange()

expect(ctx.project.findSpecs).to.have.been.calledWith(...watchOptions)
expect(ctx.actions.project.setSpecs).not.to.have.been.called

// Update internal specs state so that a change will be detected on next FS event
const updatedSpecs = [...mockFoundSpecs, { name: 'test-4.cy.js' }] as FoundSpec[]

ctx.project.setSpecs(updatedSpecs)

await handleFsChange()

expect(ctx.project.findSpecs).to.have.been.calledWith(...watchOptions)
expect(ctx.actions.project.setSpecs).to.have.been.calledWith(mockFoundSpecs)
})
})