Skip to content

Commit 2f8c049

Browse files
committed
feat(watcher): Debounce autoWatchBatchDelay
- renamed batchInterval to autoWatchBatchDelay to aid in greppability. - Modified debouncing tests to wait on promises. - Added documentation explaining how list.removeFile is different from list.addFile and list.changeFile. Closes #2331
1 parent 2a847c2 commit 2f8c049

File tree

3 files changed

+179
-29
lines changed

3 files changed

+179
-29
lines changed

docs/config/01-configuration-file.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ These are all of the available configuration options.
8989

9090
**Description:** When Karma is watching the files for changes, it tries to batch
9191
multiple changes into a single run so that the test runner doesn't try to start and restart running
92-
tests more than it should. The configuration setting tells Karma how long to wait (in milliseconds) after any changes
93-
have occurred before starting the test process again.
92+
tests more than it should, or restart while build files are not in a consistent state. The configuration setting
93+
tells Karma how long to wait (in milliseconds) from the last file change before starting
94+
the test process again, resetting the timer each time a file changes (i.e. [debouncing](https://davidwalsh.name/javascript-debounce-function)).
9495

9596

9697
## basePath

lib/file-list.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ function byPath (a, b) {
4949
// emitter - EventEmitter
5050
// preprocess - Function
5151
// batchInterval - Number
52-
var List = function (patterns, excludes, emitter, preprocess, batchInterval) {
52+
var List = function (patterns, excludes, emitter, preprocess, autoWatchBatchDelay) {
5353
// Store options
5454
this._patterns = patterns
5555
this._excludes = excludes
5656
this._emitter = emitter
5757
this._preprocess = Promise.promisify(preprocess)
58-
this._batchInterval = batchInterval
58+
this._autoWatchBatchDelay = autoWatchBatchDelay
5959

6060
// The actual list of files
6161
this.buckets = new Map()
@@ -71,14 +71,14 @@ var List = function (patterns, excludes, emitter, preprocess, batchInterval) {
7171
var self = this
7272

7373
// Emit the `file_list_modified` event.
74-
// This function is throttled to the value of `batchInterval`
75-
// to avoid spamming the listener.
74+
// This function is debounced to the value of `autoWatchBatchDelay`
75+
// to avoid reloading while files are still being modified.
7676
function emit () {
7777
self._emitter.emit('file_list_modified', self.files)
7878
}
79-
var throttledEmit = _.throttle(emit, self._batchInterval, {leading: false})
79+
var debouncedEmit = _.debounce(emit, self._autoWatchBatchDelay)
8080
self._emitModified = function (immediate) {
81-
immediate ? emit() : throttledEmit()
81+
immediate ? emit() : debouncedEmit()
8282
}
8383
}
8484

test/unit/file-list.spec.js

Lines changed: 170 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,8 @@ describe('FileList', () => {
422422
})
423423

424424
describe('addFile', () => {
425+
var clock = null
426+
425427
beforeEach(() => {
426428
patternList = PATTERN_LIST
427429
mg = MG
@@ -435,14 +437,25 @@ describe('FileList', () => {
435437
statCache: mg.statCache
436438
})
437439
}
440+
441+
clock = sinon.useFakeTimers()
442+
// This hack is needed to ensure lodash is using the fake timers
443+
// from sinon
438444
List = proxyquire('../../lib/file-list', {
445+
lodash: _.runInContext(),
439446
helper: helper,
440447
glob: glob,
448+
'graceful-fs': mockFs,
441449
path: pathLib.posix || pathLib/* for node 0.10 */,
442-
'graceful-fs': mockFs
450+
bluebird: Promise
443451
})
444452

445-
list = new List(patterns('/some/*.js', '*.txt'), ['/secret/*.txt'], emitter, preprocess)
453+
list = new List(patterns('/some/*.js', '*.txt'), ['/secret/*.txt'], emitter, preprocess, 100)
454+
})
455+
456+
afterEach(() => {
457+
clock.restore()
458+
Promise.setScheduler((fn) => process.nextTick(fn))
446459
})
447460

448461
it('does not add excluded files', () => {
@@ -487,6 +500,7 @@ describe('FileList', () => {
487500
modified.reset()
488501

489502
return list.addFile('/some/d.js').then(() => {
503+
clock.tick(101)
490504
expect(modified).to.have.been.calledOnce
491505
})
492506
})
@@ -534,9 +548,12 @@ describe('FileList', () => {
534548
})
535549

536550
describe('changeFile', () => {
551+
var clock = null
552+
537553
beforeEach(() => {
538554
patternList = PATTERN_LIST
539555
mg = MG
556+
Promise.setScheduler((fn) => fn())
540557

541558
preprocess = sinon.spy((file, done) => process.nextTick(done))
542559
emitter = new EventEmitter()
@@ -548,20 +565,30 @@ describe('FileList', () => {
548565
})
549566
}
550567

568+
clock = sinon.useFakeTimers()
569+
// This hack is needed to ensure lodash is using the fake timers
570+
// from sinon
551571
List = proxyquire('../../lib/file-list', {
572+
lodash: _.runInContext(),
552573
helper: helper,
553574
glob: glob,
575+
'graceful-fs': mockFs,
554576
path: pathLib.posix || pathLib/* for node 0.10 */,
555-
'graceful-fs': mockFs
577+
bluebird: Promise
556578
})
557579

558580
mockFs._touchFile('/some/a.js', '2012-04-04')
559581
mockFs._touchFile('/some/b.js', '2012-05-05')
560582
})
561583

584+
afterEach(() => {
585+
clock.restore()
586+
Promise.setScheduler((fn) => process.nextTick(fn))
587+
})
588+
562589
it('updates mtime and fires "file_list_modified"', () => {
563590
// MATCH: /some/a.js, /some/b.js
564-
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess)
591+
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess, 100)
565592
var modified = sinon.stub()
566593
emitter.on('file_list_modified', modified)
567594

@@ -570,6 +597,7 @@ describe('FileList', () => {
570597
modified.reset()
571598

572599
return list.changeFile('/some/b.js').then((files) => {
600+
clock.tick(101)
573601
expect(modified).to.have.been.calledOnce
574602
expect(findFile('/some/b.js', files.served).mtime).to.be.eql(new Date('2020-01-01'))
575603
})
@@ -627,9 +655,12 @@ describe('FileList', () => {
627655
})
628656

629657
describe('removeFile', () => {
658+
var clock = null
659+
630660
beforeEach(() => {
631661
patternList = PATTERN_LIST
632662
mg = MG
663+
Promise.setScheduler((fn) => fn())
633664

634665
preprocess = sinon.spy((file, done) => process.nextTick(done))
635666
emitter = new EventEmitter()
@@ -641,20 +672,30 @@ describe('FileList', () => {
641672
})
642673
}
643674

675+
clock = sinon.useFakeTimers()
676+
// This hack is needed to ensure lodash is using the fake timers
677+
// from sinon
644678
List = proxyquire('../../lib/file-list', {
679+
lodash: _.runInContext(),
645680
helper: helper,
646681
glob: glob,
682+
'graceful-fs': mockFs,
647683
path: pathLib.posix || pathLib/* for node 0.10 */,
648-
'graceful-fs': mockFs
684+
bluebird: Promise
649685
})
650686

651687
modified = sinon.stub()
652688
emitter.on('file_list_modified', modified)
653689
})
654690

691+
afterEach(() => {
692+
clock.restore()
693+
Promise.setScheduler((fn) => process.nextTick(fn))
694+
})
695+
655696
it('removes the file from the list and fires "file_list_modified"', () => {
656697
// MATCH: /some/a.js, /some/b.js, /a.txt
657-
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess)
698+
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess, 100)
658699

659700
var modified = sinon.stub()
660701
emitter.on('file_list_modified', modified)
@@ -667,6 +708,7 @@ describe('FileList', () => {
667708
'/some/b.js',
668709
'/a.txt'
669710
])
711+
clock.tick(101)
670712
expect(modified).to.have.been.calledOnce
671713
})
672714
})
@@ -685,6 +727,15 @@ describe('FileList', () => {
685727
})
686728

687729
describe('batch interval', () => {
730+
// IMPORTANT: When writing tests for debouncing behaviour, you must wait for the promise
731+
// returned by list.changeFile or list.addFile. list.removeFile calls self._emitModified()
732+
// in a different manner and doesn't *need* to be waited on. If you use this behaviour
733+
// in your tests it can can lead to very confusing results when they are modified or
734+
// extended.
735+
//
736+
// Rule of thumb: Always wait on the promises returned by list.addFile, list.changeFile,
737+
// and list.removeFile.
738+
688739
var clock = null
689740

690741
beforeEach(() => {
@@ -723,7 +774,97 @@ describe('FileList', () => {
723774
Promise.setScheduler((fn) => process.nextTick(fn))
724775
})
725776

726-
it('batches multiple changes within an interval', () => {
777+
it('debounces calls to emitModified', () => {
778+
list = new List(patterns(), [], emitter, preprocess, 100)
779+
780+
return list.refresh().then(() => {
781+
modified.reset()
782+
list._emitModified()
783+
clock.tick(99)
784+
expect(modified).to.not.have.been.called
785+
list._emitModified()
786+
clock.tick(2)
787+
expect(modified).to.not.have.been.called
788+
clock.tick(97)
789+
expect(modified).to.not.have.been.called
790+
clock.tick(2)
791+
expect(modified).to.have.been.calledOnce
792+
clock.tick(1000)
793+
expect(modified).to.have.been.calledOnce
794+
list._emitModified()
795+
clock.tick(99)
796+
expect(modified).to.have.been.calledOnce
797+
clock.tick(2)
798+
expect(modified).to.have.been.calledTwice
799+
})
800+
})
801+
802+
it('debounces a single file change', () => {
803+
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess, 100)
804+
805+
return list.refresh().then((files) => {
806+
modified.reset()
807+
// Even with no changes, all these files are served
808+
list.addFile('/some/0.js').then(() => {
809+
clock.tick(99)
810+
expect(modified).to.not.have.been.called
811+
812+
clock.tick(2)
813+
expect(modified).to.have.been.calledOnce
814+
815+
files = modified.lastCall.args[0]
816+
expect(pathsFrom(files.served)).to.be.eql([
817+
'/some/0.js',
818+
'/some/a.js',
819+
'/some/b.js',
820+
'/a.txt'
821+
])
822+
})
823+
})
824+
})
825+
826+
it('debounces several changes to a file', () => {
827+
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess, 100)
828+
829+
return list.refresh().then((files) => {
830+
modified.reset()
831+
list.addFile('/some/0.js').then(() => {
832+
clock.tick(99)
833+
expect(modified).to.not.have.been.called
834+
835+
// Modify file, must change mtime too, or change is ignored
836+
mockFs._touchFile('/some/0.js', '2020-01-01')
837+
list.changeFile('/some/0.js').then(() => {
838+
// Ensure that the debounce timer was reset
839+
clock.tick(2)
840+
expect(modified).to.not.have.been.called
841+
842+
// Ensure that debounce timer fires after 100ms
843+
clock.tick(99)
844+
expect(modified).to.have.been.calledOnce
845+
846+
// Make sure there aren't any lingering debounce calls
847+
clock.tick(1000)
848+
849+
// Modify file (one hour later mtime)
850+
expect(modified).to.have.been.calledOnce
851+
mockFs._touchFile('/some/0.js', '2020-01-02')
852+
list.changeFile('/some/0.js').then(() => {
853+
clock.tick(99)
854+
expect(modified).to.have.been.calledOnce
855+
clock.tick(2)
856+
expect(modified).to.have.been.calledTwice
857+
858+
// Make sure there aren't any lingering calls
859+
clock.tick(1000)
860+
expect(modified).to.have.been.calledTwice
861+
})
862+
})
863+
})
864+
})
865+
})
866+
867+
it('debounces multiple changes until there is quiescence', () => {
727868
// MATCH: /some/a.js, /some/b.js, /a.txt
728869
list = new List(patterns('/some/*.js', '/a.*'), [], emitter, preprocess, 100)
729870

@@ -734,20 +875,28 @@ describe('FileList', () => {
734875
list.removeFile('/some/a.js') // /some/b.js, /a.txt
735876
list.removeFile('/a.txt') // /some/b.js
736877
list.addFile('/a.txt') // /some/b.js, /a.txt
737-
list.addFile('/some/0.js') // /some/0.js, /some/b.js, /a.txt
738-
739-
clock.tick(99)
740-
expect(modified).to.not.have.been.called
741-
742-
clock.tick(2)
743-
expect(modified).to.have.been.calledOnce
744-
745-
files = modified.lastCall.args[0]
746-
expect(pathsFrom(files.served)).to.be.eql([
747-
'/some/0.js',
748-
'/some/b.js',
749-
'/a.txt'
750-
])
878+
list.addFile('/some/0.js').then(() => { // /some/0.js, /some/b.js, /a.txt
879+
clock.tick(99)
880+
expect(modified).to.not.have.been.called
881+
mockFs._touchFile('/a.txt', '2020-01-01')
882+
list.changeFile('/a.txt').then(() => {
883+
clock.tick(2)
884+
expect(modified).to.not.have.been.called
885+
886+
clock.tick(100)
887+
expect(modified).to.have.been.calledOnce
888+
889+
clock.tick(1000)
890+
expect(modified).to.have.been.calledOnce
891+
892+
files = modified.lastCall.args[0]
893+
expect(pathsFrom(files.served)).to.be.eql([
894+
'/some/0.js',
895+
'/some/b.js',
896+
'/a.txt'
897+
])
898+
})
899+
})
751900
})
752901
})
753902

0 commit comments

Comments
 (0)