diff options
Diffstat (limited to 'toolkit/mozapps/webextensions/test/xpcshell/test_DeferredSave.js')
-rw-r--r-- | toolkit/mozapps/webextensions/test/xpcshell/test_DeferredSave.js | 549 |
1 files changed, 549 insertions, 0 deletions
diff --git a/toolkit/mozapps/webextensions/test/xpcshell/test_DeferredSave.js b/toolkit/mozapps/webextensions/test/xpcshell/test_DeferredSave.js new file mode 100644 index 000000000..2a6ff291e --- /dev/null +++ b/toolkit/mozapps/webextensions/test/xpcshell/test_DeferredSave.js @@ -0,0 +1,549 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +// Test behaviour of module to perform deferred save of data +// files to disk + +"use strict"; + +const testFile = gProfD.clone(); +testFile.append("DeferredSaveTest"); + +Components.utils.import("resource://gre/modules/Promise.jsm"); + +var DSContext = Components.utils.import("resource://gre/modules/DeferredSave.jsm", {}); +var DeferredSave = DSContext.DeferredSave; + +// Test wrapper to let us do promise/task based testing of DeferredSave +function DeferredSaveTester(aDataProvider) { + let tester = { + // Deferred for the promise returned by the mock writeAtomic + waDeferred: null, + + // The most recent data "written" by the mock OS.File.writeAtomic + writtenData: undefined, + + dataToSave: "Data to save", + + save: (aData, aWriteHandler) => { + tester.writeHandler = aWriteHandler || writer; + tester.dataToSave = aData; + return tester.saver.saveChanges(); + }, + + flush: (aWriteHandler) => { + tester.writeHandler = aWriteHandler || writer; + return tester.saver.flush(); + }, + + get lastError() { + return tester.saver.lastError; + } + }; + + // Default write handler for most cases where the test case doesn't need + // to do anything while the write is in progress; just completes the write + // on the next event loop + function writer(aTester) { + do_print("default write callback"); + let length = aTester.writtenData.length; + do_execute_soon(() => aTester.waDeferred.resolve(length)); + } + + if (!aDataProvider) + aDataProvider = () => tester.dataToSave; + + tester.saver = new DeferredSave(testFile.path, aDataProvider); + + // Install a mock for OS.File.writeAtomic to let us control the async + // behaviour of the promise + DSContext.OS.File.writeAtomic = function mock_writeAtomic(aFile, aData, aOptions) { + do_print("writeAtomic: " + aFile + " data: '" + aData + "', " + aOptions.toSource()); + tester.writtenData = aData; + tester.waDeferred = Promise.defer(); + tester.writeHandler(tester); + return tester.waDeferred.promise; + }; + + return tester; +} + +/** + * Install a mock nsITimer factory that triggers on the next spin of + * the event loop after it is scheduled + */ +function setQuickMockTimer() { + let quickTimer = { + initWithCallback: function(aFunction, aDelay, aType) { + do_print("Starting quick timer, delay = " + aDelay); + do_execute_soon(aFunction); + }, + cancel: function() { + do_throw("Attempted to cancel a quickMockTimer"); + } + }; + DSContext.MakeTimer = () => { + do_print("Creating quick timer"); + return quickTimer; + }; +} + +/** + * Install a mock nsITimer factory in DeferredSave.jsm, returning a promise that resolves + * when the client code sets the timer. Test cases can use this to wait for client code to + * be ready for a timer event, and then signal the event by calling mockTimer.callback(). + * This could use some enhancement; clients can re-use the returned timer, + * but with this implementation it's not possible for the test to wait for + * a second call to initWithCallback() on the re-used timer. + * @return Promise{mockTimer} that resolves when initWithCallback() + * is called + */ +function setPromiseMockTimer() { + let waiter = Promise.defer(); + let mockTimer = { + callback: null, + delay: null, + type: null, + isCancelled: false, + + initWithCallback: function(aFunction, aDelay, aType) { + do_print("Starting timer, delay = " + aDelay); + this.callback = aFunction; + this.delay = aDelay; + this.type = aType; + // cancelled timers can be re-used + this.isCancelled = false; + waiter.resolve(this); + }, + cancel: function() { + do_print("Cancelled mock timer"); + this.callback = null; + this.delay = null; + this.type = null; + this.isCancelled = true; + // If initWithCallback was never called, resolve to let tests check for cancel + waiter.resolve(this); + } + }; + DSContext.MakeTimer = () => { + do_print("Creating mock timer"); + return mockTimer; + }; + return waiter.promise; +} + +/** + * Return a Promise<null> that resolves after the specified number of milliseconds + */ +function delay(aDelayMS) { + let deferred = Promise.defer(); + do_timeout(aDelayMS, () => deferred.resolve(null)); + return deferred.promise; +} + +function run_test() { + run_next_test(); +} + +// Modify set data once, ask for save, make sure it saves cleanly +add_task(function* test_basic_save_succeeds() { + setQuickMockTimer(); + let tester = DeferredSaveTester(); + let data = "Test 1 Data"; + + yield tester.save(data); + do_check_eq(tester.writtenData, data); + do_check_eq(1, tester.saver.totalSaves); +}); + +// Two saves called during the same event loop, both with callbacks +// Make sure we save only the second version of the data +add_task(function* test_two_saves() { + setQuickMockTimer(); + let tester = DeferredSaveTester(); + let firstCallback_happened = false; + let firstData = "Test first save"; + let secondData = "Test second save"; + + // first save should not resolve until after the second one is called, + // so we can't just yield this promise + tester.save(firstData).then(count => { + do_check_eq(secondData, tester.writtenData); + do_check_false(firstCallback_happened); + firstCallback_happened = true; + }, do_report_unexpected_exception); + + yield tester.save(secondData); + do_check_true(firstCallback_happened); + do_check_eq(secondData, tester.writtenData); + do_check_eq(1, tester.saver.totalSaves); +}); + +// Two saves called with a delay in between, both with callbacks +// Make sure we save the second version of the data +add_task(function* test_two_saves_delay() { + let timerPromise = setPromiseMockTimer(); + let tester = DeferredSaveTester(); + let firstCallback_happened = false; + let delayDone = false; + + let firstData = "First data to save with delay"; + let secondData = "Modified data to save with delay"; + + tester.save(firstData).then(count => { + do_check_false(firstCallback_happened); + do_check_true(delayDone); + do_check_eq(secondData, tester.writtenData); + firstCallback_happened = true; + }, do_report_unexpected_exception); + + // Wait a short time to let async events possibly spawned by the + // first tester.save() to run + yield delay(2); + delayDone = true; + // request to save modified data + let saving = tester.save(secondData); + // Yield to wait for client code to set the timer + let activeTimer = yield timerPromise; + // and then trigger it + activeTimer.callback(); + // now wait for the DeferredSave to finish saving + yield saving; + do_check_true(firstCallback_happened); + do_check_eq(secondData, tester.writtenData); + do_check_eq(1, tester.saver.totalSaves); + do_check_eq(0, tester.saver.overlappedSaves); +}); + +// Test case where OS.File immediately reports an error when the write begins +// Also check that the "error" getter correctly returns the error +// Then do a write that succeeds, and make sure the error is cleared +add_task(function* test_error_immediate() { + let tester = DeferredSaveTester(); + let testError = new Error("Forced failure"); + function writeFail(aTester) { + aTester.waDeferred.reject(testError); + } + + setQuickMockTimer(); + yield tester.save("test_error_immediate", writeFail).then( + count => do_throw("Did not get expected error"), + error => do_check_eq(testError.message, error.message) + ); + do_check_eq(testError, tester.lastError); + + // This write should succeed and clear the error + yield tester.save("test_error_immediate succeeds"); + do_check_eq(null, tester.lastError); + // The failed save attempt counts in our total + do_check_eq(2, tester.saver.totalSaves); +}); + +// Save one set of changes, then while the write is in progress, modify the +// data two more times. Test that we re-write the dirty data exactly once +// after the first write succeeds +add_task(function* dirty_while_writing() { + let tester = DeferredSaveTester(); + let firstData = "First data"; + let secondData = "Second data"; + let thirdData = "Third data"; + let firstCallback_happened = false; + let secondCallback_happened = false; + let writeStarted = Promise.defer(); + + function writeCallback(aTester) { + writeStarted.resolve(aTester.waDeferred); + } + + setQuickMockTimer(); + do_print("First save"); + tester.save(firstData, writeCallback).then( + count => { + do_check_false(firstCallback_happened); + do_check_false(secondCallback_happened); + do_check_eq(tester.writtenData, firstData); + firstCallback_happened = true; + }, do_report_unexpected_exception); + + do_print("waiting for writer"); + let writer = yield writeStarted.promise; + do_print("Write started"); + + // Delay a bit, modify the data and call saveChanges, delay a bit more, + // modify the data and call saveChanges again, another delay, + // then complete the in-progress write + yield delay(1); + + tester.save(secondData).then( + count => { + do_check_true(firstCallback_happened); + do_check_false(secondCallback_happened); + do_check_eq(tester.writtenData, thirdData); + secondCallback_happened = true; + }, do_report_unexpected_exception); + + // wait and then do the third change + yield delay(1); + let thirdWrite = tester.save(thirdData); + + // wait a bit more and then finally finish the first write + yield delay(1); + writer.resolve(firstData.length); + + // Now let everything else finish + yield thirdWrite; + do_check_true(firstCallback_happened); + do_check_true(secondCallback_happened); + do_check_eq(tester.writtenData, thirdData); + do_check_eq(2, tester.saver.totalSaves); + do_check_eq(1, tester.saver.overlappedSaves); +}); + +// A write callback for the OS.File.writeAtomic mock that rejects write attempts +function disabled_write_callback(aTester) { + do_throw("Should not have written during clean flush"); +} + +// special write callback that disables itself to make sure +// we don't try to write twice +function write_then_disable(aTester) { + do_print("write_then_disable"); + let length = aTester.writtenData.length; + aTester.writeHandler = disabled_write_callback; + do_execute_soon(() => aTester.waDeferred.resolve(length)); +} + +// Flush tests. First, do an ordinary clean save and then call flush; +// there should not be another save +add_task(function* flush_after_save() { + setQuickMockTimer(); + let tester = DeferredSaveTester(); + let dataToSave = "Flush after save"; + + yield tester.save(dataToSave); + yield tester.flush(disabled_write_callback); + do_check_eq(1, tester.saver.totalSaves); +}); + +// Flush while a write is in progress, but the in-memory data is clean +add_task(function* flush_during_write() { + let tester = DeferredSaveTester(); + let dataToSave = "Flush during write"; + let firstCallback_happened = false; + let writeStarted = Promise.defer(); + + function writeCallback(aTester) { + writeStarted.resolve(aTester.waDeferred); + } + + setQuickMockTimer(); + tester.save(dataToSave, writeCallback).then( + count => { + do_check_false(firstCallback_happened); + firstCallback_happened = true; + }, do_report_unexpected_exception); + + let writer = yield writeStarted.promise; + + // call flush with the write callback disabled, delay a bit more, complete in-progress write + let flushing = tester.flush(disabled_write_callback); + yield delay(2); + writer.resolve(dataToSave.length); + + // now wait for the flush to finish + yield flushing; + do_check_true(firstCallback_happened); + do_check_eq(1, tester.saver.totalSaves); +}); + +// Flush while dirty but write not in progress +// The data written should be the value at the time +// flush() is called, even if it is changed later +add_task(function* flush_while_dirty() { + let timerPromise = setPromiseMockTimer(); + let tester = DeferredSaveTester(); + let firstData = "Flush while dirty, valid data"; + let firstCallback_happened = false; + + tester.save(firstData, write_then_disable).then( + count => { + do_check_false(firstCallback_happened); + firstCallback_happened = true; + do_check_eq(tester.writtenData, firstData); + }, do_report_unexpected_exception); + + // Wait for the timer to be set, but don't trigger it so the write won't start + let activeTimer = yield timerPromise; + + let flushing = tester.flush(); + + // Make sure the timer was cancelled + do_check_true(activeTimer.isCancelled); + + // Also make sure that data changed after the flush call + // (even without a saveChanges() call) doesn't get written + tester.dataToSave = "Flush while dirty, invalid data"; + + yield flushing; + do_check_true(firstCallback_happened); + do_check_eq(tester.writtenData, firstData); + do_check_eq(1, tester.saver.totalSaves); +}); + +// And the grand finale - modify the data, start writing, +// modify the data again so we're in progress and dirty, +// then flush, then modify the data again +// Data for the second write should be taken at the time +// flush() is called, even if it is modified later +add_task(function* flush_writing_dirty() { + let timerPromise = setPromiseMockTimer(); + let tester = DeferredSaveTester(); + let firstData = "Flush first pass data"; + let secondData = "Flush second pass data"; + let firstCallback_happened = false; + let secondCallback_happened = false; + let writeStarted = Promise.defer(); + + function writeCallback(aTester) { + writeStarted.resolve(aTester.waDeferred); + } + + tester.save(firstData, writeCallback).then( + count => { + do_check_false(firstCallback_happened); + do_check_eq(tester.writtenData, firstData); + firstCallback_happened = true; + }, do_report_unexpected_exception); + + // Trigger the timer callback as soon as the DeferredSave sets it + let activeTimer = yield timerPromise; + activeTimer.callback(); + let writer = yield writeStarted.promise; + // the first write has started + + // dirty the data and request another save + // after the second save completes, there should not be another write + tester.save(secondData, write_then_disable).then( + count => { + do_check_true(firstCallback_happened); + do_check_false(secondCallback_happened); + do_check_eq(tester.writtenData, secondData); + secondCallback_happened = true; + }, do_report_unexpected_exception); + + let flushing = tester.flush(write_then_disable); + // Flush should have cancelled our timer + do_check_true(activeTimer.isCancelled); + tester.dataToSave = "Flush, invalid data: changed late"; + // complete the first write + writer.resolve(firstData.length); + // now wait for the second write / flush to complete + yield flushing; + do_check_true(firstCallback_happened); + do_check_true(secondCallback_happened); + do_check_eq(tester.writtenData, secondData); + do_check_eq(2, tester.saver.totalSaves); + do_check_eq(1, tester.saver.overlappedSaves); +}); + +// A data provider callback that throws an error the first +// time it is called, and a different error the second time +// so that tests can (a) make sure the promise is rejected +// with the error and (b) make sure the provider is only +// called once in case of error +const expectedDataError = "Failed to serialize data"; +var badDataError = null; +function badDataProvider() { + let err = new Error(badDataError); + badDataError = "badDataProvider called twice"; + throw err; +} + +// Handle cases where data provider throws +// First, throws during a normal save +add_task(function* data_throw() { + setQuickMockTimer(); + badDataError = expectedDataError; + let tester = DeferredSaveTester(badDataProvider); + yield tester.save("data_throw").then( + count => do_throw("Expected serialization failure"), + error => do_check_eq(error.message, expectedDataError)); +}); + +// Now, throws during flush +add_task(function* data_throw_during_flush() { + badDataError = expectedDataError; + let tester = DeferredSaveTester(badDataProvider); + let firstCallback_happened = false; + + setPromiseMockTimer(); + // Write callback should never be called + tester.save("data_throw_during_flush", disabled_write_callback).then( + count => do_throw("Expected serialization failure"), + error => { + do_check_false(firstCallback_happened); + do_check_eq(error.message, expectedDataError); + firstCallback_happened = true; + }); + + // flush() will cancel the timer + yield tester.flush(disabled_write_callback).then( + count => do_throw("Expected serialization failure"), + error => do_check_eq(error.message, expectedDataError) + ); + + do_check_true(firstCallback_happened); +}); + +// Try to reproduce race condition. The observed sequence of events: +// saveChanges +// start writing +// saveChanges +// finish writing (need to restart delayed timer) +// saveChanges +// flush +// write starts +// actually restart timer for delayed write +// write completes +// delayed timer goes off, throws error because DeferredSave has been torn down +add_task(function* delay_flush_race() { + let timerPromise = setPromiseMockTimer(); + let tester = DeferredSaveTester(); + let firstData = "First save"; + let secondData = "Second save"; + let thirdData = "Third save"; + let writeStarted = Promise.defer(); + + function writeCallback(aTester) { + writeStarted.resolve(aTester.waDeferred); + } + + // This promise won't resolve until after writeStarted + let firstSave = tester.save(firstData, writeCallback); + (yield timerPromise).callback(); + + let writer = yield writeStarted.promise; + // the first write has started + + // dirty the data and request another save + let secondSave = tester.save(secondData); + + // complete the first write + writer.resolve(firstData.length); + yield firstSave; + do_check_eq(tester.writtenData, firstData); + + tester.save(thirdData); + let flushing = tester.flush(); + + yield secondSave; + do_check_eq(tester.writtenData, thirdData); + + yield flushing; + do_check_eq(tester.writtenData, thirdData); + + // Our DeferredSave should not have a _timer here; if it + // does, the bug caused a reschedule + do_check_eq(null, tester.saver._timer); +}); |