summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/test/xpcshell/test_DeferredSave.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions/test/xpcshell/test_DeferredSave.js')
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_DeferredSave.js550
1 files changed, 550 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_DeferredSave.js b/toolkit/mozapps/extensions/test/xpcshell/test_DeferredSave.js
new file mode 100644
index 000000000..7599c8b80
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_DeferredSave.js
@@ -0,0 +1,550 @@
+/* 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");
+
+let DSContext = Components.utils.import("resource://gre/modules/DeferredSave.jsm", {});
+let 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");
+ deferred.reject(new Error("Write during supposedly 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";
+let 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);
+});