Bug 871445 - patch 3 - DataStore: getChanges + revisionID, r=ehsan, sr=mounir, r=bent

This commit is contained in:
Andrea Marchesini 2013-10-02 13:27:11 -04:00
parent 771c5cedf1
commit 214ea52208
6 changed files with 544 additions and 61 deletions

View File

@ -112,9 +112,17 @@ IndexedDBHelper.prototype = {
newTxn: function newTxn(txn_type, store_name, callback, successCb, failureCb) {
this.ensureDB(function () {
if (DEBUG) debug("Starting new transaction" + txn_type);
let txn = this._db.transaction(this.dbStoreNames, txn_type);
let txn = this._db.transaction(Array.isArray(store_name) ? store_name : this.dbStoreNames, txn_type);
if (DEBUG) debug("Retrieving object store", this.dbName);
let store = txn.objectStore(store_name);
let stores;
if (Array.isArray(store_name)) {
stores = [];
for (let i = 0; i < store_name.length; ++i) {
stores.push(txn.objectStore(store_name[i]));
}
} else {
stores = txn.objectStore(store_name);
}
txn.oncomplete = function (event) {
if (DEBUG) debug("Transaction complete. Returning to callback.");
@ -137,7 +145,7 @@ IndexedDBHelper.prototype = {
}
}
};
callback(txn, store);
callback(txn, stores);
}.bind(this), failureCb);
},

View File

@ -14,10 +14,20 @@ function debug(s) {
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
const REVISION_ADDED = "added";
const REVISION_UPDATED = "updated";
const REVISION_REMOVED = "removed";
const REVISION_VOID = "void";
Cu.import("resource://gre/modules/DataStoreDB.jsm");
Cu.import("resource://gre/modules/ObjectWrapper.jsm");
Cu.import('resource://gre/modules/Services.jsm');
/* Helper function */
function createDOMError(aWindow, aEvent) {
return new aWindow.DOMError(aEvent.target.error.name);
}
/* DataStore object */
function DataStore(aAppId, aName, aOwner, aReadOnly, aGlobalScope) {
@ -35,6 +45,7 @@ DataStore.prototype = {
name: null,
owner: null,
readOnly: null,
revisionId: null,
newDBPromise: function(aWindow, aTxnType, aFunction) {
let db = this.db;
@ -42,19 +53,19 @@ DataStore.prototype = {
debug("DBPromise started");
db.txn(
aTxnType,
function(aTxn, aStore) {
function(aTxn, aStore, aRevisionStore) {
debug("DBPromise success");
aFunction(aResolve, aReject, aTxn, aStore);
aFunction(aResolve, aReject, aTxn, aStore, aRevisionStore);
},
function() {
function(aEvent) {
debug("DBPromise error");
aReject(new aWindow.DOMError("InvalidStateError"));
aReject(createDOMError(aWindow, aEvent));
}
);
});
},
getInternal: function(aWindow, aResolve, aReject, aStore, aId) {
getInternal: function(aWindow, aResolve, aStore, aId) {
debug("GetInternal " + aId);
let request = aStore.get(aId);
@ -62,71 +73,127 @@ DataStore.prototype = {
debug("GetInternal success. Record: " + aEvent.target.result);
aResolve(ObjectWrapper.wrap(aEvent.target.result, aWindow));
};
request.onerror = function(aEvent) {
debug("GetInternal error");
aReject(new aWindow.DOMError(aEvent.target.error.name));
};
},
updateInternal: function(aWindow, aResolve, aReject, aStore, aId, aObj) {
updateInternal: function(aResolve, aStore, aRevisionStore, aId, aObj) {
debug("UpdateInternal " + aId);
let self = this;
let request = aStore.put(aObj, aId);
request.onsuccess = function(aEvent) {
debug("UpdateInternal success");
// No wrap here because the result is always a int.
aResolve(aEvent.target.result);
};
request.onerror = function(aEvent) {
debug("UpdateInternal error");
aReject(new aWindow.DOMError(aEvent.target.error.name));
self.addRevision(aRevisionStore, aId, REVISION_UPDATED,
function() {
debug("UpdateInternal - revisionId increased");
// No wrap here because the result is always a int.
aResolve(aEvent.target.result);
}
);
};
},
addInternal: function(aWindow, aResolve, aReject, aStore, aObj) {
addInternal: function(aResolve, aStore, aRevisionStore, aObj) {
debug("AddInternal");
let self = this;
let request = aStore.put(aObj);
request.onsuccess = function(aEvent) {
debug("Request successful. Id: " + aEvent.target.result);
// No wrap here because the result is always a int.
aResolve(aEvent.target.result);
};
request.onerror = function(aEvent) {
debug("AddInternal error");
aReject(new aWindow.DOMError(aEvent.target.error.name));
self.addRevision(aRevisionStore, aEvent.target.result, REVISION_ADDED,
function() {
debug("AddInternal - revisionId increased");
// No wrap here because the result is always a int.
aResolve(aEvent.target.result);
}
);
};
},
removeInternal: function(aResolve, aReject, aStore, aId) {
removeInternal: function(aResolve, aStore, aRevisionStore, aId) {
debug("RemoveInternal");
let request = aStore.delete(aId);
request.onsuccess = function() {
debug("RemoveInternal success");
aResolve();
};
request.onerror = function(aEvent) {
debug("RemoveInternal error");
aReject(new aWindow.DOMError(aEvent.target.error.name));
let self = this;
let request = aStore.get(aId);
request.onsuccess = function(aEvent) {
debug("RemoveInternal success. Record: " + aEvent.target.result);
if (aEvent.target.result === undefined) {
aResolve(false);
return;
}
let deleteRequest = aStore.delete(aId);
deleteRequest.onsuccess = function() {
debug("RemoveInternal success");
self.addRevision(aRevisionStore, aId, REVISION_REMOVED,
function() {
aResolve(true);
}
);
};
};
},
clearInternal: function(aResolve, aReject, aStore) {
clearInternal: function(aResolve, aStore, aRevisionStore) {
debug("ClearInternal");
let self = this;
let request = aStore.clear();
request.onsuccess = function() {
debug("ClearInternal success");
aResolve();
};
request.onerror = function(aEvent) {
debug("ClearInternal error");
aReject(new aWindow.DOMError(aEvent.target.error.name));
self.db.clearRevisions(aRevisionStore,
function() {
debug("Revisions cleared");
self.addRevision(aRevisionStore, 0, REVISION_VOID,
function() {
debug("ClearInternal - revisionId increased");
aResolve();
}
);
}
);
};
},
addRevision: function(aRevisionStore, aId, aType, aSuccessCb) {
let self = this;
this.db.addRevision(aRevisionStore, aId, aType,
function(aRevisionId) {
self.revisionId = aRevisionId;
aSuccessCb();
}
);
},
retrieveRevisionId: function(aSuccessCb) {
if (this.revisionId != null) {
aSuccessCb();
return;
}
let self = this;
this.db.revisionTxn(
'readwrite',
function(aTxn, aRevisionStore) {
debug("RetrieveRevisionId transaction success");
let request = aRevisionStore.openCursor(null, 'prev');
request.onsuccess = function(aEvent) {
let cursor = aEvent.target.result;
if (!cursor) {
// If the revision doesn't exist, let's create the first one.
self.addRevision(aRevisionStore, 0, REVISION_VOID, aSuccessCb);
return;
}
self.revisionId = cursor.value.revisionId;
aSuccessCb();
};
}
);
},
throwInvalidArg: function(aWindow) {
return aWindow.Promise.reject(
new aWindow.DOMError("SyntaxError", "Non-numeric or invalid id"));
@ -163,8 +230,8 @@ DataStore.prototype = {
// Promise<Object>
return self.newDBPromise(aWindow, "readonly",
function(aResolve, aReject, aTxn, aStore) {
self.getInternal(aWindow, aResolve, aReject, aStore, aId);
function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
self.getInternal(aWindow, aResolve, aStore, aId);
}
);
},
@ -181,8 +248,8 @@ DataStore.prototype = {
// Promise<void>
return self.newDBPromise(aWindow, "readwrite",
function(aResolve, aReject, aTxn, aStore) {
self.updateInternal(aWindow, aResolve, aReject, aStore, aId, aObj);
function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
self.updateInternal(aResolve, aStore, aRevisionStore, aId, aObj);
}
);
},
@ -194,8 +261,8 @@ DataStore.prototype = {
// Promise<int>
return self.newDBPromise(aWindow, "readwrite",
function(aResolve, aReject, aTxn, aStore) {
self.addInternal(aWindow, aResolve, aReject, aStore, aObj);
function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
self.addInternal(aResolve, aStore, aRevisionStore, aObj);
}
);
},
@ -212,8 +279,8 @@ DataStore.prototype = {
// Promise<void>
return self.newDBPromise(aWindow, "readwrite",
function(aResolve, aReject, aTxn, aStore) {
self.removeInternal(aResolve, aReject, aStore, aId);
function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
self.removeInternal(aResolve, aStore, aRevisionStore, aId);
}
);
},
@ -225,16 +292,114 @@ DataStore.prototype = {
// Promise<void>
return self.newDBPromise(aWindow, "readwrite",
function(aResolve, aReject, aTxn, aStore) {
self.clearInternal(aResolve, aReject, aStore);
function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
self.clearInternal(aResolve, aStore, aRevisionStore);
}
);
},
get revisionId() {
return self.revisionId;
},
getChanges: function(aRevisionId) {
debug("GetChanges: " + aRevisionId);
if (aRevisionId === null || aRevisionId === undefined) {
return aWindow.Promise.reject(
new aWindow.DOMError("SyntaxError", "Invalid revisionId"));
}
// Promise<DataStoreChanges>
return new aWindow.Promise(function(aResolve, aReject) {
debug("GetChanges promise started");
self.db.revisionTxn(
'readonly',
function(aTxn, aStore) {
debug("GetChanges transaction success");
let request = self.db.getInternalRevisionId(
aRevisionId,
aStore,
function(aInternalRevisionId) {
if (aInternalRevisionId == undefined) {
aResolve(undefined);
return;
}
// This object is the return value of this promise.
// Initially we use maps, and then we convert them in array.
let changes = {
revisionId: '',
addedIds: {},
updatedIds: {},
removedIds: {}
};
let request = aStore.mozGetAll(aWindow.IDBKeyRange.lowerBound(aInternalRevisionId, true));
request.onsuccess = function(aEvent) {
for (let i = 0; i < aEvent.target.result.length; ++i) {
let data = aEvent.target.result[i];
switch (data.operation) {
case REVISION_ADDED:
changes.addedIds[data.objectId] = true;
break;
case REVISION_UPDATED:
// We don't consider an update if this object has been added
// or if it has been already modified by a previous
// operation.
if (!(data.objectId in changes.addedIds) &&
!(data.objectId in changes.updatedIds)) {
changes.updatedIds[data.objectId] = true;
}
break;
case REVISION_REMOVED:
let id = data.objectId;
// If the object has been added in this range of revisions
// we can ignore it and remove it from the list.
if (id in changes.addedIds) {
delete changes.addedIds[id];
} else {
changes.removedIds[id] = true;
}
if (id in changes.updatedIds) {
delete changes.updatedIds[id];
}
break;
}
}
// The last revisionId.
if (aEvent.target.result.length) {
changes.revisionId = aEvent.target.result[aEvent.target.result.length - 1].revisionId;
}
// From maps to arrays.
changes.addedIds = Object.keys(changes.addedIds).map(function(aKey) { return parseInt(aKey, 10); });
changes.updatedIds = Object.keys(changes.updatedIds).map(function(aKey) { return parseInt(aKey, 10); });
changes.removedIds = Object.keys(changes.removedIds).map(function(aKey) { return parseInt(aKey, 10); });
let wrappedObject = ObjectWrapper.wrap(changes, aWindow);
aResolve(wrappedObject);
};
}
);
},
function(aEvent) {
debug("GetChanges transaction failed");
aReject(createDOMError(aWindow, aEvent));
}
);
});
},
/* TODO:
readonly attribute DOMString revisionId
attribute EventHandler onchange;
Promise<DataStoreChanges> getChanges(DOMString revisionId)
getAll(), getLength()
*/
@ -246,7 +411,9 @@ DataStore.prototype = {
update: 'r',
add: 'r',
remove: 'r',
clear: 'r'
clear: 'r',
revisionId: 'r',
getChanges: 'r'
}
};

View File

@ -14,8 +14,15 @@ function debug(s) {
const DATASTOREDB_VERSION = 1;
const DATASTOREDB_OBJECTSTORE_NAME = 'DataStoreDB';
const DATASTOREDB_REVISION = 'revision';
const DATASTOREDB_REVISION_INDEX = 'revisionIndex';
Cu.import('resource://gre/modules/IndexedDBHelper.jsm');
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
this.DataStoreDB = function DataStoreDB() {}
@ -26,25 +33,68 @@ DataStoreDB.prototype = {
upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) {
debug('updateSchema');
aDb.createObjectStore(DATASTOREDB_OBJECTSTORE_NAME, { autoIncrement: true });
let store = aDb.createObjectStore(DATASTOREDB_REVISION,
{ autoIncrement: true,
keyPath: 'internalRevisionId' });
store.createIndex(DATASTOREDB_REVISION_INDEX, 'revisionId', { unique: true });
},
init: function(aOrigin, aName) {
let dbName = aOrigin + '_' + aName;
this.initDBHelper(dbName, DATASTOREDB_VERSION,
[DATASTOREDB_OBJECTSTORE_NAME]);
[DATASTOREDB_OBJECTSTORE_NAME, DATASTOREDB_REVISION]);
},
txn: function(aType, aCallback, aErrorCb) {
debug('Transaction request');
this.newTxn(
aType,
DATASTOREDB_OBJECTSTORE_NAME,
aType == 'readonly'
? [ DATASTOREDB_OBJECTSTORE_NAME ] : [ DATASTOREDB_OBJECTSTORE_NAME, DATASTOREDB_REVISION ],
function(aTxn, aStores) {
aType == 'readonly' ? aCallback(aTxn, aStores[0], null) : aCallback(aTxn, aStores[0], aStores[1]);
},
function() {},
aErrorCb
);
},
revisionTxn: function(aType, aCallback, aErrorCb) {
debug("Transaction request");
this.newTxn(
aType,
DATASTOREDB_REVISION,
aCallback,
function() {},
aErrorCb
);
},
addRevision: function(aStore, aId, aType, aSuccessCb) {
debug("AddRevision: " + aId + " - " + aType);
let revisionId = uuidgen.generateUUID().toString();
let request = aStore.put({ revisionId: revisionId, objectId: aId, operation: aType });
request.onsuccess = function() {
aSuccessCb(revisionId);
}
},
getInternalRevisionId: function(aRevisionId, aStore, aSuccessCb) {
debug('GetInternalRevisionId');
let request = aStore.index(DATASTOREDB_REVISION_INDEX).getKey(aRevisionId);
request.onsuccess = function(aEvent) {
aSuccessCb(aEvent.target.result);
}
},
clearRevisions: function(aStore, aSuccessCb) {
debug("ClearRevisions");
let request = aStore.clear();
request.onsuccess = function() {
aSuccessCb();
}
},
delete: function() {
debug('delete');
this.close();

View File

@ -61,16 +61,38 @@ DataStoreService.prototype = {
debug('getDataStores - aName: ' + aName);
let self = this;
return new aWindow.Promise(function(resolve, reject) {
let results = [];
let matchingStores = [];
if (aName in self.stores) {
for (let appId in self.stores[aName]) {
let obj = self.stores[aName][appId].exposeObject(aWindow);
results.push(obj);
matchingStores.push(self.stores[aName][appId]);
}
}
resolve(results);
let callbackPending = matchingStores.length;
let results = [];
if (!callbackPending) {
resolve(results);
return;
}
for (let i = 0; i < matchingStores.length; ++i) {
let obj = matchingStores[i].exposeObject(aWindow);
results.push(obj);
matchingStores[i].retrieveRevisionId(
function() {
--callbackPending;
if (!callbackPending) {
resolve(results);
}
},
function() {
reject();
}
);
}
});
},

View File

@ -15,6 +15,7 @@ MOCHITEST_FILES = \
test_app_install.html \
test_readonly.html \
test_basic.html \
test_revision.html \
file_app.sjs \
file_app.template.webapp \
$(NULL)

View File

@ -0,0 +1,235 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>Test for DataStore - basic operation on a readonly db</title>
<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
</head>
<body>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
<script type="application/javascript;version=1.7">
var gBaseURL = 'http://test/tests/dom/datastore/tests/';
var gHostedManifestURL = gBaseURL + 'file_app.sjs';
var gApp;
var gStore;
var gPreviousRevisionId = '';
function cbError() {
ok(false, "Error callback invoked");
finish();
}
function installApp() {
var request = navigator.mozApps.install(gHostedManifestURL);
request.onerror = cbError;
request.onsuccess = function() {
gApp = request.result;
runTest();
}
}
function testGetDataStores() {
navigator.getDataStores('foo').then(function(stores) {
is(stores.length, 1, "getDataStores('foo') returns 1 element");
is(stores[0].name, 'foo', 'The dataStore.name is foo');
is(stores[0].readOnly, false, 'The dataStore foo is not in readonly');
gStore = stores[0];
runTest();
}, cbError);
}
function testStoreAdd(value, expectedId) {
return gStore.add(value).then(function(id) {
is(id, expectedId, "store.add() is called");
runTest();
}, cbError);
}
function testStoreUpdate(id, value) {
return gStore.update(id, value).then(function(retId) {
is(id, retId, "store.update() is called with the right id");
runTest();
}, cbError);
}
function testStoreRemove(id, expectedSuccess) {
return gStore.remove(id).then(function(success) {
is(success, expectedSuccess, "store.remove() returns the right value");
runTest();
}, cbError);
}
function testStoreRevisionId() {
is(/[0-9a-zA-Z]{8}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{12}/.test(gStore.revisionId), true, "store.revisionId returns something");
runTest();
}
function testStoreWrongRevisions(id) {
return gStore.getChanges(id).then(
function(what) {
is(what, undefined, "Wrong revisionId == undefined object");
runTest();
}, cbError);
}
function testStoreRevisions(id, changes) {
return gStore.getChanges(id).then(function(what) {
is(JSON.stringify(changes.addedIds),
JSON.stringify(what.addedIds), "store.revisions - addedIds: " +
JSON.stringify(what.addedIds) + " | " + JSON.stringify(changes.addedIds));
is(JSON.stringify(changes.updatedIds),
JSON.stringify(what.updatedIds), "store.revisions - updatedIds: " +
JSON.stringify(what.updatedIds) + " | " + JSON.stringify(changes.updatedIds));
is(JSON.stringify(changes.removedIds),
JSON.stringify(what.removedIds), "store.revisions - removedIds: " +
JSON.stringify(what.removedIds) + " | " + JSON.stringify(changes.removedIds));
runTest();
}, cbError);
}
function uninstallApp() {
// Uninstall the app.
request = navigator.mozApps.mgmt.uninstall(gApp);
request.onerror = cbError;
request.onsuccess = function() {
// All done.
ok(true, "All done");
runTest();
}
}
function testStoreRevisionIdChanged() {
isnot(gStore.revisionId, gPreviousRevisionId, "Revision changed");
gPreviousRevisionId = gStore.revisionId;
runTest();
}
function testStoreRevisionIdNotChanged() {
is(gStore.revisionId, gPreviousRevisionId, "Revision changed");
runTest();
}
var revisions = [];
var tests = [
// Permissions
function() {
SpecialPowers.pushPermissions(
[{ "type": "browser", "allow": 1, "context": document },
{ "type": "embed-apps", "allow": 1, "context": document },
{ "type": "webapps-manage", "allow": 1, "context": document }], runTest);
},
// Preferences
function() {
SpecialPowers.pushPrefEnv({"set": [["dom.promise.enabled", true]]}, runTest);
},
// No confirmation needed when an app is installed
function() {
SpecialPowers.autoConfirmAppInstall(runTest);
},
// Installing the app
installApp,
// Test for GetDataStore
testGetDataStores,
// The first revision is not empty
testStoreRevisionIdChanged,
// wrong revision ID
function() { testStoreWrongRevisions('foobar'); },
// Add
function() { testStoreAdd({ number: 42 }, 1); },
function() { revisions.push(gStore.revisionId); testStoreRevisionId(); },
testStoreRevisionIdChanged,
function() { testStoreRevisions(revisions[0], { addedIds: [], updatedIds: [], removedIds: [] }); },
// Add
function() { testStoreAdd({ number: 42 }, 2); },
function() { revisions.push(gStore.revisionId); runTest(); },
testStoreRevisionIdChanged,
function() { testStoreRevisions(revisions[0], { addedIds: [2], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[1], { addedIds: [], updatedIds: [], removedIds: [] }); },
// Add
function() { testStoreAdd({ number: 42 }, 3); },
function() { revisions.push(gStore.revisionId); runTest(); },
testStoreRevisionIdChanged,
function() { testStoreRevisions(revisions[0], { addedIds: [2,3], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[1], { addedIds: [3], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [], removedIds: [] }); },
// Update
function() { testStoreUpdate(3, { number: 43 }); },
function() { revisions.push(gStore.revisionId); runTest(); },
testStoreRevisionIdChanged,
function() { testStoreRevisions(revisions[0], { addedIds: [2,3], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[1], { addedIds: [3], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [3], removedIds: [] }); },
function() { testStoreRevisions(revisions[3], { addedIds: [], updatedIds: [], removedIds: [] }); },
// Update
function() { testStoreUpdate(3, { number: 42 }); },
function() { revisions.push(gStore.revisionId); runTest(); },
testStoreRevisionIdChanged,
function() { testStoreRevisions(revisions[0], { addedIds: [2,3], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[1], { addedIds: [3], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [3], removedIds: [] }); },
function() { testStoreRevisions(revisions[3], { addedIds: [], updatedIds: [3], removedIds: [] }); },
function() { testStoreRevisions(revisions[4], { addedIds: [], updatedIds: [], removedIds: [] }); },
// Remove
function() { testStoreRemove(3, true); },
function() { revisions.push(gStore.revisionId); runTest(); },
testStoreRevisionIdChanged,
function() { testStoreRevisions(revisions[0], { addedIds: [2], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[1], { addedIds: [], updatedIds: [], removedIds: [] }); },
function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [], removedIds: [3] }); },
function() { testStoreRevisions(revisions[3], { addedIds: [], updatedIds: [], removedIds: [3] }); },
function() { testStoreRevisions(revisions[4], { addedIds: [], updatedIds: [], removedIds: [3] }); },
function() { testStoreRevisions(revisions[5], { addedIds: [], updatedIds: [], removedIds: [] }); },
function() { testStoreRemove(3, false); },
testStoreRevisionIdNotChanged,
// Remove
function() { testStoreRemove(42, false); },
testStoreRevisionIdNotChanged,
// Uninstall the app
uninstallApp
];
function runTest() {
if (!tests.length) {
finish();
return;
}
var test = tests.shift();
test();
}
function finish() {
SimpleTest.finish();
}
SimpleTest.waitForExplicitFinish();
runTest();
</script>
</pre>
</body>
</html>