Bug 674097 - land TPS in core, r=philikon, a=test-only, DONTBUILD
authorJonathan Griffin <jgriffin@mozilla.com>
Wed, 27 Jul 2011 16:32:42 -0700
changeset 74455 5a1e829db409e9a853470e27b9db387d92419155
parent 74454 9d927d1f2ac82facccd6165ca47a4fe11c7f1e42
child 74456 3773e471ce368ee13d891eafa24353ab026cd8d9
push id235
push userbzbarsky@mozilla.com
push dateTue, 27 Sep 2011 17:13:04 +0000
treeherdermozilla-beta@2d1e082d176a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersphilikon, test-only, DONTBUILD
bugs674097
milestone8.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 674097 - land TPS in core, r=philikon, a=test-only, DONTBUILD
services/sync/tests/tps/all_tests.json
services/sync/tests/tps/test_bookmarks_in_same_named_folder.js
services/sync/tests/tps/test_bug501528.js
services/sync/tests/tps/test_bug530717.js
services/sync/tests/tps/test_bug531489.js
services/sync/tests/tps/test_bug535326.js
services/sync/tests/tps/test_bug538298.js
services/sync/tests/tps/test_bug546807.js
services/sync/tests/tps/test_bug556509.js
services/sync/tests/tps/test_bug562515.js
services/sync/tests/tps/test_bug563989.js
services/sync/tests/tps/test_bug575423.js
services/sync/tests/tps/test_client_wipe.js
services/sync/tests/tps/test_formdata.js
services/sync/tests/tps/test_history.js
services/sync/tests/tps/test_history_collision.js
services/sync/tests/tps/test_passwords.js
services/sync/tests/tps/test_prefs.js
services/sync/tests/tps/test_privbrw_formdata.js
services/sync/tests/tps/test_privbrw_passwords.js
services/sync/tests/tps/test_privbrw_tabs.js
services/sync/tests/tps/test_special_tabs.js
services/sync/tests/tps/test_sync.js
services/sync/tests/tps/test_tabs.js
services/sync/tps/chrome.manifest
services/sync/tps/components/tps-cmdline.js
services/sync/tps/install.rdf
services/sync/tps/modules/bookmarks.jsm
services/sync/tps/modules/forms.jsm
services/sync/tps/modules/history.jsm
services/sync/tps/modules/logger.jsm
services/sync/tps/modules/passwords.jsm
services/sync/tps/modules/prefs.jsm
services/sync/tps/modules/quit.js
services/sync/tps/modules/tabs.jsm
services/sync/tps/modules/tps.jsm
testing/tps/INSTALL.sh
testing/tps/README
testing/tps/config.json
testing/tps/pages/microsummary1.txt
testing/tps/pages/microsummary2.txt
testing/tps/pages/microsummary3.txt
testing/tps/pages/page1.html
testing/tps/pages/page2.html
testing/tps/pages/page3.html
testing/tps/pages/page4.html
testing/tps/pages/page5.html
testing/tps/setup.py
testing/tps/tps/__init__.py
testing/tps/tps/cli.py
testing/tps/tps/emailtemplate.py
testing/tps/tps/firefoxrunner.py
testing/tps/tps/phase.py
testing/tps/tps/pulse.py
testing/tps/tps/sendemail.py
testing/tps/tps/testrunner.py
testing/tps/tps/thread.py
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/all_tests.json
@@ -0,0 +1,28 @@
+{ "tests": [
+    "test_sync.js",
+    "test_prefs.js",
+    "test_tabs.js",
+    "test_passwords.js",
+    "test_history.js",
+    "test_formdata.js",
+    "test_bug530717.js",
+    "test_bug531489.js",
+    "test_bug538298.js",
+    "test_bug556509.js",
+    "test_bug562515.js",
+    "test_bug563989.js",
+    "test_bug535326.js",
+    "test_bug501528.js",
+    "test_bug575423.js",
+    "test_bug546807.js",
+    "test_history_collision.js",
+    "test_privbrw_formdata.js",
+    "test_privbrw_passwords.js",
+    "test_privbrw_tabs.js",
+    "test_bookmarks_in_same_named_folder.js",
+    "test_client_wipe.js",
+    "test_special_tabs.js"
+  ]
+}
+
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bookmarks_in_same_named_folder.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// bug 558077
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1"};
+
+var bookmarks_initial_1 = {
+  "menu": [
+    { folder: "aaa",
+      description: "foo"
+    },
+    { uri: "http://www.mozilla.com"
+    }
+  ],
+  "menu/aaa": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo"
+    },
+    { uri: "http://www.google.com",
+      title: "testing Google"
+    }
+  ]
+};
+
+var bookmarks_initial_2 = {
+  "menu": [
+    { folder: "aaa",
+      description: "bar"
+    },
+    { uri: "http://www.mozilla.com"
+    }
+  ],
+  "menu/aaa": [
+    { uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
+      title: "Bugzilla"
+    },
+    { uri: "http://www.apple.com",
+      tags: [ "apple" ]
+    }
+  ]
+};
+
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial_1],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial_1],
+  [Bookmarks.add, bookmarks_initial_2],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  // XXX [Bookmarks.verify, bookmarks_initial_1],
+  [Bookmarks.verify, bookmarks_initial_2]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug501528.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Password lists
+ */
+
+var passwords_initial = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "secret",
+    usernameField: "uname",
+    passwordField: "pword",
+    changes: {
+      password: "SeCrEt$$$"
+    }
+  },
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "jack",
+    password: "secretlogin"
+  }
+];
+
+var passwords_after_first_update = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "SeCrEt$$$",
+    usernameField: "uname",
+    passwordField: "pword"
+  },
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "jack",
+    password: "secretlogin"
+  }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Passwords.add, passwords_initial],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Passwords.add, passwords_initial],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [Passwords.verify, passwords_initial],
+  [Passwords.modify, passwords_initial],
+  [Passwords.verify, passwords_after_first_update],
+  [Sync]
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Passwords.verify, passwords_after_first_update],
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug530717.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1"};
+
+/*
+ * Preference lists
+ */
+
+var prefs1 = [
+  { name: "browser.startup.homepage",
+    value: "http://www.getfirefox.com"
+  },
+  { name: "browser.urlbar.maxRichResults",
+    value: 20
+  },
+  { name: "browser.tabs.autoHide",
+    value: true
+  }
+];
+
+var prefs2 = [
+  { name: "browser.startup.homepage",
+    value: "http://www.mozilla.com"
+  },
+  { name: "browser.urlbar.maxRichResults",
+    value: 18
+  },
+  { name: "browser.tabs.autoHide",
+    value: false
+  }
+];
+
+/*
+ * Test phases
+ */
+
+// Add prefs to profile1 and sync.
+Phase('phase1', [
+  [Prefs.modify, prefs1],
+  [Prefs.verify, prefs1],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+// Sync profile2 and verify same prefs are present.
+Phase('phase2', [
+  [Sync],
+  [Prefs.verify, prefs1]
+]);
+
+// Using profile1, change some prefs, then do another sync with wipe-client.
+// Verify that the cloud's prefs are restored, and the recent local changes
+// discarded.
+Phase('phase3', [
+  [Prefs.modify, prefs2],
+  [Prefs.verify, prefs2],
+  [Sync, SYNC_WIPE_CLIENT],
+  [Prefs.verify, prefs1]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug531489.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1"};
+
+/*
+ * Bookmark asset lists: these define bookmarks that are used during the test
+ */
+
+// the initial list of bookmarks to add to the browser
+var bookmarks_initial = {
+  "menu": [
+    { folder: "foldera" },
+    { uri: "http://www.google.com",
+      title: "Google"
+    }
+  ],
+  "menu/foldera": [
+    { uri: "http://www.google.com",
+      title: "Google"
+    }
+  ],
+  "toolbar": [
+    { uri: "http://www.google.com",
+      title: "Google"
+    }
+  ]
+};
+
+/*
+ * Test phases
+ */
+
+// Add three bookmarks with the same url to different locations and sync.
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial],
+  [Bookmarks.verify, bookmarks_initial],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+// Sync to profile2 and verify that all three bookmarks are present
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
+// Sync again to profile1 and verify that all three bookmarks are still
+// present.
+Phase('phase3', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug535326.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2"};
+
+var tabs1 = [
+  { uri: "data:text/html,<html><head><title>Howdy</title></head><body>Howdy</body></html>",
+    title: "Howdy",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>America</title></head><body>America</body></html>",
+    title: "America",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Apple</title></head><body>Apple</body></html>",
+    title: "Apple",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>This</title></head><body>This</body></html>",
+    title: "This",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Bug</title></head><body>Bug</body></html>",
+    title: "Bug",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>IRC</title></head><body>IRC</body></html>",
+    title: "IRC",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Tinderbox</title></head><body>Tinderbox</body></html>",
+    title: "Tinderbox",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Fox</title></head><body>Fox</body></html>",
+    title: "Fox",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Hello</title></head><body>Hello</body></html>",
+    title: "Hello",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Eagle</title></head><body>Eagle</body></html>",
+    title: "Eagle",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Train</title></head><body>Train</body></html>",
+    title: "Train",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Macbook</title></head><body>Macbook</body></html>",
+    title: "Macbook",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Clock</title></head><body>Clock</body></html>",
+    title: "Clock",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Google</title></head><body>Google</body></html>",
+    title: "Google",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Human</title></head><body>Human</body></html>",
+    title: "Human",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Jetpack</title></head><body>Jetpack</body></html>",
+    title: "Jetpack",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Selenium</title></head><body>Selenium</body></html>",
+    title: "Selenium",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Mozilla</title></head><body>Mozilla</body></html>",
+    title: "Mozilla",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
+    title: "Firefox",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Weave</title></head><body>Weave</body></html>",
+    title: "Weave",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Android</title></head><body>Android</body></html>",
+    title: "Android",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Bye</title></head><body>Bye</body></html>",
+    title: "Bye",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Hi</title></head><body>Hi</body></html>",
+    title: "Hi",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Final</title></head><body>Final</body></html>",
+    title: "Final",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Fennec</title></head><body>Fennec</body></html>",
+    title: "Fennec",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Mobile</title></head><body>Mobile</body></html>",
+    title: "Mobile",
+    profile: "profile1"
+  }
+];
+
+Phase('phase1', [
+  [Tabs.add, tabs1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Tabs.verify, tabs1]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug538298.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Bookmark asset lists: these define bookmarks that are used during the test
+ */
+
+// the initial list of bookmarks to add to the browser
+var bookmarks_initial = {
+  "toolbar": [
+    { uri: "http://www.google.com",
+      title: "Google"
+    },
+    { uri: "http://www.cnn.com",
+      title: "CNN",
+      changes: {
+        position: "Google"
+      }
+    },
+    { uri: "http://www.mozilla.com",
+      title: "Mozilla"
+    },
+    { uri: "http://www.firefox.com",
+      title: "Firefox",
+      changes: {
+        position: "Mozilla"
+      }
+    }
+  ]
+};
+
+var bookmarks_after_move = {
+  "toolbar": [
+    { uri: "http://www.cnn.com",
+      title: "CNN"
+    },
+    { uri: "http://www.google.com",
+      title: "Google"
+    },
+    { uri: "http://www.firefox.com",
+      title: "Firefox"
+    },
+    { uri: "http://www.mozilla.com",
+      title: "Mozilla"
+    }
+  ]
+};
+
+/*
+ * Test phases
+ */
+
+// Add four bookmarks to the toolbar and sync.
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial],
+  [Bookmarks.verify, bookmarks_initial],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+// Sync to profile2 and verify that all four bookmarks are present.
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
+// Change the order of the toolbar bookmarks, and sync.
+Phase('phase3', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial],
+  [Bookmarks.modify, bookmarks_initial],
+  [Bookmarks.verify, bookmarks_after_move],
+  [Sync],
+]);
+
+// Go back to profile2, sync, and verify that the bookmarks are reordered
+// as expected.
+Phase('phase4', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_after_move]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug546807.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2"};
+
+/*
+ * Tabs data
+ */
+
+var tabs1 = [
+  { uri: "about:config",
+    profile: "profile1"
+  },
+  { uri: "about:credits",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Apple</title></head><body>Apple</body></html>",
+    title: "Apple",
+    profile: "profile1"
+  }
+];
+
+var tabs_absent = [
+  { uri: "about:config",
+    profile: "profile1"
+  },
+  { uri: "about:credits",
+    profile: "profile1"
+  },
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Tabs.add, tabs1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Tabs.verifyNot, tabs_absent]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug556509.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2"};
+
+
+// the initial list of bookmarks to add to the browser
+var bookmarks_initial = {
+  "menu": [
+    { folder: "testfolder",
+      description: "it's just me, a test folder"
+    }
+  ],
+  "menu/testfolder": [
+    { uri: "http://www.mozilla.com",
+      title: "Mozilla"
+    }
+  ]
+};
+
+/*
+ * Test phases
+ */
+
+// Add a bookmark folder which has a description, and sync.
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial],
+  [Bookmarks.verify, bookmarks_initial],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+// Sync to profile2 and verify that the bookmark folder is created, along
+// with its description.
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug562515.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Bookmark lists
+ */
+
+// the initial list of bookmarks to add to the browser
+var bookmarks_initial = {
+  "menu": [
+    { uri: "http://www.google.com",
+      loadInSidebar: true,
+      tags: [ "google", "computers", "internet", "www"]
+    },
+    { uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
+      title: "Bugzilla",
+      keyword: "bz"
+    },
+    { folder: "foldera" },
+    { uri: "http://www.mozilla.com" },
+    { separator: true },
+    { folder: "folderb" }
+  ],
+  "menu/foldera": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo"
+    },
+    { uri: "http://www.cnn.com",
+      description: "This is a description of the site a at www.cnn.com"
+    },
+    { livemark: "Livemark1",
+      feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
+      siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
+    }
+  ],
+  "menu/folderb": [
+    { uri: "http://www.apple.com",
+      tags: [ "apple", "mac" ]
+    }
+  ],
+  "toolbar": [
+    { uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
+      title: "Visited Today"
+    }
+  ]
+};
+
+// a list of bookmarks to delete during a 'delete' action
+var bookmarks_to_delete = {
+  "menu": [
+    { uri: "http://www.google.com",
+      loadInSidebar: true,
+      tags: [ "google", "computers", "internet", "www"]
+    }
+  ],
+  "menu/foldera": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo"
+    }
+  ]
+};
+
+/*
+ * Test phases
+ */
+
+// add bookmarks to profile1 and sync
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial],
+  [Bookmarks.verify, bookmarks_initial],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+// sync to profile2 and verify that the bookmarks are present
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
+// delete some bookmarks from profile1, then sync with "wipe-client"
+// set; finally, verify that the deleted bookmarks were restored.
+Phase('phase3', [
+  [Bookmarks.delete, bookmarks_to_delete],
+  [Bookmarks.verifyNot, bookmarks_to_delete],
+  [Sync, SYNC_WIPE_CLIENT],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
+// sync profile2 again, verify no bookmarks have been deleted
+Phase('phase4', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug563989.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Bookmark asset lists: these define bookmarks that are used during the test
+ */
+
+// the initial list of bookmarks to add to the browser
+var bookmarks_initial = {
+  "menu": [
+    { uri: "http://www.google.com",
+      loadInSidebar: true,
+      tags: [ "google", "computers", "internet", "www" ]
+    },
+    { uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
+      title: "Bugzilla",
+      keyword: "bz"
+    },
+    { folder: "foldera" },
+    { uri: "http://www.mozilla.com" },
+    { separator: true },
+    { folder: "folderb" }
+  ],
+  "menu/foldera": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo"
+    },
+    { uri: "http://www.cnn.com",
+      description: "This is a description of the site a at www.cnn.com"
+    },
+    { livemark: "Livemark1",
+      feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
+      siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
+    }
+  ],
+  "menu/folderb": [
+    { uri: "http://www.apple.com",
+      tags: [ "apple", "mac" ]
+    }
+  ],
+  "toolbar": [
+    { uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
+      title: "Visited Today"
+    }
+  ]
+};
+
+// a list of bookmarks to delete during a 'delete' action
+var bookmarks_to_delete = {
+  "menu/folderb": [
+    { uri: "http://www.apple.com",
+      tags: [ "apple", "mac" ]
+    }
+  ],
+  "toolbar": [
+    { uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
+      title: "Visited Today"
+    }
+  ]
+};
+
+/*
+ * Test phases
+ */
+
+// Add bookmarks to profile1 and sync.
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial],
+  [Bookmarks.verify, bookmarks_initial],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+// Sync to profile2 and verify that the bookmarks are present.  Delete 
+// some bookmarks, and verify that they're not present, but don't sync again.
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial],
+  [Bookmarks.delete, bookmarks_to_delete],
+  [Bookmarks.verifyNot, bookmarks_to_delete]
+]);
+
+// Using profile1, sync again with wipe-server set to true.  Verify our
+// initial bookmarks are still all present.
+Phase('phase3', [
+  [Sync, SYNC_WIPE_SERVER],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
+// Back in profile2, do a sync and verify that the bookmarks we had
+// deleted earlier are now restored.
+Phase('phase4', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_bug575423.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2"};
+
+/*
+ * History data
+ */
+
+// the history data to add to the browser
+var history1 = [
+  { uri: "http://www.google.com/",
+    title: "Google",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -1
+      }
+    ]
+  },
+  { uri: "http://www.cnn.com/",
+    title: "CNN",
+    visits: [
+      { type: 1,
+        date: -1
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  }
+];
+
+// Another history data to add to the browser
+var history2 = [
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  },
+  { uri: "http://www.google.com/language_tools?hl=en",
+    title: "Language Tools",
+    visits: [
+      { type: 1, 
+        date: 0
+      },
+      { type: 2, 
+        date: -40
+      }
+    ]
+  }
+];
+
+/*
+ * Test phases
+ */
+Phase('phase1', [
+  [History.add, history1],
+  [Sync],
+  [History.add, history2],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [History.verify, history2]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_client_wipe.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1"};
+
+/*
+ * Bookmark lists
+ */
+
+// the initial list of bookmarks to add to the browser
+var bookmarks_initial = {
+  toolbar: [
+    { uri: "http://www.google.com",
+      title: "Google"
+    },
+    { uri: "http://www.cnn.com",
+      title: "CNN",
+      changes: {
+        position: "Google"
+      }
+    },
+    { uri: "http://www.mozilla.com",
+      title: "Mozilla"
+    },
+    { uri: "http://www.firefox.com",
+      title: "Firefox",
+      changes: {
+        position: "Mozilla"
+      }
+    }
+  ]
+};
+
+var bookmarks_after_move = {
+  toolbar: [
+    { uri: "http://www.cnn.com",
+      title: "CNN"
+    },
+    { uri: "http://www.google.com",
+      title: "Google"
+    },
+    { uri: "http://www.firefox.com",
+      title: "Firefox"
+    },
+    { uri: "http://www.mozilla.com",
+      title: "Mozilla"
+    }
+  ]
+};
+
+/*
+ * Password data
+ */
+
+// Initial password data
+var passwords_initial = [
+   { hostname: "http://www.example.com",
+     submitURL: "http://login.example.com",
+     username: "joe",
+     password: "secret",
+     usernameField: "uname",
+     passwordField: "pword",
+     changes: {
+       password: "SeCrEt$$$"
+     }
+   },
+   { hostname: "http://www.example.com",
+     realm: "login",
+     username: "jack",
+     password: "secretlogin"
+   }
+];
+
+// Password after first modify action has been performed
+var passwords_after_change = [
+   { hostname: "http://www.example.com",
+     submitURL: "http://login.example.com",
+     username: "joe",
+     password: "SeCrEt$$$",
+     usernameField: "uname",
+     passwordField: "pword",
+     changes: {
+        username: "james"
+     }
+   },
+   { hostname: "http://www.example.com",
+     realm: "login",
+     username: "jack",
+     password: "secretlogin"
+   }
+];
+
+/*
+ * Prefs to use in the test
+ */
+var prefs1 = [
+  { name: "browser.startup.homepage",
+    value: "http://www.getfirefox.com"
+  },
+  { name: "browser.urlbar.maxRichResults",
+    value: 20
+  },
+  { name: "browser.tabs.autoHide",
+    value: true
+  }
+];
+
+var prefs2 = [
+  { name: "browser.startup.homepage",
+    value: "http://www.mozilla.com"
+  },
+  { name: "browser.urlbar.maxRichResults",
+    value: 18
+  },
+  { name: "browser.tabs.autoHide",
+    value: false
+  }
+];
+
+/*
+ * Test phases
+ */
+
+// Add prefs,passwords and bookmarks to profile1 and sync.
+Phase('phase1', [
+  [Passwords.add, passwords_initial],
+  [Bookmarks.add, bookmarks_initial],
+  [Prefs.modify, prefs1],
+  [Prefs.verify, prefs1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+// Sync profile2 and verify same prefs,passwords and bookmarks are present.
+Phase('phase2', [
+  [Sync],
+  [Prefs.verify, prefs1],
+  [Passwords.verify, passwords_initial],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
+// Using profile1, change some prefs,bookmarks and pwds, then do another sync with wipe-client.
+// Verify that the cloud's  settings are restored, and the recent local changes
+// discarded.
+Phase('phase3', [
+  [Prefs.modify, prefs2],
+  [Passwords.modify, passwords_initial],
+  [Bookmarks.modify, bookmarks_initial],
+  [Prefs.verify, prefs2],
+  [Passwords.verify, passwords_after_change],
+  [Bookmarks.verify, bookmarks_after_move],
+  [Sync, SYNC_WIPE_CLIENT],
+  [Prefs.verify, prefs1],
+  [Passwords.verify, passwords_initial],
+  [Bookmarks.verify, bookmarks_initial]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_formdata.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Form data asset lists: these define form values that are used in the tests.
+ */
+
+var formdata1 = [
+  { fieldname: "testing",
+    value: "success",
+    date: -1
+  },
+  { fieldname: "testing",
+    value: "failure",
+    date: -2
+  },
+  { fieldname: "username",
+    value: "joe"
+  }
+];
+
+var formdata2 = [
+  { fieldname: "testing",
+    value: "success",
+    date: -1
+  },
+  { fieldname: "username",
+    value: "joe"
+  }
+];
+
+var formdata_delete = [
+  { fieldname: "testing",
+    value: "failure"
+  }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Formdata.add, formdata1],
+  [Formdata.verify, formdata1],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Formdata.verify, formdata1],
+]);
+
+/*
+ * Note: Weave does not support syncing deleted form data, so those
+ * tests are disabled below.  See bug 568363.
+ */
+
+Phase('phase3', [
+  [Sync],
+  [Formdata.delete, formdata_delete],
+//[Formdata.verifyNot, formdata_delete],
+  [Formdata.verify, formdata2],
+  [Sync],
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Formdata.verify, formdata2],
+//[Formdata.verifyNot, formdata_delete]
+]);
+
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_history.js
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2" };
+
+/*
+ * History asset lists: these define history entries that are used during
+ * the test
+ */
+
+// the initial list of history items to add to the browser
+var history1 = [
+  { uri: "http://www.google.com/",
+    title: "Google",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -1
+      }
+    ]
+  },
+  { uri: "http://www.cnn.com/",
+    title: "CNN",
+    visits: [
+      { type: 1,
+        date: -1
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  },
+  { uri: "http://www.google.com/language_tools?hl=en",
+    title: "Language Tools",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -40
+      }
+    ]
+  },
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 1,
+        date: -1
+      },
+      { type: 1,
+        date: -20
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  }
+];
+
+// a list of items to delete from the history
+var history_to_delete = [
+  { uri: "http://www.cnn.com/" },
+  { begin: -24,
+    end: -1
+  },
+  { host: "www.google.com" }
+];
+
+// a list which reflects items that should be in the history after
+// the above items are deleted
+var history2 = [
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  }
+];
+
+// a list which includes history entries that should not be present
+// after deletion of the history_to_delete entries
+var history_not = [
+  { uri: "http://www.google.com/",
+    title: "Google",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -1
+      }
+    ]
+  },
+  { uri: "http://www.cnn.com/",
+    title: "CNN",
+    visits: [
+      { type: 1,
+        date: -1
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  },
+  { uri: "http://www.google.com/language_tools?hl=en",
+    title: "Language Tools",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -40
+      }
+    ]
+  },
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1,
+        date: -1
+      },
+      { type: 1,
+        date: -20
+      }
+    ]
+  }
+];
+
+/*
+ * Test phases
+ * Note:  there is no test phase in which deleted history entries are
+ * synced to other clients.  This functionality is not supported by
+ * Sync, see bug 446517.
+ */
+
+Phase('phase1', [
+  [History.add, history1],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [History.verify, history1],
+  [History.delete, history_to_delete],
+  [History.verify, history2],
+  [History.verifyNot, history_not],
+  [Sync]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_history_collision.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * History lists
+ */
+
+// the initial list of history to add to the browser
+var history1 = [
+   { uri: "http://www.google.com/",
+     title: "Google",
+     visits: [
+       { type: 1,
+         date: 0
+       }
+     ]
+   },
+   { uri: "http://www.cnn.com/",
+     title: "CNN",
+     visits: [
+       { type: 1,
+         date: -1
+       },
+       { type: 2,
+         date: -36
+       }
+     ]
+   },
+   { uri: "http://www.mozilla.com/",
+     title: "Mozilla",
+     visits: [
+       { type: 1,
+         date: 0
+       },
+       { type: 2,
+         date: -36
+       }
+     ]
+   }
+];
+
+// the history to delete
+var history_to_delete = [
+   { uri: "http://www.cnn.com/",
+     title: "CNN"
+   },
+   { begin: -36,
+     end: -1
+   }
+];
+
+var history_not = [
+   { uri: "http://www.cnn.com/",
+     title: "CNN",
+     visits: [
+       { type: 1,
+         date: -1
+       },
+       { type: 2,
+         date: -36
+       }
+     ]
+   }
+];
+
+var history_after_delete = [
+   { uri: "http://www.google.com/",
+     title: "Google",
+     visits: [
+       { type: 1,
+         date: 0
+       }
+     ]
+   },
+   { uri: "http://www.mozilla.com/",
+     title: "Mozilla",
+     visits: [
+       { type: 1,
+         date: 0
+       }
+     ]
+   }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [History.add, history1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [History.add, history1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [History.verify, history1],
+  [History.delete, history_to_delete],
+  [History.verify, history_after_delete],
+  [History.verifyNot, history_not],
+  [Sync]
+]);
+
+Phase('phase4', [
+  [Sync],
+  [History.verify, history_after_delete],
+  [History.verifyNot, history_not]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_passwords.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Password asset lists: these define password entries that are used during
+ * the test
+ */
+
+// initial password list to be loaded into the browser
+var passwords_initial = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "SeCrEt123",
+    usernameField: "uname",
+    passwordField: "pword",
+    changes: {
+      password: "zippity-do-dah"
+    }
+  },
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+// expected state of passwords after the changes in the above list are applied
+var passwords_after_first_update = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "zippity-do-dah",
+    usernameField: "uname",
+    passwordField: "pword"
+  },
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+var passwords_to_delete = [
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+var passwords_absent = [
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+// expected state of passwords after the delete operation
+var passwords_after_second_update = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "zippity-do-dah",
+    usernameField: "uname",
+    passwordField: "pword"
+  }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Passwords.add, passwords_initial],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Passwords.verify, passwords_initial],
+  [Passwords.modify, passwords_initial],
+  [Passwords.verify, passwords_after_first_update],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [Passwords.verify, passwords_after_first_update],
+  [Passwords.delete, passwords_to_delete],
+  [Passwords.verify, passwords_after_second_update],
+  [Passwords.verifyNot, passwords_absent],
+  [Sync]
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Passwords.verify, passwords_after_second_update],
+  [Passwords.verifyNot, passwords_absent]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_prefs.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1"};
+
+var prefs1 = [
+  { name: "browser.startup.homepage",
+    value: "http://www.getfirefox.com"
+  },
+  { name: "browser.urlbar.maxRichResults",
+    value: 20
+  },
+  { name: "browser.tabs.autoHide",
+    value: true
+  }
+];
+
+var prefs2 = [
+  { name: "browser.startup.homepage",
+    value: "http://www.mozilla.com"
+  },
+  { name: "browser.urlbar.maxRichResults",
+    value: 18
+  },
+  { name: "browser.tabs.autoHide",
+    value: false
+  }
+];
+
+Phase('phase1', [
+  [Prefs.modify, prefs1],
+  [Prefs.verify, prefs1],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Prefs.verify, prefs1],
+  [Prefs.modify, prefs2],
+  [Prefs.verify, prefs2],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [Prefs.verify, prefs2]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_privbrw_formdata.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Form data
+ */
+
+// the form data to add to the browser
+var formdata1 = [
+   { fieldname: "name",
+     value: "xyz",
+     date: -1
+   },
+   { fieldname: "email",
+     value: "abc@gmail.com",
+     date: -2
+   },
+   { fieldname: "username",
+     value: "joe"
+   }
+];
+
+// the form data to add in private browsing mode
+var formdata2 = [
+   { fieldname: "password",
+     value: "secret",
+     date: -1
+   },
+   { fieldname: "city",
+     value: "mtview"
+   }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Formdata.add, formdata1],
+  [Formdata.verify, formdata1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Formdata.verify, formdata1]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [SetPrivateBrowsing, true],
+  [Formdata.add, formdata2],
+  [Formdata.verify, formdata2],
+  [Sync],
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Formdata.verify, formdata1],
+  [Formdata.verifyNot, formdata2]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_privbrw_passwords.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Password data
+ */
+
+// Initial password data
+var passwords_initial = [
+   { hostname: "http://www.example.com",
+     submitURL: "http://login.example.com",
+     username: "joe",
+     password: "secret",
+     usernameField: "uname",
+     passwordField: "pword",
+     changes: {
+       password: "SeCrEt$$$"
+     }
+   },
+   { hostname: "http://www.example.com",
+     realm: "login",
+     username: "jack",
+     password: "secretlogin"
+   }
+];
+
+// Password after first modify action has been performed
+var passwords_after_first_change = [
+   { hostname: "http://www.example.com",
+     submitURL: "http://login.example.com",
+     username: "joe",
+     password: "SeCrEt$$$",
+     usernameField: "uname",
+     passwordField: "pword",
+     changes: {
+        username: "james"
+     }
+   },
+   { hostname: "http://www.example.com",
+     realm: "login",
+     username: "jack",
+     password: "secretlogin"
+   }
+];
+
+// Password after second modify action has been performed
+var passwords_after_second_change = [
+   { hostname: "http://www.example.com",
+     submitURL: "http://login.example.com",
+     username: "james",
+     password: "SeCrEt$$$",
+     usernameField: "uname",
+     passwordField: "pword"
+   },
+   { hostname: "http://www.example.com",
+     realm: "login",
+     username: "jack",
+     password: "secretlogin"
+   }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Passwords.add, passwords_initial],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Passwords.verify, passwords_initial],
+  [Passwords.modify, passwords_initial],
+  [Passwords.verify, passwords_after_first_change],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [SetPrivateBrowsing, true],
+  [Passwords.verify, passwords_after_first_change],
+  [Passwords.modify, passwords_after_first_change],
+  [Passwords.verify, passwords_after_second_change],
+  [Sync]
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Passwords.verify, passwords_after_second_change]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_privbrw_tabs.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Tabs data
+ */
+
+var tabs1 = [
+  { uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
+    title: "Firefox",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Weave</title></head><body>Weave</body></html>",
+    title: "Weave",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Apple</title></head><body>Apple</body></html>",
+    title: "Apple",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>IRC</title></head><body>IRC</body></html>",
+    title: "IRC",
+    profile: "profile1"
+  }
+];
+
+var tabs2 = [
+  { uri: "data:text/html,<html><head><title>Tinderbox</title></head><body>Tinderbox</body></html>",
+    title: "Tinderbox",
+    profile: "profile2"
+  },
+  { uri: "data:text/html,<html><head><title>Fox</title></head><body>Fox</body></html>",
+    title: "Fox",
+    profile: "profile2"
+  }
+];
+
+var tabs3 = [
+  { uri: "data:text/html,<html><head><title>Jetpack</title></head><body>Jetpack</body></html>",
+    title: "Jetpack",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Selenium</title></head><body>Selenium</body></html>",
+    title: "Selenium",
+    profile: "profile1"
+  }
+];
+
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Tabs.add, tabs1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Tabs.verify, tabs1],
+  [Tabs.add, tabs2],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [SetPrivateBrowsing, true],
+  [Tabs.add, tabs3],
+  [Sync]
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Tabs.verifyNot, tabs3]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_special_tabs.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Bug 532173 - Dont sync tabs like about:* , weave firstrun etc
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2" };
+
+var tabs1 = [
+  { uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
+    title: "Firefox",
+    profile: "profile1"
+  },
+  { uri: "about:plugins",
+    title: "About",
+    profile: "profile1"
+  },
+  { uri: "about:credits",
+    title: "Credits",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Mozilla</title></head><body>Mozilla</body></html>",
+    title: "Mozilla",
+    profile: "profile1"
+  },
+  { uri: "http://www.mozilla.com/en-US/firefox/sync/firstrun.html",
+    title: "Firstrun",
+    profile: "profile1"
+  }
+];
+
+var tabs2 = [
+  { uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
+    title: "Firefox",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Mozilla</title></head><body>Mozilla</body></html>",
+    title: "Mozilla",
+    profile: "profile1"
+  }
+];
+
+var tabs3 = [
+  { uri: "http://www.mozilla.com/en-US/firefox/sync/firstrun.html",
+    title: "Firstrun",
+    profile: "profile1"
+  },
+  { uri: "about:plugins",
+    title: "About",
+    profile: "profile1"
+  },
+  { uri: "about:credits",
+    title: "Credits",
+    profile: "profile1"
+  }
+];
+
+/*
+ * Test phases
+ */
+Phase('phase1', [
+  [Tabs.add, tabs1],
+  [Sync, SYNC_WIPE_SERVER]
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Tabs.verify, tabs2],
+  [Tabs.verifyNot, tabs3]
+]);
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_sync.js
@@ -0,0 +1,424 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1",
+               "phase4": "profile2" };
+
+/*
+ * Bookmark asset lists: these define bookmarks that are used during the test
+ */
+
+// the initial list of bookmarks to be added to the browser
+var bookmarks_initial = {
+  "menu": [
+    { uri: "http://www.google.com",
+      loadInSidebar: true,
+      tags: ["google", "computers", "internet", "www"],
+      changes: {
+        title: "Google",
+        loadInSidebar: false,
+        tags: ["google", "computers", "misc"],
+      }
+    },
+    { uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
+      title: "Bugzilla",
+      keyword: "bz",
+      changes: {
+        keyword: "bugzilla" 
+      }
+    },
+    { folder: "foldera" },
+    { uri: "http://www.mozilla.com" },
+    { separator: true },
+    { folder: "folderb" },
+  ],
+  "menu/foldera": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo",
+      changes: {
+        location: "menu/folderb"
+      }
+    },
+    { uri: "http://www.cnn.com",
+      description: "This is a description of the site a at www.cnn.com",
+      changes: {
+        uri: "http://money.cnn.com",
+        description: "new description",
+      }
+    },
+    { livemark: "Livemark1",
+      feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
+      siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html",
+      changes: {
+        livemark: "LivemarkOne"
+      }
+    },
+  ],
+  "menu/folderb": [
+    { uri: "http://www.apple.com",
+      tags: ["apple", "mac"],
+      changes: {
+        uri: "http://www.apple.com/iphone/",
+        title: "iPhone",
+        location: "menu",
+        position: "Google",
+        tags: []
+      }
+    }
+  ],
+  toolbar: [
+    { uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
+      title: "Visited Today"
+    }
+  ]
+};
+
+// the state of bookmarks after the first 'modify' action has been performed
+// on them
+var bookmarks_after_first_modify = {
+  "menu": [
+    { uri: "http://www.apple.com/iphone/",
+      title: "iPhone",
+      before: "Google",
+      tags: []
+    },
+    { uri: "http://www.google.com",
+      title: "Google",
+      loadInSidebar: false,
+      tags: [ "google", "computers", "misc"]
+    },
+    { uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
+      title: "Bugzilla",
+      keyword: "bugzilla"
+    },
+    { folder: "foldera" },
+    { uri: "http://www.mozilla.com" },
+    { separator: true },
+    { folder: "folderb",
+      changes: {
+        location: "menu/foldera",
+        folder: "Folder B",
+        description: "folder description"
+      }
+    }
+  ],
+  "menu/foldera": [
+    { uri: "http://money.cnn.com",
+      title: "http://www.cnn.com",
+      description: "new description"
+    },
+    { livemark: "LivemarkOne",
+      feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
+      siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
+    }
+  ],
+  "menu/folderb": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo"
+    }
+  ],
+  "toolbar": [
+    { uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
+      title: "Visited Today"
+    }
+  ]
+};
+
+// a list of bookmarks to delete during a 'delete' action
+var bookmarks_to_delete = {
+  "menu": [
+    { uri: "http://www.google.com",
+      title: "Google",
+      loadInSidebar: false,
+      tags: [ "google", "computers", "misc" ]
+    }
+  ]
+};
+
+// the state of bookmarks after the second 'modify' action has been performed
+// on them
+var bookmarks_after_second_modify = {
+  "menu": [
+    { uri: "http://www.apple.com/iphone/",
+      title: "iPhone"
+    },
+    { uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
+      title: "Bugzilla",
+      keyword: "bugzilla"
+    },
+    { folder: "foldera" },
+    { uri: "http://www.mozilla.com" },
+    { separator: true },
+  ],
+  "menu/foldera": [
+    { uri: "http://money.cnn.com",
+      title: "http://www.cnn.com",
+      description: "new description"
+    },
+    { livemark: "LivemarkOne",
+      feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
+      siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
+    },
+    { folder: "Folder B",
+      description: "folder description"
+    }
+  ],
+  "menu/foldera/Folder B": [
+    { uri: "http://www.yahoo.com",
+      title: "testing Yahoo"
+    }
+  ]
+};
+
+// a list of bookmarks which should not be present after the last
+// 'delete' and 'modify' actions
+var bookmarks_absent = {
+  "menu": [
+    { uri: "http://www.google.com",
+      title: "Google"
+    },
+    { folder: "folderb" },
+    { folder: "Folder B" }
+  ]
+};
+
+/*
+ * History asset lists: these define history entries that are used during
+ * the test
+ */
+
+// the initial list of history items to add to the browser
+var history_initial = [
+  { uri: "http://www.google.com/",
+    title: "Google",
+    visits: [
+      { type: 1, date: 0 },
+      { type: 2, date: -1 }
+    ]
+  },
+  { uri: "http://www.cnn.com/",
+    title: "CNN",
+    visits: [
+      { type: 1, date: -1 },
+      { type: 2, date: -36 }
+    ]
+  },
+  { uri: "http://www.google.com/language_tools?hl=en",
+    title: "Language Tools",
+    visits: [
+      { type: 1, date: 0 },
+      { type: 2, date: -40 }
+    ]
+  },
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1, date: 0 },
+      { type: 1, date: -1 },
+      { type: 1, date: -20 },
+      { type: 2, date: -36 }
+    ]
+  }
+];
+
+// a list of history entries to delete during a 'delete' action
+var history_to_delete = [
+  { uri: "http://www.cnn.com/" },
+  { begin: -24,
+    end: -1 },
+  { host: "www.google.com" }
+];
+
+// the expected history entries after the first 'delete' action
+var history_after_delete = [
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  }
+];
+
+// history entries expected to not exist after a 'delete' action
+var history_absent = [
+  { uri: "http://www.google.com/",
+    title: "Google",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -1
+      }
+    ]
+  },
+  { uri: "http://www.cnn.com/",
+    title: "CNN",
+    visits: [
+      { type: 1,
+        date: -1
+      },
+      { type: 2,
+        date: -36
+      }
+    ]
+  },
+  { uri: "http://www.google.com/language_tools?hl=en",
+    title: "Language Tools",
+    visits: [
+      { type: 1,
+        date: 0
+      },
+      { type: 2,
+        date: -40
+      }
+    ]
+  },
+  { uri: "http://www.mozilla.com/",
+    title: "Mozilla",
+    visits: [
+      { type: 1,
+        date: -1
+      },
+      { type: 1,
+        date: -20
+      }
+    ]
+  }
+];
+
+/*
+ * Password asset lists: these define password entries that are used during
+ * the test
+ */
+
+// the initial list of passwords to add to the browser
+var passwords_initial = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "SeCrEt123",
+    usernameField: "uname",
+    passwordField: "pword",
+    changes: {
+      password: "zippity-do-dah"
+    }
+  },
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+// the expected state of passwords after the first 'modify' action
+var passwords_after_first_modify = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "zippity-do-dah",
+    usernameField: "uname",
+    passwordField: "pword"
+  },
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+// a list of passwords to delete during a 'delete' action
+var passwords_to_delete = [
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+// a list of passwords expected to be absent after 'delete' and 'modify'
+// actions
+var passwords_absent = [
+  { hostname: "http://www.example.com",
+    realm: "login",
+    username: "joe",
+    password: "secretlogin"
+  }
+];
+
+// the expected state of passwords after the seconds 'modify' action
+var passwords_after_second_modify = [
+  { hostname: "http://www.example.com",
+    submitURL: "http://login.example.com",
+    username: "joe",
+    password: "zippity-do-dah",
+    usernameField: "uname",
+    passwordField: "pword"
+  }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Bookmarks.add, bookmarks_initial],
+  [Passwords.add, passwords_initial],
+  [History.add, history_initial],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_initial],
+  [Passwords.verify, passwords_initial],
+  [History.verify, history_initial],
+  [Bookmarks.modify, bookmarks_initial],
+  [Passwords.modify, passwords_initial],
+  [History.delete, history_to_delete],
+  [Bookmarks.verify, bookmarks_after_first_modify],
+  [Passwords.verify, passwords_after_first_modify],
+  [History.verify, history_after_delete],
+  [History.verifyNot, history_absent],
+  [Sync],
+]);
+
+Phase('phase3', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_after_first_modify],
+  [Passwords.verify, passwords_after_first_modify],
+  [History.verify, history_after_delete],
+  [Bookmarks.modify, bookmarks_after_first_modify],
+  [Passwords.modify, passwords_after_first_modify],
+  [Bookmarks.delete, bookmarks_to_delete],
+  [Passwords.delete, passwords_to_delete],
+  [Bookmarks.verify, bookmarks_after_second_modify],
+  [Passwords.verify, passwords_after_second_modify],
+  [Bookmarks.verifyNot, bookmarks_absent],
+  [Passwords.verifyNot, passwords_absent],
+  [Sync],
+]);
+
+Phase('phase4', [
+  [Sync],
+  [Bookmarks.verify, bookmarks_after_second_modify],
+  [Passwords.verify, passwords_after_second_modify],
+  [Bookmarks.verifyNot, bookmarks_absent],
+  [Passwords.verifyNot, passwords_absent],
+  [History.verifyNot, history_absent],
+]);
+
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/tps/test_tabs.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * The list of phases mapped to their corresponding profiles.  The object
+ * here must be in strict JSON format, as it will get parsed by the Python
+ * testrunner (no single quotes, extra comma's, etc).
+ */
+
+var phases = { "phase1": "profile1",
+               "phase2": "profile2",
+               "phase3": "profile1"};
+
+/*
+ * Tab lists.
+ */
+
+var tabs1 = [
+  { uri: "http://hg.mozilla.org/automation/crossweave/raw-file/2d9aca9585b6/pages/page1.html",
+    title: "Crossweave Test Page 1",
+    profile: "profile1"
+  },
+  { uri: "data:text/html,<html><head><title>Hello</title></head><body>Hello</body></html>",
+    title: "Hello",
+    profile: "profile1"
+  }
+];
+
+var tabs2 = [
+  { uri: "http://hg.mozilla.org/automation/crossweave/raw-file/2d9aca9585b6/pages/page3.html",
+    title: "Crossweave Test Page 3",
+    profile: "profile2"
+  },
+  { uri: "data:text/html,<html><head><title>Bye</title></head><body>Bye</body></html>",
+    profile: "profile2"
+  }
+];
+
+/*
+ * Test phases
+ */
+
+Phase('phase1', [
+  [Tabs.add, tabs1],
+  [Sync, SYNC_WIPE_SERVER],
+]);
+
+Phase('phase2', [
+  [Sync],
+  [Tabs.verify, tabs1],
+  [Tabs.add, tabs2],
+  [Sync]
+]);
+
+Phase('phase3', [
+  [Sync],
+  [Tabs.verify, tabs2]
+]);
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/chrome.manifest
@@ -0,0 +1,4 @@
+resource tps modules/
+component {4e5bd3f0-41d3-11df-9879-0800200c9a66} components/tps-cmdline.js
+contract @mozilla.org/commandlinehandler/general-startup;1?type=tps {4e5bd3f0-41d3-11df-9879-0800200c9a66}
+category command-line-handler m-tps @mozilla.org/commandlinehandler/general-startup;1?type=tps
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/components/tps-cmdline.js
@@ -0,0 +1,186 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is TPS.
+ *
+ * The Initial Developer of the Original Code is
+ * Christopher A. Aillon <christopher@aillon.com>.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Christopher A. Aillon <christopher@aillon.com>
+ *   L. David Baron, Mozilla Corporation <dbaron@dbaron.org> (modified for reftest)
+ *   Jonathan Griffin <jgriffin@mozilla.com> (modified for TPS)
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+
+const TPS_ID                         = "tps@mozilla.org";
+const TPS_CMDLINE_CONTRACTID         = "@mozilla.org/commandlinehandler/general-startup;1?type=tps";
+const TPS_CMDLINE_CLSID              = Components.ID('{4e5bd3f0-41d3-11df-9879-0800200c9a66}');
+const CATMAN_CONTRACTID              = "@mozilla.org/categorymanager;1";
+const nsISupports                    = Components.interfaces.nsISupports;
+  
+const nsICategoryManager             = Components.interfaces.nsICategoryManager;
+const nsICmdLineHandler              = Components.interfaces.nsICmdLineHandler;
+const nsICommandLine                 = Components.interfaces.nsICommandLine;
+const nsICommandLineHandler          = Components.interfaces.nsICommandLineHandler;
+const nsIComponentRegistrar          = Components.interfaces.nsIComponentRegistrar;
+const nsISupportsString              = Components.interfaces.nsISupportsString;
+const nsIWindowWatcher               = Components.interfaces.nsIWindowWatcher;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+function TPSCmdLineHandler() {}
+TPSCmdLineHandler.prototype =
+{
+  classDescription: "TPSCmdLineHandler",
+  classID         : TPS_CMDLINE_CLSID,
+  contractID      : TPS_CMDLINE_CONTRACTID,
+
+  QueryInterface: XPCOMUtils.generateQI([nsISupports,
+                                         nsICommandLineHandler,
+                                         nsICmdLineHandler]),   /* nsISupports */
+
+  /* nsICmdLineHandler */
+  commandLineArgument : "-tps",
+  prefNameForStartup : "general.startup.tps",
+  helpText : "Run TPS tests with the given test file.",
+  handlesArgs : true,
+  defaultArgs : "",
+  openWindowWithArgs : true,
+
+  /* nsICommandLineHandler */
+  handle : function handler_handle(cmdLine) {
+    var uristr = cmdLine.handleFlagWithParam("tps", false);
+    if (uristr == null)
+        return;
+    var phase = cmdLine.handleFlagWithParam("tpsphase", false);
+    if (phase == null)
+        throw("must specify --tpsphase with --tps");
+    var logfile = cmdLine.handleFlagWithParam("tpslogfile", false);
+    if (logfile == null)
+        logfile = "";
+
+    let uri = cmdLine.resolveURI(uristr).spec;
+
+    /* Ignore the platform's online/offline status while running tests. */
+    var ios = Components.classes["@mozilla.org/network/io-service;1"]
+              .getService(Components.interfaces.nsIIOService2);
+    ios.manageOfflineStatus = false;
+    ios.offline = false;
+             
+    Components.utils.import("resource://tps/tps.jsm");
+    Components.utils.import("resource://tps/quit.js", TPS);
+    TPS.RunTestPhase(uri, phase, logfile);
+    
+    //cmdLine.preventDefault = true;
+  },
+
+  helpInfo : "  -tps <file>               Run TPS tests with the given test file.\n" +
+             "  -tpsphase <phase>         Run the specified phase in the TPS test.\n" +
+             "  -tpslogfile <file>        Logfile for TPS output.\n",
+};
+
+
+var TPSCmdLineFactory =
+{
+  createInstance : function(outer, iid)
+  {
+    if (outer != null) {
+      throw Components.results.NS_ERROR_NO_AGGREGATION;
+    }
+
+    return new TPSCmdLineHandler().QueryInterface(iid);
+  }
+};
+
+
+var TPSCmdLineModule =
+{
+  registerSelf : function(compMgr, fileSpec, location, type)
+  {
+    compMgr = compMgr.QueryInterface(nsIComponentRegistrar);
+
+    compMgr.registerFactoryLocation(TPS_CMDLINE_CLSID,
+                                    "TPS CommandLine Service",
+                                    TPS_CMDLINE_CONTRACTID,
+                                    fileSpec,
+                                    location,
+                                    type);
+
+    var catman = Components.classes[CATMAN_CONTRACTID].getService(nsICategoryManager);
+    catman.addCategoryEntry("command-line-argument-handlers",
+                            "TPS command line handler",
+                            TPS_CMDLINE_CONTRACTID, true, true);
+    catman.addCategoryEntry("command-line-handler",
+                            "m-tps",
+                            TPS_CMDLINE_CONTRACTID, true, true);
+  },
+
+  unregisterSelf : function(compMgr, fileSpec, location)
+  {
+    compMgr = compMgr.QueryInterface(nsIComponentRegistrar);
+
+    compMgr.unregisterFactoryLocation(TPS_CMDLINE_CLSID, fileSpec);
+    catman = Components.classes[CATMAN_CONTRACTID].getService(nsICategoryManager);
+    catman.deleteCategoryEntry("command-line-argument-handlers",
+                               "TPS command line handler", true);
+    catman.deleteCategoryEntry("command-line-handler",
+                               "m-tps", true);
+  },
+
+  getClassObject : function(compMgr, cid, iid)
+  {
+    if (cid.equals(TPS_CMDLINE_CLSID)) {
+      return TPSCmdLineFactory;
+    }
+
+    if (!iid.equals(Components.interfaces.nsIFactory)) {
+      throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+    }
+
+    throw Components.results.NS_ERROR_NO_INTERFACE;
+  },
+
+  canUnload : function(compMgr)
+  {
+    return true;
+  }
+};
+
+/**
+* XPCOMUtils.generateNSGetFactory was introduced in Mozilla 2 (Firefox 4).
+* XPCOMUtils.generateNSGetModule is for Mozilla 1.9.2 (Firefox 3.6).
+*/
+if (XPCOMUtils.generateNSGetFactory)
+    var NSGetFactory = XPCOMUtils.generateNSGetFactory([TPSCmdLineHandler]);
+
+function NSGetModule(compMgr, fileSpec) {
+  return TPSCmdLineModule;
+}
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/install.rdf
@@ -0,0 +1,23 @@
+<?xml version="1.0"?>
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>tps@mozilla.org</em:id>
+    <em:version>0.2</em:version>
+
+    <em:targetApplication>
+      <!-- Firefox -->
+      <Description>
+        <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
+        <em:minVersion>3.5.0</em:minVersion>
+        <em:maxVersion>8.0.*</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+
+    <!-- front-end metadata -->
+    <em:name>TPS</em:name>
+    <em:description>Sync test extension</em:description>
+    <em:creator>Jonathan Griffin</em:creator>
+    <em:homepageURL>http://www.mozilla.org/</em:homepageURL>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/bookmarks.jsm
@@ -0,0 +1,1017 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *   Philipp von Weitershausen <philipp@weitershausen.de>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+  * Components.utils.import() and acts as a singleton. Only the following 
+  * listed symbols will exposed on import, and only when and where imported.
+  */
+
+var EXPORTED_SYMBOLS = ["PlacesItem", "Bookmark", "Separator", "Livemark",
+                        "BookmarkFolder"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+CU.import("resource://tps/logger.jsm");
+CU.import("resource://gre/modules/Services.jsm");
+CU.import("resource://gre/modules/PlacesUtils.jsm");
+
+/**
+ * extend, causes a child object to inherit from a parent
+ */
+function extend(child, supertype)
+{
+   child.prototype.__proto__ = supertype.prototype;
+}
+
+/**
+ * PlacesItemProps object, holds properties for places items
+ */
+function PlacesItemProps(props) {
+  this.location = null;
+  this.uri = null;
+  this.loadInSidebar = null;
+  this.keyword = null;
+  this.title = null;
+  this.description = null;
+  this.after = null;
+  this.before = null;
+  this.folder = null;
+  this.position = null;
+  this.delete = false;
+  this.siteUri = null;
+  this.feedUri = null;
+  this.livemark = null;
+  this.tags = null;
+  this.last_item_pos = null;
+  this.type = null;
+
+  for (var prop in props) {
+    if (prop in this)
+      this[prop] = props[prop];
+  }
+}
+
+/**
+ * PlacesItem object.  Base class for places items.
+ */
+function PlacesItem(props) {
+  this.props = new PlacesItemProps(props);
+  if (this.props.location == null)
+    this.props.location = "menu";
+  if ("changes" in props)
+    this.updateProps = new PlacesItemProps(props.changes);
+  else
+    this.updateProps = null;
+}
+
+/**
+ * Instance methods for generic places items.
+ */
+PlacesItem.prototype = {
+  // an array of possible root folders for places items
+  _bookmarkFolders: {
+    "places": "placesRoot",
+    "menu": "bookmarksMenuFolder",
+    "tags": "tagFolder",
+    "unfiled": "unfiledBookmarksFolder",
+    "toolbar": "toolbarFolder",
+  },
+
+  toString: function() {
+    var that = this;
+    var props = ['uri', 'title', 'location', 'folder', 'feedUri', 'siteUri', 'livemark'];
+    var string = (this.props.type ? this.props.type + " " : "") +
+      "(" +
+      (function() {
+        var ret = [];
+        for (var i in props) {
+          if (that.props[props[i]]) {
+            ret.push(props[i] + ": " + that.props[props[i]])
+          }
+        }
+        return ret;
+      })().join(", ") + ")";
+    return string;
+  },
+
+  /**
+   * GetPlacesNodeId
+   *
+   * Finds the id of the an item with the specified properties in the places
+   * database.
+   *
+   * @param folder The id of the folder to search
+   * @param type The type of the item to find, or null to match any item;
+   *        this is one of the values listed at
+   *        https://developer.mozilla.org/en/nsINavHistoryResultNode#Constants
+   * @param title The title of the item to find, or null to match any title
+   * @param uri The uri of the item to find, or null to match any uri
+   *
+   * @return the node id if the item was found, otherwise -1
+   */
+  GetPlacesNodeId: function (folder, type, title, uri) {
+    let node_id = -1;
+  
+    let options = PlacesUtils.history.getNewQueryOptions();
+    let query = PlacesUtils.history.getNewQuery();
+    query.setFolders([folder], 1);
+    let result = PlacesUtils.history.executeQuery(query, options);
+    let rootNode = result.root;
+    rootNode.containerOpen = true;
+
+    for (let j = 0; j < rootNode.childCount; j ++) {
+      let node = rootNode.getChild(j);
+      if (node.title == title) {
+        if (type == null || type == undefined || node.type == type)
+          if (uri == undefined || uri == null || node.uri.spec == uri.spec)
+            node_id = node.itemId;
+      }
+    }
+    rootNode.containerOpen = false;
+    
+    return node_id;
+  },
+
+  /**
+   * IsAdjacentTo
+   *
+   * Determines if this object is immediately adjacent to another.
+   *
+   * @param itemName The name of the other object; this may be any kind of
+   *        places item
+   * @param relativePos The relative position of the other object.  If -1, 
+   *        it means the other object should precede this one, if +1,
+   *        the other object should come after this one
+   * @return true if this object is immediately adjacent to the other object,
+   *         otherwise false
+   */
+  IsAdjacentTo: function(itemName, relativePos) {
+    Logger.AssertTrue(this.props.folder_id != -1 && this.props.item_id != -1,
+      "Either folder_id or item_id was invalid");
+    let other_id = this.GetPlacesNodeId(this.props.folder_id, null, itemName);
+    Logger.AssertTrue(other_id != -1, "item " + itemName + " not found");
+    let other_pos = PlacesUtils.bookmarks.getItemIndex(other_id);
+    let this_pos = PlacesUtils.bookmarks.getItemIndex(this.props.item_id);
+    if (other_pos + relativePos != this_pos) {
+      Logger.logPotentialError("Invalid position - " + 
+       (this.props.title ? this.props.title : this.props.folder) +
+      " not " + (relativePos == 1 ? "after " : "before ") + itemName + 
+      " for " + this.toString());
+      return false;
+    }
+    return true;
+  },
+
+  /**
+   * GetItemIndex
+   *
+   * Gets the item index for this places item.
+   *
+   * @return the item index, or -1 if there's an error
+   */
+  GetItemIndex: function() {
+    if (this.props.item_id == -1)
+      return -1;
+    return PlacesUtils.bookmarks.getItemIndex(this.props.item_id);
+  },
+
+  /**
+   * GetFolder
+   *
+   * Gets the folder id for the specified bookmark folder
+   *
+   * @param location The full path of the folder, which must begin
+   *        with one of the bookmark root folders
+   * @return the folder id if the folder is found, otherwise -1
+   */
+  GetFolder: function(location) {
+    let folder_parts = location.split("/");
+    if (!(folder_parts[0] in this._bookmarkFolders)) {
+      return -1;
+    }
+    let folder_id = PlacesUtils.bookmarks[this._bookmarkFolders[folder_parts[0]]];
+    for (let i = 1; i < folder_parts.length; i++) {
+      let subfolder_id = this.GetPlacesNodeId(
+        folder_id, 
+        CI.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 
+        folder_parts[i]);
+      if (subfolder_id == -1) {
+        return -1;
+      }
+      else {
+        folder_id = subfolder_id;
+      }
+    }
+    return folder_id;
+  },
+
+  /**
+   * CreateFolder
+   *
+   * Creates a bookmark folder.
+   *
+   * @param location The full path of the folder, which must begin
+   *        with one of the bookmark root folders
+   * @return the folder id if the folder was created, otherwise -1
+   */
+  CreateFolder: function(location) {
+    let folder_parts = location.split("/");
+    if (!(folder_parts[0] in this._bookmarkFolders)) {
+      return -1;
+    }
+    let folder_id = PlacesUtils.bookmarks[this._bookmarkFolders[folder_parts[0]]];
+    for (let i = 1; i < folder_parts.length; i++) {
+      let subfolder_id = this.GetPlacesNodeId(
+        folder_id, 
+        CI.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 
+        folder_parts[i]);
+      if (subfolder_id == -1) {
+        folder_id = PlacesUtils.bookmarks.createFolder(folder_id, 
+                                                 folder_parts[i], -1);
+      }
+      else {
+        folder_id = subfolder_id;
+      }
+    }
+    return folder_id;
+  },
+
+  /**
+   * GetOrCreateFolder
+   *
+   * Locates the specified folder; if not found it is created.
+   *
+   * @param location The full path of the folder, which must begin
+   *        with one of the bookmark root folders
+   * @return the folder id if the folder was found or created, otherwise -1
+   */
+  GetOrCreateFolder: function(location) {
+    folder_id = this.GetFolder(location);
+    if (folder_id == -1)
+      folder_id = this.CreateFolder(location);
+    return folder_id;
+  },
+
+  /**
+   * CheckDescription
+   *
+   * Compares the description of this places item with an expected
+   * description.
+   *
+   * @param expectedDescription The description this places item is
+   *        expected to have
+   * @return true if the actual and expected descriptions match, or if
+   *         there is no expected description; otherwise false
+   */
+  CheckDescription: function(expectedDescription) {
+    if (expectedDescription != null) {
+      let description = "";
+      if (PlacesUtils.annotations.itemHasAnnotation(this.props.item_id, 
+          "bookmarkProperties/description")) {
+        description = PlacesUtils.annotations.getItemAnnotation(
+          this.props.item_id, "bookmarkProperties/description");
+      }
+      if (description != expectedDescription) {
+        Logger.logPotentialError("Invalid description, expected: " + 
+          expectedDescription + ", actual: " + description + " for " +
+          this.toString());
+        return false;
+      }
+    }
+    return true;
+  },
+
+  /**
+   * CheckPosition
+   *
+   * Verifies the position of this places item.
+   *
+   * @param before The name of the places item that this item should be
+            before, or null if this check should be skipped
+   * @param after The name of the places item that this item should be
+            after, or null if this check should be skipped
+   * @param last_item_pos The index of the places item above this one,
+   *        or null if this check should be skipped
+   * @return true if this item is in the correct position, otherwise false
+   */
+  CheckPosition: function(before, after, last_item_pos) {
+    if (after)
+      if (!this.IsAdjacentTo(after, 1)) return false;
+    if (before)
+      if (!this.IsAdjacentTo(before, -1)) return false;
+    if (last_item_pos != null && last_item_pos > -1) {
+      if (this.GetItemIndex() != last_item_pos + 1) {
+        Logger.logPotentialError("Item not found at the expected index, got " +
+          this.GetItemIndex() + ", expected " + (last_item_pos + 1) + " for " +
+          this.toString());
+        return false;
+      }
+    }
+    return true;
+  },
+
+  /**
+   * SetLocation
+   *
+   * Moves this places item to a different folder.
+   *
+   * @param location The full path of the folder to which to move this
+   *        places item, which must begin with one of the bookmark root
+   *        folders; if null, no changes are made
+   * @return nothing if successful, otherwise an exception is thrown
+   */
+  SetLocation: function(location) {
+    if (location != null) {
+      let newfolder_id = this.GetOrCreateFolder(location);
+      Logger.AssertTrue(newfolder_id != -1, "Location " + location + 
+                        " doesn't exist; can't change item's location");
+      PlacesUtils.bookmarks.moveItem(this.props.item_id, newfolder_id, -1);
+      this.props.folder_id = newfolder_id;
+    }
+  },
+
+  /**
+   * SetDescription
+   *
+   * Updates the description for this places item.
+   *
+   * @param description The new description to set; if null, no changes are
+   *        made
+   * @return nothing
+   */
+  SetDescription: function(description) {
+    if (description != null) {
+      if (description != "")
+        PlacesUtils.annotations.setItemAnnotation(this.props.item_id, 
+                                      "bookmarkProperties/description", 
+                                      description, 
+                                      0, 
+                                      PlacesUtils.annotations.EXPIRE_NEVER);
+      else
+        PlacesUtils.annotations.removeItemAnnotation(this.props.item_id,
+                                         "bookmarkProperties/description");
+    }
+  },
+
+  /**
+   * SetPosition
+   *
+   * Updates the position of this places item within this item's current
+   * folder.  Use SetLocation to change folders.
+   *
+   * @param position The new index this item should be moved to; if null,
+   *        no changes are made; if -1, this item is moved to the bottom of
+   *        the current folder
+   * @return nothing if successful, otherwise an exception is thrown
+   */
+  SetPosition: function(position) {
+    if (position != null) {
+      let newposition = -1;
+      if (position != -1) {
+        newposition = this.GetPlacesNodeId(this.props.folder_id, 
+                                           null, position);
+        Logger.AssertTrue(newposition != -1, "position " + position +
+                          " is invalid; unable to change position");
+        newposition = PlacesUtils.bookmarks.getItemIndex(newposition);
+      }
+      PlacesUtils.bookmarks.moveItem(this.props.item_id, 
+                               this.props.folder_id, newposition);
+    }
+  },
+
+  /**
+   * Update the title of this places item
+   *
+   * @param title The new title to set for this item; if null, no changes
+   *        are made
+   * @return nothing
+   */
+  SetTitle: function(title) {
+    if (title != null) {
+      PlacesUtils.bookmarks.setItemTitle(this.props.item_id, title);
+    }
+  },
+};
+
+/**
+ * Bookmark class constructor.  Initializes instance properties.
+ */
+function Bookmark(props) {
+  PlacesItem.call(this, props);
+  if (this.props.title == null)
+    this.props.title = this.props.uri;
+  this.props.type = "bookmark";
+}
+
+/**
+ * Bookmark instance methods.
+ */
+Bookmark.prototype = {
+  /**
+   * SetKeyword
+   *
+   * Update this bookmark's keyword.
+   *
+   * @param keyword The keyword to set for this bookmark; if null, no
+   *        changes are made
+   * @return nothing
+   */
+  SetKeyword: function(keyword) {
+    if (keyword != null)
+      PlacesUtils.bookmarks.setKeywordForBookmark(this.props.item_id, keyword);
+  },
+
+  /**
+   * SetLoadInSidebar
+   *
+   * Updates this bookmark's loadInSidebar property.
+   *
+   * @param loadInSidebar if true, the loadInSidebar property will be set,
+   *        if false, it will be cleared, and any other value will result
+   *        in no change
+   * @return nothing
+   */
+  SetLoadInSidebar: function(loadInSidebar) {
+    if (loadInSidebar == true)
+      PlacesUtils.annotations.setItemAnnotation(this.props.item_id, 
+                                    "bookmarkProperties/loadInSidebar", 
+                                    true, 
+                                    0, 
+                                    PlacesUtils.annotations.EXPIRE_NEVER);
+    else if (loadInSidebar == false)
+      PlacesUtils.annotations.removeItemAnnotation(this.props.item_id, 
+                                       "bookmarkProperties/loadInSidebar");
+  },
+
+  /**
+   * SetTitle
+   *
+   * Updates this bookmark's title.
+   *
+   * @param title The new title to set for this boomark; if null, no changes
+   *        are made
+   * @return nothing
+   */
+  SetTitle: function(title) {
+    if (title)
+      PlacesUtils.bookmarks.setItemTitle(this.props.item_id, title);
+  },
+
+  /**
+   * SetUri
+   *
+   * Updates this bookmark's URI.
+   *
+   * @param uri The new URI to set for this boomark; if null, no changes
+   *        are made
+   * @return nothing
+   */
+  SetUri: function(uri) {
+    if (uri) {
+      let newURI = Services.io.newURI(uri, null, null);
+      PlacesUtils.bookmarks.changeBookmarkURI(this.props.item_id, newURI);
+    }
+  },
+
+  /**
+   * SetTags
+   *
+   * Updates this bookmark's tags.
+   *
+   * @param tags An array of tags which should be associated with this
+   *        bookmark; any previous tags are removed; if this param is null,
+   *        no changes are made.  If this param is an empty array, all
+   *        tags are removed from this bookmark.
+   * @return nothing
+   */
+  SetTags: function(tags) {
+    if (tags != null) {
+      let URI = Services.io.newURI(this.props.uri, null, null);
+      PlacesUtils.tagging.untagURI(URI, null);
+      if (tags.length > 0)
+        PlacesUtils.tagging.tagURI(URI, tags);
+    }
+  },
+
+  /**
+   * Create
+   *
+   * Creates the bookmark described by this object's properties.
+   *
+   * @return the id of the created bookmark
+   */
+  Create: function() {
+    this.props.folder_id = this.GetOrCreateFolder(this.props.location);
+    Logger.AssertTrue(this.props.folder_id != -1, "Unable to create " +
+      "bookmark, error creating folder " + this.props.location);
+    let bookmarkURI = Services.io.newURI(this.props.uri, null, null);
+    this.props.item_id = PlacesUtils.bookmarks.insertBookmark(this.props.folder_id, 
+                                                        bookmarkURI, 
+                                                        -1, 
+                                                        this.props.title);
+    this.SetKeyword(this.props.keyword);
+    this.SetDescription(this.props.description);
+    this.SetLoadInSidebar(this.props.loadInSidebar);
+    this.SetTags(this.props.tags);
+    return this.props.item_id;
+  },
+
+  /**
+   * Update
+   *
+   * Updates this bookmark's properties according the properties on this
+   * object's 'updateProps' property.
+   *
+   * @return nothing
+   */
+  Update: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Remove");
+    this.SetKeyword(this.updateProps.keyword);
+    this.SetDescription(this.updateProps.description);
+    this.SetLoadInSidebar(this.updateProps.loadInSidebar);
+    this.SetTitle(this.updateProps.title);
+    this.SetUri(this.updateProps.uri);
+    this.SetTags(this.updateProps.tags);
+    this.SetLocation(this.updateProps.location);
+    this.SetPosition(this.updateProps.position);
+  },
+
+  /**
+   * Find
+   *
+   * Locates the bookmark which corresponds to this object's properties.
+   *
+   * @return the bookmark id if the bookmark was found, otherwise -1
+   */
+  Find: function() {
+    this.props.folder_id = this.GetFolder(this.props.location);
+    if (this.props.folder_id == -1) {
+      Logger.logError("Unable to find folder " + this.props.location);
+      return -1;
+    }
+    let bookmarkTitle = this.props.title;
+    this.props.item_id = this.GetPlacesNodeId(this.props.folder_id, 
+                                              null,
+                                              bookmarkTitle,
+                                              this.props.uri);
+
+    if (this.props.item_id == -1) {
+      Logger.logPotentialError(this.toString() + " not found");
+      return -1;
+    }
+    if (!this.CheckDescription(this.props.description))
+      return -1;
+    if (this.props.keyword != null) {
+      let keyword = PlacesUtils.bookmarks.getKeywordForBookmark(this.props.item_id);
+      if (keyword != this.props.keyword) {
+        Logger.logPotentialError("Incorrect keyword - expected: " +
+          this.props.keyword + ", actual: " + keyword +
+          " for " + this.toString());
+        return -1;
+      }
+    }
+    let loadInSidebar = PlacesUtils.annotations.itemHasAnnotation(
+      this.props.item_id, 
+      "bookmarkProperties/loadInSidebar");
+    if (loadInSidebar)
+      loadInSidebar = PlacesUtils.annotations.getItemAnnotation(
+        this.props.item_id, 
+        "bookmarkProperties/loadInSidebar");
+    if (this.props.loadInSidebar != null && 
+        loadInSidebar != this.props.loadInSidebar) {
+      Logger.logPotentialError("Incorrect loadInSidebar setting - expected: " +
+        this.props.loadInSidebar + ", actual: " + loadInSidebar +
+        " for " + this.toString());
+      return -1;
+    }
+    if (this.props.tags != null) {
+      try {
+        let URI = Services.io.newURI(this.props.uri, null, null);
+        let tags = PlacesUtils.tagging.getTagsForURI(URI, {});
+        tags.sort();
+        this.props.tags.sort();
+        if (JSON.stringify(tags) != JSON.stringify(this.props.tags)) {
+          Logger.logPotentialError("Wrong tags - expected: " +
+            JSON.stringify(this.props.tags) + ", actual: " +
+            JSON.stringify(tags) + " for " + this.toString());
+          return -1;
+        }
+      }
+      catch (e) {
+        Logger.logPotentialError("error processing tags " + e);
+        return -1;
+      }
+    }
+    if (!this.CheckPosition(this.props.before, 
+                            this.props.after, 
+                            this.props.last_item_pos))
+      return -1;
+    return this.props.item_id;
+  },
+
+  /**
+   * Remove
+   *
+   * Removes this bookmark.  The bookmark should have been located previously
+   * by a call to Find.
+   *
+   * @return nothing
+   */
+  Remove: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Remove");
+    PlacesUtils.bookmarks.removeItem(this.props.item_id);
+  },
+};
+      
+extend(Bookmark, PlacesItem);
+
+/**
+ * BookmarkFolder class constructor. Initializes instance properties.
+ */
+function BookmarkFolder(props) {
+  PlacesItem.call(this, props);
+  this.props.type = "folder";
+}
+
+/**
+ * BookmarkFolder instance methods
+ */
+BookmarkFolder.prototype = {
+  /**
+   * Create
+   *
+   * Creates the bookmark folder described by this object's properties.
+   *
+   * @return the id of the created bookmark folder
+   */
+  Create: function() {
+    this.props.folder_id = this.GetOrCreateFolder(this.props.location);
+    Logger.AssertTrue(this.props.folder_id != -1, "Unable to create " +
+      "folder, error creating parent folder " + this.props.location);
+    this.props.item_id = PlacesUtils.bookmarks.createFolder(this.props.folder_id, 
+                                                      this.props.folder, 
+                                                      -1);
+    this.SetDescription(this.props.description);
+    return this.props.folder_id;
+  },
+
+  /**
+   * Find
+   *
+   * Locates the bookmark folder which corresponds to this object's 
+   * properties.
+   *
+   * @return the folder id if the folder was found, otherwise -1
+   */
+  Find: function() {
+    this.props.folder_id = this.GetFolder(this.props.location);
+    if (this.props.folder_id == -1) {
+      Logger.logError("Unable to find folder " + this.props.location);
+      return -1;
+    }
+    this.props.item_id = this.GetPlacesNodeId(
+                              this.props.folder_id, 
+                              CI.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 
+                              this.props.folder);
+    if (!this.CheckDescription(this.props.description))
+      return -1;
+    if (!this.CheckPosition(this.props.before, 
+                            this.props.after, 
+                            this.props.last_item_pos))
+      return -1;
+    return this.props.item_id;
+  },
+
+  /**
+   * Remove
+   *
+   * Removes this folder.  The folder should have been located previously
+   * by a call to Find.
+   *
+   * @return nothing
+   */
+  Remove: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Remove");
+    PlacesUtils.bookmarks.removeFolderChildren(this.props.item_id);
+    PlacesUtils.bookmarks.removeItem(this.props.item_id);
+  },
+
+  /**
+   * Update
+   *
+   * Updates this bookmark's properties according the properties on this
+   * object's 'updateProps' property.
+   *
+   * @return nothing
+   */
+  Update: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Update");
+    this.SetLocation(this.updateProps.location);
+    this.SetPosition(this.updateProps.position);
+    this.SetTitle(this.updateProps.folder);
+    this.SetDescription(this.updateProps.description);
+  },
+};
+
+extend(BookmarkFolder, PlacesItem);
+
+/**
+ * Livemark class constructor. Initialzes instance properties.
+ */
+function Livemark(props) {
+  PlacesItem.call(this, props);
+  this.props.type = "livemark";
+}
+
+/**
+ * Livemark instance methods
+ */
+Livemark.prototype = {
+  /**
+   * Create
+   *
+   * Creates the livemark described by this object's properties.
+   *
+   * @return the id of the created livemark
+   */
+  Create: function() {
+    this.props.folder_id = this.GetOrCreateFolder(this.props.location);
+    Logger.AssertTrue(this.props.folder_id != -1, "Unable to create " +
+      "folder, error creating parent folder " + this.props.location);
+    let siteURI = null;
+    if (this.props.siteUri != null)
+      siteURI = Services.io.newURI(this.props.siteUri, null, null);
+    this.props.item_id = PlacesUtils.livemarks.createLivemark(
+        this.props.folder_id, 
+        this.props.livemark, 
+        siteURI,
+        Services.io.newURI(this.props.feedUri, null, null), 
+        -1);
+    return this.props.item_id;
+  },
+
+  /**
+   * Find
+   *
+   * Locates the livemark which corresponds to this object's 
+   * properties.
+   *
+   * @return the item id if the livemark was found, otherwise -1
+   */
+  Find: function() {
+    this.props.folder_id = this.GetFolder(this.props.location);
+    if (this.props.folder_id == -1) {
+      Logger.logError("Unable to find folder " + this.props.location);
+      return -1;
+    }
+    this.props.item_id = this.GetPlacesNodeId(
+                              this.props.folder_id, 
+                              CI.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 
+                              this.props.livemark);
+    if (!PlacesUtils.livemarks.isLivemark(this.props.item_id)) {
+      Logger.logPotentialError("livemark folder found, but it's just a regular folder, for " +
+        this.toString());
+      this.props.item_id = -1;
+      return -1;
+    }
+    let feedURI = Services.io.newURI(this.props.feedUri, null, null);
+    let lmFeedURI = PlacesUtils.livemarks.getFeedURI(this.props.item_id);
+    if (feedURI.spec != lmFeedURI.spec) {
+      Logger.logPotentialError("livemark feed uri not correct, expected: " +
+        this.props.feedUri + ", actual: " + lmFeedURI.spec +
+        " for " + this.toString());
+      return -1;
+    }
+    if (this.props.siteUri != null) {
+      let siteURI = Services.io.newURI(this.props.siteUri, null, null);
+      let lmSiteURI = PlacesUtils.livemarks.getSiteURI(this.props.item_id);
+      if (siteURI.spec != lmSiteURI.spec) {
+        Logger.logPotentialError("livemark site uri not correct, expected: " +
+        this.props.siteUri + ", actual: " + lmSiteURI.spec + " for " +
+        this.toString());
+        return -1;
+      }
+    }
+    if (!this.CheckPosition(this.props.before, 
+                            this.props.after, 
+                            this.props.last_item_pos))
+      return -1;
+    return this.props.item_id;
+  },
+
+  /**
+   * SetSiteUri
+   *
+   * Sets the siteURI property for this livemark.
+   *
+   * @param siteUri the URI to set; if null, no changes are made
+   * @return nothing
+   */
+  SetSiteUri: function(siteUri) {
+    if (siteUri) {
+      let siteURI = Services.io.newURI(siteUri, null, null);
+      PlacesUtils.livemarks.setSiteURI(this.props.item_id, siteURI);
+    }
+  },
+
+  /**
+   * SetFeedUri
+   *
+   * Sets the feedURI property for this livemark.
+   *
+   * @param feedUri the URI to set; if null, no changes are made
+   * @return nothing
+   */
+  SetFeedUri: function(feedUri) {
+    if (feedUri) {
+      let feedURI = Services.io.newURI(feedUri, null, null);
+      PlacesUtils.livemarks.setFeedURI(this.props.item_id, feedURI);
+    }
+  },
+
+  /**
+   * Update
+   *
+   * Updates this livemark's properties according the properties on this
+   * object's 'updateProps' property.
+   *
+   * @return nothing
+   */
+  Update: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Update");
+    this.SetLocation(this.updateProps.location);
+    this.SetPosition(this.updateProps.position);
+    this.SetSiteUri(this.updateProps.siteUri);
+    this.SetFeedUri(this.updateProps.feedUri);
+    this.SetTitle(this.updateProps.livemark);
+    return true;
+  },
+
+  /**
+   * Remove
+   *
+   * Removes this livemark.  The livemark should have been located previously
+   * by a call to Find.
+   *
+   * @return nothing
+   */
+  Remove: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Remove");
+    PlacesUtils.bookmarks.removeItem(this.props.item_id);
+  },
+};
+
+extend(Livemark, PlacesItem);
+
+/**
+ * Separator class constructor. Initializes instance properties.
+ */
+function Separator(props) {
+  PlacesItem.call(this, props);
+  this.props.type = "separator";
+}
+
+/**
+ * Separator instance methods.
+ */
+Separator.prototype = {
+  /**
+   * Create
+   *
+   * Creates the bookmark separator described by this object's properties.
+   *
+   * @return the id of the created separator
+   */
+  Create: function () {
+    this.props.folder_id = this.GetOrCreateFolder(this.props.location);
+    Logger.AssertTrue(this.props.folder_id != -1, "Unable to create " +
+      "folder, error creating parent folder " + this.props.location);
+    this.props.item_id = PlacesUtils.bookmarks.insertSeparator(this.props.folder_id,
+                                                         -1);
+    return this.props.item_id;
+  },
+
+  /**
+   * Find
+   *
+   * Locates the bookmark separator which corresponds to this object's 
+   * properties.
+   *
+   * @return the item id if the separator was found, otherwise -1
+   */
+  Find: function () {
+    this.props.folder_id = this.GetFolder(this.props.location);
+    if (this.props.folder_id == -1) {
+      Logger.logError("Unable to find folder " + this.props.location);
+      return -1;
+    }
+    if (this.props.before == null && this.props.last_item_pos == null) {
+      Logger.logPotentialError("Separator requires 'before' attribute if it's the" +
+        "first item in the list");
+      return -1;
+    }
+    let expected_pos = -1;
+    if (this.props.before) {
+      other_id = this.GetPlacesNodeId(this.props.folder_id, 
+                                      null, 
+                                      this.props.before);
+      if (other_id == -1) {
+        Logger.logPotentialError("Can't find places item " + this.props.before + 
+          " for locating separator");
+        return -1;
+      }
+      expected_pos = PlacesUtils.bookmarks.getItemIndex(other_id) - 1;
+    }
+    else {
+      expected_pos = this.props.last_item_pos + 1;
+    }
+    this.props.item_id = PlacesUtils.bookmarks.getIdForItemAt(this.props.folder_id,
+                                                        expected_pos);
+    if (this.props.item_id == -1) {
+      Logger.logPotentialError("No separator found at position " + expected_pos);
+    }
+    else {
+      if (PlacesUtils.bookmarks.getItemType(this.props.item_id) != 
+          PlacesUtils.bookmarks.TYPE_SEPARATOR) {
+        Logger.logPotentialError("Places item at position " + expected_pos + 
+          " is not a separator");
+        return -1;
+      }
+    }
+    return this.props.item_id;
+  },
+
+  /**
+   * Update
+   *
+   * Updates this separator's properties according the properties on this
+   * object's 'updateProps' property.
+   *
+   * @return nothing
+   */
+  Update: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Update");
+    this.SetLocation(this.updateProps.location);
+    this.SetPosition(this.updateProps.position);
+    return true;
+  },
+
+  /**
+   * Remove
+   *
+   * Removes this separator.  The separator should have been located
+   * previously by a call to Find.
+   *
+   * @return nothing
+   */
+  Remove: function() {
+    Logger.AssertTrue(this.props.item_id != -1 && this.props.item_id != null,
+      "Invalid item_id during Update");
+    PlacesUtils.bookmarks.removeItem(this.props.item_id);
+  },
+};
+
+extend(Separator, PlacesItem);
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/forms.jsm
@@ -0,0 +1,295 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+   Components.utils.import() and acts as a singleton. Only the following 
+   listed symbols will exposed on import, and only when and where imported. 
+  */
+
+var EXPORTED_SYMBOLS = ["FormData"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+CU.import("resource://tps/logger.jsm");
+
+let formService = CC["@mozilla.org/satchel/form-history;1"]
+                  .getService(CI.nsIFormHistory2);
+
+/**
+ * FormDB
+ *
+ * Helper object containing methods to interact with the moz_formhistory
+ * SQLite table.
+ */
+let FormDB = {
+  /**
+   * makeGUID
+   *
+   * Generates a brand-new globally unique identifier (GUID).  Borrowed
+   * from Weave's utils.js.
+   *
+   * @return the new guid
+   */
+  makeGUID: function makeGUID() {
+    // 70 characters that are not-escaped URL-friendly
+    const code =
+      "!()*-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~";
+
+    let guid = "";
+    let num = 0;
+    let val;
+
+    // Generate ten 70-value characters for a 70^10 (~61.29-bit) GUID
+    for (let i = 0; i < 10; i++) {
+      // Refresh the number source after using it a few times
+      if (i == 0 || i == 5)
+        num = Math.random();
+
+      // Figure out which code to use for the next GUID character
+      num *= 70;
+      val = Math.floor(num);
+      guid += code[val];
+      num -= val;
+    }
+
+    return guid;
+  },
+
+  /**
+   * insertValue
+   *
+   * Inserts the specified value for the specified fieldname into the
+   * moz_formhistory table.
+   *
+   * @param fieldname The form fieldname to insert
+   * @param value The form value to insert
+   * @param us The time, in microseconds, to use for the lastUsed
+   *        and firstUsed columns
+   * @return nothing
+   */
+  insertValue: function (fieldname, value, us) {
+    let query = this.createStatement(
+      "INSERT INTO moz_formhistory " +
+      "(fieldname, value, timesUsed, firstUsed, lastUsed, guid) VALUES " +
+      "(:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)");
+    query.params.fieldname = fieldname;
+    query.params.value = value;
+    query.params.timesUsed = 1;
+    query.params.firstUsed = us;
+    query.params.lastUsed = us;
+    query.params.guid = this.makeGUID();
+    query.execute();
+    query.reset();
+  },
+
+  /**
+   * updateValue
+   *
+   * Updates a row in the moz_formhistory table with a new value.
+   *
+   * @param id The id of the row to update
+   * @param newvalue The new value to set
+   * @return nothing
+   */
+  updateValue: function (id, newvalue) {
+    let query = this.createStatement(
+      "UPDATE moz_formhistory SET value = :value WHERE id = :id");
+    query.params.id = id;
+    query.params.value = newvalue;
+    query.execute();
+    query.reset();
+  },
+
+  /**
+   * getDataForValue
+   *
+   * Retrieves a set of values for a row in the database that 
+   * corresponds to the given fieldname and value.
+   *
+   * @param fieldname The fieldname of the row to query
+   * @param value The value of the row to query
+   * @return null if no row is found with the specified fieldname and value,
+   *         or an object containing the row's id, lastUsed, and firstUsed
+   *         values
+   */
+  getDataForValue: function (fieldname, value) {
+    let query = this.createStatement(
+      "SELECT id, lastUsed, firstUsed FROM moz_formhistory WHERE " +
+      "fieldname = :fieldname AND value = :value");
+    query.params.fieldname = fieldname;
+    query.params.value = value;
+    if (!query.executeStep())
+      return null;
+
+    return {
+      id: query.row.id,
+      lastUsed: query.row.lastUsed,
+      firstUsed: query.row.firstUsed
+    };
+  },
+
+  /**
+   * createStatement
+   *
+   * Creates a statement from a SQL string.  This function is borrowed
+   * from Weave's forms.js.
+   *
+   * @param query The SQL query string
+   * @return the mozIStorageStatement created from the specified SQL
+   */
+  createStatement: function createStatement(query) {
+    try {
+      // Just return the statement right away if it's okay
+      return formService.DBConnection.createStatement(query);
+    }
+    catch(ex) {
+      // Assume guid column must not exist yet, so add it with an index
+      formService.DBConnection.executeSimpleSQL(
+        "ALTER TABLE moz_formhistory ADD COLUMN guid TEXT");
+      formService.DBConnection.executeSimpleSQL(
+        "CREATE INDEX IF NOT EXISTS moz_formhistory_guid_index " +
+        "ON moz_formhistory (guid)");
+    }
+
+    // Try creating the query now that the column exists
+    return formService.DBConnection.createStatement(query);
+  }
+};
+
+/**
+ * FormData class constructor
+ *
+ * Initializes instance properties.
+ */ 
+function FormData(props, usSinceEpoch) {
+  this.fieldname = null;
+  this.value = null;
+  this.date = 0;
+  this.newvalue = null;
+  this.usSinceEpoch = usSinceEpoch;
+
+  for (var prop in props) {
+    if (prop in this)
+      this[prop] = props[prop];
+  }
+}
+
+/**
+ * FormData instance methods
+ */
+FormData.prototype = {
+  /**
+   * hours_to_us
+   *
+   * Converts hours since present to microseconds since epoch.
+   *
+   * @param hours The number of hours since the present time (e.g., 0 is
+   *        'now', and -1 is 1 hour ago)
+   * @return the corresponding number of microseconds since the epoch
+   */
+  hours_to_us: function(hours) {
+    return this.usSinceEpoch + (hours * 60 * 60 * 1000 * 1000);
+  },
+
+  /**
+   * Create
+   *
+   * If this FormData object doesn't exist in the moz_formhistory database,
+   * add it.  Throws on error.
+   *
+   * @return nothing
+   */
+  Create: function() {
+    Logger.AssertTrue(this.fieldname != null && this.value != null,
+      "Must specify both fieldname and value");
+
+    let formdata = FormDB.getDataForValue(this.fieldname, this.value);
+    if (!formdata) {
+      // this item doesn't exist yet in the db, so we need to insert it
+      FormDB.insertValue(this.fieldname, this.value,
+                         this.hours_to_us(this.date));
+    }
+    else {
+      /* Right now, we ignore this case.  If bug 552531 is ever fixed,
+         we might need to add code here to update the firstUsed or
+         lastUsed fields, as appropriate.
+       */
+    }
+  },
+
+  /**
+   * Find
+   *
+   * Attempts to locate an entry in the moz_formhistory database that
+   * matches the fieldname and value for this FormData object.
+   *
+   * @return true if this entry exists in the database, otherwise false
+   */
+  Find: function() {
+    let formdata = FormDB.getDataForValue(this.fieldname, this.value);
+    let status = formdata != null;
+    if (status) {
+      /* 
+      //form history dates currently not synced!  bug 552531
+      let us = this.hours_to_us(this.date);
+      status = Logger.AssertTrue(
+        us >= formdata.firstUsed && us <= formdata.lastUsed,
+        "No match for with that date value");
+      
+      if (status)
+      */
+        this.id = formdata.id;
+    }
+    return status;
+  },
+
+  /**
+   * Remove
+   *
+   * Removes the row represented by this FormData instance from the
+   * moz_formhistory database.
+   *
+   * @return nothing
+   */
+  Remove: function() {
+    /* Right now Weave doesn't handle this correctly, see bug 568363.
+     */
+    formService.removeEntry(this.fieldname, this.value);
+    return true;
+  },
+};
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/history.jsm
@@ -0,0 +1,194 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *   Philipp von Weitershausen <philipp@weitershausen.de>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+  * Components.utils.import() and acts as a singleton. Only the following 
+  * listed symbols will exposed on import, and only when and where imported.
+  */
+
+var EXPORTED_SYMBOLS = ["HistoryEntry"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+CU.import("resource://gre/modules/Services.jsm");
+CU.import("resource://gre/modules/PlacesUtils.jsm");
+CU.import("resource://tps/logger.jsm");
+CU.import("resource://services-sync/async.js");
+
+/**
+ * HistoryEntry object
+ *
+ * Contains methods for manipulating browser history entries.
+ */
+var HistoryEntry = {
+  /**
+   * _db
+   *
+   * Returns the DBConnection object for the history service.
+   */
+  get _db() {
+    return PlacesUtils.history.QueryInterface(CI.nsPIPlacesDatabase).DBConnection;
+  },
+
+  /**
+   * _visitStm
+   *
+   * Return the SQL statement for getting history visit information
+   * from the moz_historyvisits table.  Borrowed from Weave's
+   * history.js.
+   */
+  get _visitStm() {
+    let stm = this._db.createStatement(
+      "SELECT visit_type type, visit_date date " +
+      "FROM moz_historyvisits " +
+      "WHERE place_id = (" +
+        "SELECT id " +
+        "FROM moz_places " +
+        "WHERE url = :url) " +
+      "ORDER BY date DESC LIMIT 10");
+    this.__defineGetter__("_visitStm", function() stm);
+    return stm;
+  },
+
+  /**
+   * _getVisits
+   *
+   * Gets history information about visits to a given uri.
+   *
+   * @param uri The uri to get visits for
+   * @return an array of objects with 'date' and 'type' properties,
+   * corresponding to the visits in the history database for the
+   * given uri
+   */
+  _getVisits: function HistStore__getVisits(uri) {
+    this._visitStm.params.url = uri;
+    return Async.querySpinningly(this._visitStm, ["date", "type"]);
+  },
+
+  /**
+   * Add
+   *
+   * Adds visits for a uri to the history database.  Throws on error.
+   *
+   * @param item An object representing one or more visits to a specific uri
+   * @param usSinceEpoch The number of microseconds from Epoch to
+   *        the time the current Crossweave run was started
+   * @return nothing
+   */
+  Add: function(item, usSinceEpoch) {
+    Logger.AssertTrue("visits" in item && "uri" in item,
+      "History entry in test file must have both 'visits' " +
+      "and 'uri' properties");
+    let uri = Services.io.newURI(item.uri, null, null);
+    for each (visit in item.visits) {
+      let visitId = PlacesUtils.history.addVisit(
+                    uri, 
+                    usSinceEpoch + (visit.date * 60 * 60 * 1000 * 1000), 
+                    null, visit.type, 
+                    visit.type == 5 || visit.type == 6, 0);
+      Logger.AssertTrue(visitId, "Error adding history entry");
+      if ("title" in item)
+        PlacesUtils.history.setPageTitle(uri, item.title);
+    }
+  },
+
+  /**
+   * Find
+   *
+   * Finds visits for a uri to the history database.  Throws on error.
+   *
+   * @param item An object representing one or more visits to a specific uri
+   * @param usSinceEpoch The number of microseconds from Epoch to
+   *        the time the current Crossweave run was started
+   * @return true if all the visits for the uri are found, otherwise false
+   */
+  Find: function(item, usSinceEpoch) {
+    Logger.AssertTrue("visits" in item && "uri" in item,
+      "History entry in test file must have both 'visits' " +
+      "and 'uri' properties");
+    let curvisits = curvisits = this._getVisits(item.uri);
+    for each (visit in curvisits) {
+      for each (itemvisit in item.visits) {
+        let expectedDate = itemvisit.date * 60 * 60 * 1000 * 1000 
+            + usSinceEpoch;
+        if (visit.type == itemvisit.type && visit.date == expectedDate) {
+          itemvisit.found = true;
+        }
+      }
+    }
+    
+    let all_items_found = true;
+    for each (itemvisit in item.visits) {
+      all_items_found = all_items_found && "found" in itemvisit;
+      Logger.logInfo("History entry for " + item.uri + ", type:" + 
+              itemvisit.type + ", date:" + itemvisit.date + 
+              ("found" in itemvisit ? " is present" : " is not present"));
+    }
+    return all_items_found;
+  },
+
+  /**
+   * Delete
+   *
+   * Removes visits from the history database. Throws on error.
+   * 
+   * @param item An object representing items to delete
+   * @param usSinceEpoch The number of microseconds from Epoch to
+   *        the time the current Crossweave run was started
+   * @return nothing
+   */
+  Delete: function(item, usSinceEpoch) {
+    if ("uri" in item) {
+      let uri = Services.io.newURI(item.uri, null, null);
+      PlacesUtils.history.removePage(uri);
+    }
+    else if ("host" in item) {
+      PlacesUtils.history.removePagesFromHost(item.host, false);
+    }
+    else if ("begin" in item && "end" in item) {
+      PlacesUtils.history.removeVisitsByTimeframe(
+          usSinceEpoch + (item.begin * 60 * 60 * 1000 * 1000), 
+          usSinceEpoch + (item.end * 60 * 60 * 1000 * 1000));
+    }
+    else {
+      Logger.AssertTrue(false, "invalid entry in delete history");
+    }
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/logger.jsm
@@ -0,0 +1,152 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+    Components.utils.import() and acts as a singleton.
+    Only the following listed symbols will exposed on import, and only when 
+    and where imported. */
+
+var EXPORTED_SYMBOLS = ["Logger"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+var Logger =
+{
+  _foStream: null,
+  _converter: null,
+  _potentialError: null,
+
+  init: function (path) {
+    if (this._converter != null) {
+      // we're already open!
+      return;
+    }
+
+    this._file = CC["@mozilla.org/file/local;1"]
+                 .createInstance(CI.nsILocalFile);
+    this._file.initWithPath(path);
+    var exists = this._file.exists();
+
+    // Make a file output stream and converter to handle it.
+    this._foStream = CC["@mozilla.org/network/file-output-stream;1"]
+                     .createInstance(CI.nsIFileOutputStream);
+    // If the file already exists, append it, otherwise create it.
+    var fileflags = exists ? 0x02 | 0x08 | 0x10 : 0x02 | 0x08 | 0x20;
+
+    this._foStream.init(this._file, fileflags, 0666, 0);
+    this._converter = CC["@mozilla.org/intl/converter-output-stream;1"]
+                      .createInstance(CI.nsIConverterOutputStream);
+    this._converter.init(this._foStream, "UTF-8", 0, 0);
+  },
+
+  write: function (data) {
+    if (this._converter == null) {
+      CU.reportError(
+          "TPS Logger.write called with _converter == null!");
+      return;
+    }
+    this._converter.writeString(data);
+  },
+
+  close: function () {
+    if (this._converter != null) {
+      this._converter.close();
+      this._converter = null;
+      this._foStream = null;
+    }
+  },
+
+  AssertTrue: function(bool, msg, showPotentialError) {
+    if (!bool) {
+      let message = msg;
+      if (showPotentialError && this._potentialError) {
+        message += "; " + this._potentialError;
+        this._potentialError = null;
+      }
+      throw("ASSERTION FAILED! " + message);
+    }
+  },
+
+  AssertEqual: function(val1, val2, msg) {
+    if (val1 != val2)
+      throw("ASSERTION FAILED! " + msg + "; expected " + 
+            JSON.stringify(val2) + ", got " + JSON.stringify(val1));
+  },
+
+  log: function (msg) {
+    dump(msg + "\n");
+    var now = new Date()
+    this.write(now.getFullYear() + "-" + (now.getMonth() < 9 ? '0' : '') + 
+        (now.getMonth() + 1) + "-" + 
+        (now.getDay() < 9 ? '0' : '') + (now.getDay() + 1) + " " +
+        (now.getHours() < 10 ? '0' : '') + now.getHours() + ":" +
+        (now.getMinutes() < 10 ? '0' : '') + now.getMinutes() + ":" +
+        (now.getSeconds() < 10 ? '0' : '') + now.getSeconds() + " " + 
+        msg + "\n");
+  },
+
+  clearPotentialError: function() {
+    this._potentialError = null;
+  },
+
+  logPotentialError: function(msg) {
+    this._potentialError = msg;
+  },
+
+  logLastPotentialError: function(msg) {
+    var message = msg;
+    if (this._potentialError) {
+      message = this._poentialError;
+      this._potentialError = null;
+    }
+    this.log("CROSSWEAVE ERROR: " + message);
+  },
+
+  logError: function (msg) {
+    this.log("CROSSWEAVE ERROR: " + msg);
+  },
+
+  logInfo: function (msg) {
+    this.log("CROSSWEAVE INFO: " + msg);
+  },
+
+  logPass: function (msg) {
+    this.log("CROSSWEAVE TEST PASS: " + msg);
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/passwords.jsm
@@ -0,0 +1,185 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *   Philipp von Weitershausen <philipp@weitershausen.de>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+  * Components.utils.import() and acts as a singleton. Only the following 
+  * listed symbols will exposed on import, and only when and where imported. 
+  */
+
+var EXPORTED_SYMBOLS = ["Password"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+CU.import("resource://gre/modules/Services.jsm");
+CU.import("resource://tps/logger.jsm");
+
+let nsLoginInfo = new Components.Constructor(
+                      "@mozilla.org/login-manager/loginInfo;1",  
+                      CI.nsILoginInfo,  
+                      "init");  
+
+/**
+ * PasswordProps object; holds password properties.
+ */
+function PasswordProps(props) {
+  this.hostname = null;
+  this.submitURL = null;
+  this.realm = null;
+  this.username = "";
+  this.password = "";
+  this.usernameField = "";
+  this.passwordField = "";
+  this.delete = false;
+
+  for (var prop in props) {
+    if (prop in this)
+      this[prop] = props[prop];
+  }
+}
+
+/**
+ * Password class constructor. Initializes instance properties.
+ */
+function Password(props) {
+  this.props = new PasswordProps(props);
+  if ("changes" in props) {
+    this.updateProps = new PasswordProps(props);
+    for (var prop in props.changes)
+      if (prop in this.updateProps)
+        this.updateProps[prop] = props.changes[prop];
+  }
+  else {
+    this.updateProps = null;
+  }
+}
+
+/**
+ * Password instance methods.
+ */
+Password.prototype = {
+  /**
+   * Create
+   *
+   * Adds a password entry to the login manager for the password
+   * represented by this object's properties. Throws on error.
+   *
+   * @return the new login guid
+   */
+  Create: function() {
+    let login = new nsLoginInfo(this.props.hostname, this.props.submitURL,
+                                this.props.realm, this.props.username,
+                                this.props.password, 
+                                this.props.usernameField,
+                                this.props.passwordField);
+    Services.logins.addLogin(login);
+    login.QueryInterface(CI.nsILoginMetaInfo);
+    return login.guid;               
+  },
+
+  /**
+   * Find
+   *
+   * Finds a password entry in the login manager, for the password
+   * represented by this object's properties.
+   *
+   * @return the guid of the password if found, otherwise -1
+   */
+  Find: function() {
+    let logins = Services.logins.findLogins({}, this.props.hostname,
+                                         this.props.submitURL,
+                                         this.props.realm);
+    for (var i = 0; i < logins.length; i++) {
+      if (logins[i].username == this.props.username &&
+          logins[i].password == this.props.password &&
+          logins[i].usernameField == this.props.usernameField &&
+          logins[i].passwordField == this.props.passwordField) {
+        logins[i].QueryInterface(CI.nsILoginMetaInfo);
+        return logins[i].guid;
+      }
+    }
+    return -1;
+  },
+
+  /**
+   * Update
+   *
+   * Updates an existing password entry in the login manager with 
+   * new properties. Throws on error.  The 'old' properties are this
+   * object's properties, the 'new' properties are the properties in
+   * this object's 'updateProps' object.
+   *
+   * @return nothing
+   */ 
+  Update: function() {
+    let oldlogin = new nsLoginInfo(this.props.hostname, 
+                                   this.props.submitURL,
+                                   this.props.realm, 
+                                   this.props.username,
+                                   this.props.password, 
+                                   this.props.usernameField,
+                                   this.props.passwordField);
+    let newlogin = new nsLoginInfo(this.updateProps.hostname, 
+                                   this.updateProps.submitURL,
+                                   this.updateProps.realm, 
+                                   this.updateProps.username,
+                                   this.updateProps.password, 
+                                   this.updateProps.usernameField,
+                                   this.updateProps.passwordField);
+    Services.logins.modifyLogin(oldlogin, newlogin);
+  },
+
+  /**
+   * Remove
+   *
+   * Removes an entry from the login manager for a password which
+   * matches this object's properties. Throws on error.
+   *
+   * @return nothing
+   */
+  Remove: function() {
+    let login = new nsLoginInfo(this.props.hostname, 
+                                this.props.submitURL,
+                                this.props.realm, 
+                                this.props.username,
+                                this.props.password, 
+                                this.props.usernameField,
+                                this.props.passwordField);
+    Services.logins.removeLogin(login);
+  },
+};
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/prefs.jsm
@@ -0,0 +1,150 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+   Components.utils.import() and acts as a singleton.
+   Only the following listed symbols will exposed on import, and only when 
+   and where imported. */
+
+var EXPORTED_SYMBOLS = ["Preference"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+const WEAVE_PREF_PREFIX = "services.sync.prefs.sync.";
+
+let prefs = CC["@mozilla.org/preferences-service;1"]
+           .getService(CI.nsIPrefBranch);
+
+CU.import("resource://tps/logger.jsm");
+
+/**
+ * Preference class constructor
+ *
+ * Initializes instance properties.
+ */
+function Preference (props) {
+  Logger.AssertTrue("name" in props && "value" in props, 
+    "Preference must have both name and value");
+  
+  this.name = props.name;
+  this.value = props.value;
+}
+
+/**
+ * Preference instance methods
+ */
+Preference.prototype = {
+  /**
+   * Modify
+   *
+   * Sets the value of the preference this.name to this.value. 
+   * Throws on error.
+   * 
+   * @return nothing
+   */
+  Modify: function() {
+    // Determine if this pref is actually something Weave even looks at.
+    let weavepref = WEAVE_PREF_PREFIX + this.name;
+    try {
+      let syncPref = prefs.getBoolPref(weavepref);
+      if (!syncPref)
+        prefs.setBoolPref(weavepref, true);
+    }
+    catch(e) {
+      Logger.AssertTrue(false, "Weave doesn't sync pref " + this.name);
+    }
+    
+    // Modify the pref; throw an exception if the pref type is different
+    // than the value type specified in the test.
+    let prefType = prefs.getPrefType(this.name);
+    switch (prefType) {
+      case CI.nsIPrefBranch.PREF_INT:
+        Logger.AssertEqual(typeof(this.value), "number",
+          "Wrong type used for preference value");
+        prefs.setIntPref(this.name, this.value);
+        break;
+      case CI.nsIPrefBranch.PREF_STRING:
+        Logger.AssertEqual(typeof(this.value), "string",
+          "Wrong type used for preference value");
+        prefs.setCharPref(this.name, this.value);
+        break;
+      case CI.nsIPrefBranch.PREF_BOOL:
+        Logger.AssertEqual(typeof(this.value), "boolean",
+          "Wrong type used for preference value");
+        prefs.setBoolPref(this.name, this.value);
+        break;
+    }
+  },
+
+  /**
+   * Find
+   *
+   * Verifies that the preference this.name has the value 
+   * this.value. Throws on error, or if the pref's type or value 
+   * doesn't match.
+   *
+   * @return nothing
+   */
+  Find: function() {
+    // Read the pref value.
+    let value;
+    try {
+      let prefType = prefs.getPrefType(this.name);
+      switch(prefType) {
+        case CI.nsIPrefBranch.PREF_INT:
+          value = prefs.getIntPref(this.name);
+          break;
+        case CI.nsIPrefBranch.PREF_STRING:
+          value = prefs.getCharPref(this.name);
+          break;
+        case CI.nsIPrefBranch.PREF_BOOL:
+          value = prefs.getBoolPref(this.name);
+          break;
+      }
+    }
+    catch (e) {
+      Logger.AssertTrue(false, "Error accessing pref " + this.name);
+    }
+    
+    // Throw an exception if the current and expected values aren't of 
+    // the same type, or don't have the same values.
+    Logger.AssertEqual(typeof(value), typeof(this.value),
+      "Value types don't match");
+    Logger.AssertEqual(value, this.value, "Preference values don't match");
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/quit.js
@@ -0,0 +1,106 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; -*- */
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is The Original Code is Mozilla Automated Testing Code
+ *
+ * The Initial Developer of the Original Code is
+ * Mozilla Corporation.
+ * Portions created by the Initial Developer are Copyright (C) 2005
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s): Bob Clary <bob@bclary.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/*
+  From mozilla/toolkit/content
+  These files did not have a license
+*/
+var EXPORTED_SYMBOLS = ["goQuitApplication"];
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+function canQuitApplication()
+{
+  try 
+  {
+    var cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"]
+      .createInstance(Components.interfaces.nsISupportsPRBool);
+    Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null);
+    
+    // Something aborted the quit process. 
+    if (cancelQuit.data)
+    {
+      return false;
+    }
+  }
+  catch (ex) 
+  {
+  }
+  return true;
+}
+
+function goQuitApplication()
+{
+  if (!canQuitApplication())
+  {
+    return false;
+  }
+
+  const kAppStartup = '@mozilla.org/toolkit/app-startup;1';
+  const kAppShell   = '@mozilla.org/appshell/appShellService;1';
+  var   appService;
+  var   forceQuit;
+
+  if (kAppStartup in Components.classes)
+  {
+    appService = Components.classes[kAppStartup].
+      getService(Components.interfaces.nsIAppStartup);
+    forceQuit  = Components.interfaces.nsIAppStartup.eForceQuit;
+  }
+  else if (kAppShell in Components.classes)
+  {
+    appService = Components.classes[kAppShell].
+      getService(Components.interfaces.nsIAppShellService);
+    forceQuit = Components.interfaces.nsIAppShellService.eForceQuit;
+  }
+  else
+  {
+    throw 'goQuitApplication: no AppStartup/appShell';
+  }
+
+  try
+  {
+    appService.quit(forceQuit);
+  }
+  catch(ex)
+  {
+    throw('goQuitApplication: ' + ex);
+  }
+
+  return true;
+}
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/tabs.jsm
@@ -0,0 +1,97 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Crossweave.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+   Components.utils.import() and acts as a singleton.
+   Only the following listed symbols will exposed on import, and only when 
+   and where imported. */
+
+var EXPORTED_SYMBOLS = ["BrowserTabs"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+CU.import("resource://services-sync/engines.js");
+
+var BrowserTabs = {
+  /**
+   * Add
+   *
+   * Opens a new tab in the current browser window for the
+   * given uri.  Throws on error.
+   *
+   * @param uri The uri to load in the new tab
+   * @return nothing
+   */
+  Add: function(uri, fn) {
+    // Open the uri in a new tab in the current browser window, and calls
+    // the callback fn from the tab's onload handler.
+    let wm = CC["@mozilla.org/appshell/window-mediator;1"]
+             .getService(CI.nsIWindowMediator);
+    let mainWindow = wm.getMostRecentWindow("navigator:browser");
+    let newtab = mainWindow.getBrowser().addTab(uri);
+    mainWindow.getBrowser().selectedTab = newtab;
+    let win = mainWindow.getBrowser().getBrowserForTab(newtab);
+    win.addEventListener("load", function() { fn.call(); }, true);
+  },
+
+  /**
+   * Find
+   *
+   * Finds the specified uri and title in Weave's list of remote tabs
+   * for the specified profile.
+   *
+   * @param uri The uri of the tab to find
+   * @param title The page title of the tab to find
+   * @param profile The profile to search for tabs
+   * @return true if the specified tab could be found, otherwise false
+   */
+  Find: function(uri, title, profile) {
+    // Find the uri in Weave's list of tabs for the given profile.
+    let engine = Engines.get("tabs");
+    for (let [guid, client] in Iterator(engine.getAllClients())) {
+      for each (tab in client.tabs) {
+        let weaveTabUrl = tab.urlHistory[0];
+        if (uri == weaveTabUrl && profile == client.clientName)
+          if (title == undefined || title == tab.title)
+            return true;
+      }
+    }
+    return false;
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/sync/tps/modules/tps.jsm
@@ -0,0 +1,651 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is TPS.
+ *
+ * The Initial Developer of the Original Code is Mozilla.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Jonathan Griffin <jgriffin@mozilla.com>
+ *   Philipp von Weitershausen <philipp@weitershausen.de>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+ /* This is a JavaScript module (JSM) to be imported via 
+  * Components.utils.import() and acts as a singleton. Only the following 
+  * listed symbols will exposed on import, and only when and where imported. 
+  */
+
+var EXPORTED_SYMBOLS = ["TPS"];
+
+const CC = Components.classes;
+const CI = Components.interfaces;
+const CU = Components.utils;
+
+CU.import("resource://services-sync/service.js");
+CU.import("resource://services-sync/constants.js");
+CU.import("resource://services-sync/util.js");
+CU.import("resource://gre/modules/XPCOMUtils.jsm");
+CU.import("resource://gre/modules/Services.jsm");
+CU.import("resource://tps/bookmarks.jsm");
+CU.import("resource://tps/logger.jsm");
+CU.import("resource://tps/passwords.jsm");
+CU.import("resource://tps/history.jsm");
+CU.import("resource://tps/forms.jsm");
+CU.import("resource://tps/prefs.jsm");
+CU.import("resource://tps/tabs.jsm");
+
+const ACTION_ADD = "add";
+const ACTION_VERIFY = "verify";
+const ACTION_VERIFY_NOT = "verify-not";
+const ACTION_MODIFY = "modify";
+const ACTION_SYNC = "sync";
+const ACTION_DELETE = "delete";
+const ACTION_PRIVATE_BROWSING = "private-browsing";
+const ACTION_WIPE_SERVER = "wipe-server";
+const ACTIONS = [ACTION_ADD, ACTION_VERIFY, ACTION_VERIFY_NOT, 
+                 ACTION_MODIFY, ACTION_SYNC, ACTION_DELETE,
+                 ACTION_PRIVATE_BROWSING, ACTION_WIPE_SERVER];
+
+const SYNC_WIPE_SERVER = "wipe-server";
+const SYNC_RESET_CLIENT = "reset-client";
+const SYNC_WIPE_CLIENT = "wipe-client";
+
+function GetFileAsText(file)
+{
+  let channel = Services.io.newChannel(file, null, null);
+  let inputStream = channel.open();
+  if (channel instanceof CI.nsIHttpChannel && 
+      channel.responseStatus != 200) {
+    return "";
+  }
+
+  let streamBuf = "";
+  let sis = CC["@mozilla.org/scriptableinputstream;1"]
+            .createInstance(CI.nsIScriptableInputStream);
+  sis.init(inputStream);
+
+  let available;
+  while ((available = sis.available()) != 0) {
+    streamBuf += sis.read(available);
+  }
+
+  inputStream.close();
+  return streamBuf;
+}
+
+var TPS = 
+{
+  _waitingForSync: false,
+  _test: null,
+  _currentAction: -1,
+  _currentPhase: -1,
+  _errors: 0,
+  _syncErrors: 0,
+  _usSinceEpoch: 0,
+  _tabsAdded: 0,
+  _tabsFinished: 0,
+  _phaselist: {},
+  _operations_pending: 0,
+
+  DumpError: function (msg) {
+    this._errors++;
+    Logger.logError("[phase" + this._currentPhase + "] " + msg);
+    this.quit();
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([CI.nsIObserver,
+                                         CI.nsISupportsWeakReference]),
+
+  observe: function TPS__observe(subject, topic, data) {
+    try {
+      Logger.logInfo("----------event observed: " + topic);
+      switch(topic) {
+        case "private-browsing":
+          Logger.logInfo("private browsing " + data);
+          break;
+        case "weave:service:sync:error":
+          if (this._waitingForSync && this._syncErrors == 0) {
+            // if this is the first sync error, retry...
+            Logger.logInfo("sync error; retrying...");
+            this._syncErrors++;
+            this._waitingForSync = false;
+            Weave.Service.logout();
+            Utils.nextTick(this.RunNextTestAction, this);
+          }
+          else {
+            // ...otherwise abort the test
+            this.DumpError("sync error; aborting test");
+            return;
+          }
+          break;
+        case "weave:service:sync:finish":
+          if (this._waitingForSync) {
+            this._syncErrors = 0;
+            this._waitingForSync = false;
+            // Wait a second before continuing, otherwise we can get
+            // 'sync not complete' errors.
+            Utils.namedTimer(function() {
+              Weave.Service.logout();
+              this.FinishAsyncOperation();
+            }, 1000, this, "postsync");
+          }
+          break;
+        case "sessionstore-windows-restored":
+          Utils.nextTick(this.RunNextTestAction, this);
+          break;
+      }
+    }
+    catch(e) {
+      this.DumpError("Exception caught: " + e);
+      return;
+    }
+  },
+
+  StartAsyncOperation: function TPS__StartAsyncOperation() {
+    this._operations_pending++;
+  },
+
+  FinishAsyncOperation: function TPS__FinishAsyncOperation() {
+    this._operations_pending--;
+    if (!this.operations_pending) {
+      this._currentAction++;
+      Utils.nextTick(function() {
+        this.RunNextTestAction();
+      }, this);
+    }
+  },
+
+  quit: function () {
+    Logger.close();
+    this.goQuitApplication();
+  },
+
+  HandleTabs: function (tabs, action) {
+    this._tabsAdded = tabs.length;
+    this._tabsFinished = 0;
+    for each (let tab in tabs) {
+      Logger.logInfo("executing action " + action.toUpperCase() +
+                     " on tab " + JSON.stringify(tab));
+      switch(action) {
+        case ACTION_ADD:
+          // When adding tabs, we keep track of how many tabs we're adding,
+          // and wait until we've received that many onload events from our
+          // new tabs before continuing
+          let that = this;
+          let taburi = tab.uri;
+          BrowserTabs.Add(tab.uri, function() {
+            that._tabsFinished++;
+            Logger.logInfo("tab for " + taburi + " finished loading");
+            if (that._tabsFinished == that._tabsAdded) {
+              Logger.logInfo("all tabs loaded, continuing...");
+              that.FinishAsyncOperation();
+            }
+          });
+          break;
+        case ACTION_VERIFY:
+          Logger.AssertTrue(typeof(tab.profile) != "undefined",
+            "profile must be defined when verifying tabs");
+          Logger.AssertTrue(
+            BrowserTabs.Find(tab.uri, tab.title, tab.profile), "error locating tab");
+          break;
+        case ACTION_VERIFY_NOT:
+          Logger.AssertTrue(typeof(tab.profile) != "undefined",
+            "profile must be defined when verifying tabs");
+          Logger.AssertTrue(
+            !BrowserTabs.Find(tab.uri, tab.title, tab.profile), 
+            "tab found which was expected to be absent");
+          break;
+        default:
+          Logger.AssertTrue(false, "invalid action: " + action);
+      }
+    }
+    Logger.logPass("executing action " + action.toUpperCase() + " on tabs");
+  },
+
+  HandlePrefs: function (prefs, action) {
+    for each (pref in prefs) {
+      Logger.logInfo("executing action " + action.toUpperCase() +
+                     " on pref " + JSON.stringify(pref));
+      let preference = new Preference(pref);
+      switch(action) {
+        case ACTION_MODIFY:
+          preference.Modify();
+          break;
+        case ACTION_VERIFY:
+          preference.Find();
+          break;
+        default:
+          Logger.AssertTrue(false, "invalid action: " + action);
+      }
+    }
+    Logger.logPass("executing action " + action.toUpperCase() + " on pref");
+  },
+
+  HandleForms: function (data, action) {
+    for each (datum in data) {
+      Logger.logInfo("executing action " + action.toUpperCase() +
+                     " on form entry " + JSON.stringify(datum));
+      let formdata = new FormData(datum, this._usSinceEpoch);
+      switch(action) {
+        case ACTION_ADD:
+          formdata.Create();
+          break;
+        case ACTION_DELETE:
+          formdata.Remove();
+          break;
+        case ACTION_VERIFY:
+          Logger.AssertTrue(formdata.Find(), "form data not found");
+          break;
+        case ACTION_VERIFY_NOT:
+          Logger.AssertTrue(!formdata.Find(), 
+            "form data found, but it shouldn't be present");
+          break;
+        default:
+          Logger.AssertTrue(false, "invalid action: " + action);
+      }
+    }
+    Logger.logPass("executing action " + action.toUpperCase() + 
+                   " on formdata");
+  },
+
+  HandleHistory: function (entries, action) {
+    for each (entry in entries) {
+      Logger.logInfo("executing action " + action.toUpperCase() +
+                     " on history entry " + JSON.stringify(entry));
+      switch(action) {
+        case ACTION_ADD:
+          HistoryEntry.Add(entry, this._usSinceEpoch);
+          break;
+        case ACTION_DELETE:
+          HistoryEntry.Delete(entry, this._usSinceEpoch);
+          break;
+        case ACTION_VERIFY:
+          Logger.AssertTrue(HistoryEntry.Find(entry, this._usSinceEpoch),
+            "Uri visits not found in history database");
+          break;
+        case ACTION_VERIFY_NOT:
+          Logger.AssertTrue(!HistoryEntry.Find(entry, this._usSinceEpoch),
+            "Uri visits found in history database, but they shouldn't be");
+          break;
+        default:
+          Logger.AssertTrue(false, "invalid action: " + action);
+      }
+    }
+    Logger.logPass("executing action " + action.toUpperCase() + 
+                   " on history");
+  },
+
+  HandlePasswords: function (passwords, action) {
+    for each (password in passwords) {
+      let password_id = -1;
+      Logger.logInfo("executing action " + action.toUpperCase() + 
+                    " on password " + JSON.stringify(password));
+      var password = new Password(password);
+      switch (action) {
+        case ACTION_ADD:
+          Logger.AssertTrue(password.Create() > -1, "error adding password");
+          break;
+        case ACTION_VERIFY:
+          Logger.AssertTrue(password.Find() != -1, "password not found");
+          break;
+        case ACTION_VERIFY_NOT:
+          Logger.AssertTrue(password.Find() == -1, 
+            "password found, but it shouldn't exist");
+          break;
+        case ACTION_DELETE:
+          Logger.AssertTrue(password.Find() != -1, "password not found");
+          password.Remove();
+          break;
+        case ACTION_MODIFY:
+          if (password.updateProps != null) {
+            Logger.AssertTrue(password.Find() != -1, "password not found");
+            password.Update();
+          }
+          break;
+        default:
+          Logger.AssertTrue(false, "invalid action: " + action);
+      } 
+    }
+    Logger.logPass("executing action " + action.toUpperCase() + 
+                   " on passwords");
+  },
+
+  HandleBookmarks: function (bookmarks, action) {
+    let items = [];
+    for (folder in bookmarks) {
+      let last_item_pos = -1;
+      for each (bookmark in bookmarks[folder]) {
+        Logger.clearPotentialError();
+        let placesItem;
+        bookmark['location'] = folder;
+        if (last_item_pos != -1)
+          bookmark['last_item_pos'] = last_item_pos;
+        let item_id = -1;
+        if (action != ACTION_MODIFY && action != ACTION_DELETE)
+          Logger.logInfo("executing action " + action.toUpperCase() + 
+                         " on bookmark " + JSON.stringify(bookmark));
+        if ("uri" in bookmark)
+          placesItem = new Bookmark(bookmark);
+        else if ("folder" in bookmark)
+          placesItem = new BookmarkFolder(bookmark);
+        else if ("livemark" in bookmark)
+          placesItem = new Livemark(bookmark);
+        else if ("separator" in bookmark)
+          placesItem = new Separator(bookmark);
+        if (action == ACTION_ADD) {
+          item_id = placesItem.Create();
+        }
+        else {
+          item_id = placesItem.Find();
+          if (action == ACTION_VERIFY_NOT) {
+            Logger.AssertTrue(item_id == -1,
+              "places item exists but it shouldn't: " +
+              JSON.stringify(bookmark));
+          }
+          else
+            Logger.AssertTrue(item_id != -1, "places item not found", true);
+        }
+        
+        last_item_pos = placesItem.GetItemIndex();
+        items.push(placesItem);
+      }
+    }
+
+    if (action == ACTION_DELETE || action == ACTION_MODIFY) {
+      for each (item in items) {
+        Logger.logInfo("executing action " + action.toUpperCase() + 
+                       " on bookmark " + JSON.stringify(item));
+        switch(action) {
+          case ACTION_DELETE:
+            item.Remove();
+            break;
+          case ACTION_MODIFY:
+            if (item.updateProps != null)
+              item.Update();
+            break;
+        }
+      }
+    }
+
+    Logger.logPass("executing action " + action.toUpperCase() +
+      " on bookmarks");
+  },
+
+  RunNextTestAction: function() {
+    try {
+      if (this._currentAction >= 
+          this._phaselist["phase" + this._currentPhase].length) {
+        // we're all done
+        Logger.logInfo("test phase " + this._currentPhase + ": " + 
+          (this._errors ? "FAIL" : "PASS"));
+        this.quit();
+        return;
+      }
+      
+      if (this.seconds_since_epoch)
+        this._usSinceEpoch = this.seconds_since_epoch * 1000 * 1000;
+      else {
+        this.DumpError("seconds-since-epoch not set");
+        return;
+      }
+      
+      let phase = this._phaselist["phase" + this._currentPhase];
+      let action = phase[this._currentAction];
+      Logger.logInfo("starting action: " + JSON.stringify(action));
+      action[0].call(this, action[1]);
+
+      // if we're in an async operation, don't continue on to the next action
+      if (this._operations_pending)
+        return;
+
+      this._currentAction++;
+    }
+    catch(e) {
+      this.DumpError("Exception caught: " + e);
+      return;
+    }
+    this.RunNextTestAction();
+  },
+
+  RunTestPhase: function (file, phase, logpath) {
+    try {
+      Logger.init(logpath);
+      Logger.logInfo("Weave version: " + WEAVE_VERSION);
+      Logger.logInfo("Firefox builddate: " + Services.appinfo.appBuildID);
+      Logger.logInfo("Firefox version: " + Services.appinfo.version);
+
+      // do some weave housekeeping
+      if (Weave.Service.isLoggedIn) {
+        this.DumpError("Weave logged in on startup...profile may be dirty");
+        return;
+      }
+      
+      // setup observers
+      Services.obs.addObserver(this, "weave:service:sync:finish", true);
+      Services.obs.addObserver(this, "weave:service:sync:error", true);
+      Services.obs.addObserver(this, "sessionstore-windows-restored", true);
+      Services.obs.addObserver(this, "private-browsing", true);
+
+      // parse the test file
+      Services.scriptloader.loadSubScript(file, this);
+      this._currentPhase = phase;
+      let this_phase = this._phaselist["phase" + this._currentPhase];
+
+      if (this_phase == undefined) {
+        this.DumpError("invalid phase " + this._currentPhase);
+        return;
+      }
+
+      if (this.phases["phase" + this._currentPhase] == undefined) {
+        this.DumpError("no profile defined for phase " + this._currentPhase);
+        return;
+      }
+      Logger.logInfo("setting client.name to " + this.phases["phase" + this._currentPhase]);
+      Weave.Svc.Prefs.set("client.name", this.phases["phase" + this._currentPhase]);
+
+      // wipe the server at the end of the final test phase
+      if (this.phases["phase" + (parseInt(this._currentPhase) + 1)] == undefined)
+        this_phase.push([this.WipeServer]);
+      
+      // start processing the test actions
+      this._currentAction = 0;
+    }
+    catch(e) {
+      this.DumpError("Exception caught: " + e);
+      return;
+    }
+  },
+
+  Phase: function Test__Phase(phasename, fnlist) {
+    this._phaselist[phasename] = fnlist;
+  },
+
+  SetPrivateBrowsing: function TPS__SetPrivateBrowsing(options) {
+    let PBSvc = CC["@mozilla.org/privatebrowsing;1"].
+                getService(CI.nsIPrivateBrowsingService);
+    PBSvc.privateBrowsingEnabled = options;
+    Logger.logInfo("set privateBrowsingEnabled: " + options);
+  },
+
+  Sync: function TPS__Sync(options) {
+    Logger.logInfo("executing Sync " + (options ? options : ""));
+    if (options == SYNC_WIPE_SERVER) {
+      Weave.Svc.Prefs.set("firstSync", "wipeRemote");
+    }
+    else if (options == SYNC_WIPE_CLIENT) {
+      Weave.Svc.Prefs.set("firstSync", "wipeClient");
+    }
+    else if (options == SYNC_RESET_CLIENT) {
+      Weave.Svc.Prefs.set("firstSync", "resetClient");
+    }
+    else {
+      Weave.Svc.Prefs.reset("firstSync");
+    }
+    if (this.config.account) {
+      let account = this.config.account;
+      if (account["serverURL"]) {
+        Weave.Service.serverURL = account["serverURL"];
+      }
+      if (account["admin-secret"]) {
+        // if admin-secret is specified, we'll dynamically create
+        // a new sync account
+        Weave.Svc.Prefs.set("admin-secret", account["admin-secret"]);
+        let suffix = account["account-suffix"];
+        Weave.Service.account = "tps" + suffix + "@mozilla.com";
+        Weave.Service.password = "tps" + suffix + "tps" + suffix;
+        Weave.Service.passphrase = Weave.Utils.generatePassphrase();
+        Weave.Service.createAccount(Weave.Service.account, 
+                                    Weave.Service.password,
+                                    "dummy1", "dummy2");
+        Weave.Service.login();
+      }
+      else if (account["username"] && account["password"] &&
+               account["passphrase"]) {
+        Weave.Service.account = account["username"];
+        Weave.Service.password = account["password"];
+        Weave.Service.passphrase = account["passphrase"];
+        Weave.Service.login();
+      }
+      else {
+        this.DumpError("Must specify admin-secret, or " +
+          "username/password/passphrase in the config file");
+        return;
+      }
+    }
+    else {
+      this.DumpError("No account information found; did you use " +
+        "a valid config file?");
+      return;
+    }
+    Logger.AssertEqual(Weave.Status.service, Weave.STATUS_OK, "Weave status not OK");
+    Weave.Svc.Obs.notify("weave:service:setup-complete");
+    this._waitingForSync = true;
+    this.StartAsyncOperation();
+    Weave.Service.sync();
+  },
+
+  WipeServer: function TPS__WipeServer() {
+    Logger.logInfo("WipeServer()");
+    Weave.Service.login();
+    Weave.Service.wipeServer();
+    Logger.AssertEqual(Weave.Status.service, Weave.STATUS_OK, "Weave status not OK");
+    this._waitingForSync = true;
+    this.StartAsyncOperation();
+    Weave.Service.sync();
+    return;
+  },
+};
+
+var Bookmarks = {
+  add: function Bookmarks__add(bookmarks) {
+    TPS.HandleBookmarks(bookmarks, ACTION_ADD);
+  },
+  modify: function Bookmarks__modify(bookmarks) {
+    TPS.HandleBookmarks(bookmarks, ACTION_MODIFY);
+  },
+  delete: function Bookmarks__delete(bookmarks) {
+    TPS.HandleBookmarks(bookmarks, ACTION_DELETE);
+  },
+  verify: function Bookmarks__verify(bookmarks) {
+    TPS.HandleBookmarks(bookmarks, ACTION_VERIFY);
+  },
+  verifyNot: function Bookmarks__verifyNot(bookmarks) {
+    TPS.HandleBookmarks(bookmarks, ACTION_VERIFY_NOT);
+  }
+};
+
+var Formdata = {
+  add: function Formdata__add(formdata) {
+    this.HandleForms(formdata, ACTION_ADD);
+  },
+  delete: function Formdata__delete(formdata) {
+    this.HandleForms(formdata, ACTION_DELETE);
+  },
+  verify: function Formdata__verify(formdata) {
+    this.HandleForms(formdata, ACTION_VERIFY);
+  },
+  verifyNot: function Formdata__verifyNot(formdata) {
+    this.HandleForms(formdata, ACTION_VERIFY_NOT);
+  }
+};
+
+var History = {
+  add: function History__add(history) {
+    this.HandleHistory(history, ACTION_ADD);
+  },
+  delete: function History__delete(history) {
+    this.HandleHistory(history, ACTION_DELETE);
+  },
+  verify: function History__verify(history) {
+    this.HandleHistory(history, ACTION_VERIFY);
+  },
+  verifyNot: function History__verifyNot(history) {
+    this.HandleHistory(history, ACTION_VERIFY_NOT);
+  }
+};
+
+var Passwords = {
+  add: function Passwords__add(passwords) {
+    this.HandlePasswords(passwords, ACTION_ADD);
+  },
+  modify: function Passwords__modify(passwords) {
+    this.HandlePasswords(passwords, ACTION_MODIFY);
+  },
+  delete: function Passwords__delete(passwords) {
+    this.HandlePasswords(passwords, ACTION_DELETE);
+  },
+  verify: function Passwords__verify(passwords) {
+    this.HandlePasswords(passwords, ACTION_VERIFY);
+  },
+  verifyNot: function Passwords__verifyNot(passwords) {
+    this.HandlePasswords(passwords, ACTION_VERIFY_NOT);
+  }
+};
+
+var Prefs = {
+  modify: function Prefs__modify(prefs) {
+    TPS.HandlePrefs(prefs, ACTION_MODIFY);
+  },
+  verify: function Prefs__verify(prefs) {
+    TPS.HandlePrefs(prefs, ACTION_VERIFY);
+  }
+};
+
+var Tabs = {
+  add: function Tabs__add(tabs) {
+    TPS.StartAsyncOperation();
+    TPS.HandleTabs(tabs, ACTION_ADD);
+  },
+  verify: function Tabs__verify(tabs) {
+    TPS.HandleTabs(tabs, ACTION_VERIFY);
+  },
+  verifyNot: function Tabs__verifyNot(tabs) {
+    TPS.HandleTabs(tabs, ACTION_VERIFY_NOT);
+  }
+};
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/INSTALL.sh
@@ -0,0 +1,71 @@
+#!/bin/bash
+
+# This scripts sets up a virutalenv and installs TPS into it.
+# It's probably best to specify a path NOT inside the repo, otherwise
+# all the virtualenv files will show up in e.g. hg status.
+
+# get target directory
+if [ ! -z "$1" ]
+then
+  TARGET=$1
+else
+  echo "Usage: INSTALL.sh /path/to/create/virtualenv [/path/to/python2.6]"
+  exit 1
+fi
+
+# decide which python to use
+if [ ! -z "$2" ]
+then
+    PYTHON=$2
+else
+    PYTHON=`which python`
+fi
+if [ -z "${PYTHON}" ]
+then
+    echo "No python found"
+    exit 1
+fi
+
+CWD="`pwd`"
+
+# create the destination directory
+mkdir ${TARGET}
+
+if [ "$?" -gt 0 ]
+then
+  exit 1
+fi
+
+if [ "${OS}" = "Windows_NT" ]
+then
+  BIN_NAME=Scripts/activate
+else
+  BIN_NAME=bin/activate
+fi
+
+# Create a virtualenv:
+curl https://raw.github.com/jonallengriffin/virtualenv/msys/virtualenv.py | ${PYTHON} - ${TARGET}
+cd ${TARGET}
+. $BIN_NAME
+if [ -z "${VIRTUAL_ENV}" ]
+then
+    echo "virtualenv wasn't installed correctly, aborting"
+    exit 1
+fi
+
+# install TPS
+cd ${CWD}
+python setup.py develop
+
+if [ "$?" -gt 0 ]
+then
+  exit 1
+fi
+
+echo
+echo "To run TPS, activate the virtualenv using:"
+echo "  source ${TARGET}/${BIN_NAME}"
+echo "then execute tps using:"
+echo "  runtps --binary=/path/to/firefox"
+echo
+echo "See runtps --help for all options"
new file mode 100644
--- /dev/null
+++ b/testing/tps/README
@@ -0,0 +1,17 @@
+TPS is a test automation framework for Firefox Sync.  See 
+https://developer.mozilla.org/en/TPS for documentation.
+
+INSTALLATION:
+
+TPS requires several packages to operate properly.  To install TPS and
+required packages, use the INSTALL.sh script, provided:
+
+  ./INSTALL.sh /path/to/create/virtualenv
+
+This script will create a virtalenv and install TPS into it.  TPS can then
+be run by activating the virtualenv and executing:
+
+  runtps --binary=/path/to/firefox
+
+
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/config.json
@@ -0,0 +1,23 @@
+{ 
+  "account": {
+    "serverURL": "",
+    "admin-secret": "",
+    "username": "crossweaveservices@mozilla.com",
+    "password": "crossweaveservicescrossweaveservices",
+    "passphrase": "r-jwcbc-zgf42-fjn72-p5vpp-iypmi"
+  },
+  "resultstore": {
+    "host": "brasstacks.mozilla.com",
+    "path": "/resultserv/post/"
+  },
+  "email": {
+    "username": "crossweave@mozilla.com",
+    "password": "",
+    "passednotificationlist": ["crossweave@mozilla.com"],
+    "notificationlist": ["crossweave@mozilla.com"]
+  },
+  "platform": "win32",
+  "os": "win7",
+  "es": "localhost:9200"
+}
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/microsummary1.txt
@@ -0,0 +1,1 @@
+Static microsummary #1
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/microsummary2.txt
@@ -0,0 +1,1 @@
+Static microsummary #2
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/microsummary3.txt
@@ -0,0 +1,1 @@
+Static microsummary #3
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/page1.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>Crossweave Test Page 1</title>
+</head>
+<body>
+<p>
+Crossweave Test Page 1
+</p>
+</body>
+</html>
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/page2.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>Crossweave Test Page 2</title>
+</head>
+<body>
+<p>
+Crossweave Test Page 2
+</p>
+</body>
+</html>
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/page3.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>Crossweave Test Page 3</title>
+</head>
+<body>
+<p>
+Crossweave Test Page 3
+</p>
+</body>
+</html>
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/page4.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>Crossweave Test Page 4</title>
+</head>
+<body>
+<p>
+Crossweave Test Page 4
+</p>
+</body>
+</html>
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/pages/page5.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>Crossweave Test Page 5</title>
+</head>
+<body>
+<p>
+Crossweave Test Page 5
+</p>
+</body>
+</html>
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/setup.py
@@ -0,0 +1,75 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#    
+# The Initial Developer of the Original Code is
+# Mozilla foundation
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import sys
+from setuptools import setup, find_packages
+
+version = '0.2.40'
+
+deps = ['pulsebuildmonitor >= 0.2', 'MozillaPulse == .4', 
+        'mozinfo == 0.3.1', 'mozprofile == 0.1a',
+        'mozprocess == 0.1a', 'mozrunner == 3.0a', 'mozregression == 0.3',
+        'mozautolog >= 0.2.0']
+
+# we only support python 2.6+ right now
+assert sys.version_info[0] == 2
+assert sys.version_info[1] >= 6
+
+setup(name='tps',
+      version=version,
+      description='run automated multi-profile sync tests',
+      long_description="""\
+""",
+      classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+      keywords='',
+      author='Jonathan Griffin',
+      author_email='jgriffin@mozilla.com',
+      url='http://hg.mozilla.org/services/tps',
+      license='MPL',
+      dependency_links = [
+         "http://people.mozilla.org/~jgriffin/packages/"
+      ],
+      packages=find_packages(exclude=['ez_setup', 'examples', 'tests']),
+      include_package_data=True,
+      zip_safe=False,
+      install_requires=deps,
+      entry_points="""
+      # -*- Entry points: -*-
+      [console_scripts]
+      runtps = tps.cli:main
+      """,
+      )
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/__init__.py
@@ -0,0 +1,41 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+from firefoxrunner import TPSFirefoxRunner
+from pulse import TPSPulseMonitor
+from testrunner import TPSTestRunner
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/cli.py
@@ -0,0 +1,132 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import json
+import optparse
+import os
+import logging
+
+from threading import RLock
+
+from tps import TPSFirefoxRunner, TPSPulseMonitor, TPSTestRunner
+
+def main():
+  parser = optparse.OptionParser()
+  parser.add_option("--email-results",
+                    action = "store_true", dest = "emailresults",
+                    default = False,
+                    help = "email the test results to the recipients defined "
+                           "in the config file")
+  parser.add_option("--mobile",
+                    action = "store_true", dest = "mobile",
+                    default = False,
+                    help = "run with mobile settings")
+  parser.add_option("--autolog",
+                    action = "store_true", dest = "autolog",
+                    default = False,
+                    help = "post results to Autolog")
+  parser.add_option("--testfile",
+                    action = "store", type = "string", dest = "testfile",
+                    default = '../../services/sync/tests/tps/test_sync.js',
+                    help = "path to the test file to run "
+                           "[default: %default]")
+  parser.add_option("--logfile",
+                    action = "store", type = "string", dest = "logfile",
+                    default = 'tps.log',
+                    help = "path to the log file [default: %default]")
+  parser.add_option("--binary",
+                    action = "store", type = "string", dest = "binary",
+                    default = None,
+                    help = "path to the Firefox binary, specified either as "
+                           "a local file or a url; if omitted, the PATH "
+                           "will be searched;")
+  parser.add_option("--configfile",
+                    action = "store", type = "string", dest = "configfile",
+                    default = "config.json",
+                    help = "path to the config file to use "
+                           "[default: %default]")
+  parser.add_option("--pulsefile",
+                    action = "store", type = "string", dest = "pulsefile",
+                    default = None,
+                    help = "path to file containing a pulse message in "
+                           "json format that you want to inject into the monitor")
+  (options, args) = parser.parse_args()
+
+  # load the config file
+  f = open(options.configfile, 'r')
+  configcontent = f.read()
+  f.close()
+  config = json.loads(configcontent)
+
+  rlock = RLock()
+ 
+  extensionDir = os.path.join(os.getcwd(), "..", "..", "services", "sync", "tps")
+
+  if options.binary is None:
+    # If no binary is specified, start the pulse build monitor, and wait
+    # until we receive build notifications before running tests.
+    monitor = TPSPulseMonitor(extensionDir,
+                              config=config,
+                              autolog=options.autolog,
+                              emailresults=options.emailresults,
+                              testfile=options.testfile,
+                              logfile=options.logfile,
+                              rlock=rlock)
+    print "waiting for pulse build notifications"
+
+    if options.pulsefile:
+      # For testing purposes, inject a pulse message directly into
+      # the monitor.
+      builddata = json.loads(open(options.pulsefile, 'r').read())
+      monitor.onBuildComplete(builddata)
+
+    monitor.listen()
+    return
+
+  TPS = TPSTestRunner(extensionDir,
+                      emailresults=options.emailresults,
+                      testfile=options.testfile,
+                      logfile=options.logfile,
+                      binary=options.binary,
+                      config=config,
+                      rlock=rlock,
+                      mobile=options.mobile,
+                      autolog=options.autolog)
+  TPS.run_tests()
+
+if __name__ == "__main__":
+  main()
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/emailtemplate.py
@@ -0,0 +1,188 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import datetime
+
+def GenerateEmailBody(data, numpassed, numfailed, serverUrl):
+
+  now = datetime.datetime.now()
+  builddate = datetime.datetime.strptime(data['productversion']['buildid'],
+                                         '%Y%m%d%H%M%S')
+  tree = data['productversion']['repository']
+
+  row = """
+<tr>
+  <td><a href="http://hg.mozilla.org/services/services-central/file/tip/services/sync/tests/tps/{name}">{name}</a></td>
+  <td>{state}</td>
+  <td>{message}</td>
+</tr>
+"""
+
+  rowWithLog = """
+<tr>
+  <td><a href="http://hg.mozilla.org/services/services-central/services/sync/tests/tps/file/tip/{name}">{name}</a></td>
+  <td>{state}</td>
+  <td>{message} [<a href="{logurl}">view log</a>]</td>
+</tr>
+"""
+
+  rows = ""
+  for test in data['tests']:
+    if test.get('logurl'):
+      rows += rowWithLog.format(name=test['name'],
+                         state=test['state'],
+                         message=test['message'] if test['message'] else 'None',
+                         logurl=test['logurl'])
+    else:
+      rows += row.format(name=test['name'],
+                         state=test['state'],
+                         message=test['message'] if test['message'] else 'None')
+
+  body = """
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<head>
+    <title>TPS</title>
+    <style type="text/css">
+#headertable {{ border: solid 1px black; margin-bottom: 2em; border-collapse: collapse; font-size: 0.8em; }}
+#headertable th {{ border: solid 1px black; background-color: lightgray; padding: 4px; }}
+#headertable td {{ border: solid 1px black; padding: 4px; }}
+.light {{ color: gray; }}
+.pass, a.pass:link, a.pass:visited {{ color: green; font-weight: bold; }}
+.fail, a.fail:link, a.fail:visited {{ color: red; font-weight: bold; }}
+.rightgray {{ text-align: right; background-color: lightgray; }}
+#summarytable {{ border: solid 1px black; margin-bottom: 2em; border-collapse: collapse; font-size: 0.8em; }}
+#summarytable th {{ border: solid 1px black; background-color: lightgray; padding: 4px; }}
+#summarytable td {{ border: solid 1px black; padding: 4px; }}
+</style>
+</head>
+
+<body>
+    <div id="content">
+        
+<h2>TPS Testrun Details</h2>
+
+<table id="headertable">
+
+<tr>
+  <td class="rightgray">Testrun Date</td>
+  <td>{date}</td>
+
+</tr>
+<tr>
+  <td class="rightgray">Firefox Version</td>
+  <td>{firefox_version}</td>
+</tr>
+<tr>
+  <td class="rightgray">Firefox Build Date</td>
+  <td>{firefox_date}</td>
+</tr>
+
+<tr>
+  <td class="rightgray">Firefox Sync Version / Type</td>
+  <td>{sync_version} / {sync_type}
+  </td>
+</tr>
+<tr>
+  <td class="rightgray">Firefox Sync Changeset</td>
+  <td>
+
+    <a href="{repository}/rev/{changeset}">
+
+      {changeset}</a> / {sync_tree}
+
+  </td>
+</tr>
+<tr>
+  <td class="rightgray">Sync Server</td>
+  <td>{server}</td>
+</tr>
+<tr>
+  <td class="rightgray">OS</td>
+  <td>{os}</td>
+</tr>
+<tr>
+  <td class="rightgray">Passed Tests</td>
+
+  <td>
+  <span class="{passclass}">{numpassed}</span>
+  </td>
+</tr>
+<tr>
+  <td class="rightgray">Failed Tests</td>
+  <td>
+
+  <span class="{failclass}">{numfailed}</span>
+  </td>
+</tr>
+</table>
+
+
+<table id="summarytable">
+<thead>
+<tr>
+  <th>Testcase</th>
+  <th>Result</th>
+  <th>Message</th>
+</tr>
+</thead>
+
+{rows}
+
+</table>
+
+    </div>
+</body>
+</html>
+
+""".format(date=now.ctime(),
+           firefox_version=data['productversion']['version'],
+           firefox_date=builddate.ctime(),
+           sync_version=data['addonversion']['version'],
+           sync_type=data['synctype'],
+           sync_tree=tree[tree.rfind("/") + 1:],
+           repository=data['productversion']['repository'],
+           changeset=data['productversion']['changeset'],
+           os=data['os'],
+           rows=rows,
+           numpassed=numpassed,
+           numfailed=numfailed,
+           passclass="pass" if numpassed > 0 else "light",
+           failclass="fail" if numfailed > 0 else "light",
+           server=serverUrl if serverUrl != "" else "default"
+          )
+
+  return body
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/firefoxrunner.py
@@ -0,0 +1,161 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import copy
+import os
+import shutil
+import sys
+
+from mozprocess.pid import get_pids
+from mozprofile import Profile
+from mozregression.mozInstall import MozInstaller
+from mozregression.utils import download_url, get_platform
+from mozrunner import FirefoxRunner
+
+class TPSFirefoxRunner(object):
+
+  PROCESS_TIMEOUT = 240
+
+  def __init__(self, binary):
+    if binary is not None and ('http://' in binary or 'ftp://' in binary):
+      self.url = binary
+      self.binary = None
+    else:
+      self.url = None
+      self.binary = binary
+    self.runner = None
+    self.installdir = None
+
+  def __del__(self):
+    if self.installdir:
+      shutil.rmtree(self.installdir, True)
+
+  @property
+  def names(self):
+      if sys.platform == 'darwin':
+          return ['firefox', 'minefield']
+      if (sys.platform == 'linux2') or (sys.platform in ('sunos5', 'solaris')):
+          return ['firefox', 'mozilla-firefox', 'minefield']
+      if os.name == 'nt' or sys.platform == 'cygwin':
+          return ['firefox']
+
+  def download_build(self, installdir='downloadedbuild',
+                     appname='firefox', macAppName='Minefield.app'):
+    self.installdir = os.path.abspath(installdir)
+    buildName = os.path.basename(self.url)
+    pathToBuild = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                               buildName)
+
+    # delete the build if it already exists
+    if os.access(pathToBuild, os.F_OK):
+      os.remove(pathToBuild)
+
+    # download the build
+    print "downloading build"
+    download_url(self.url, pathToBuild)
+
+    # install the build
+    print "installing %s" % pathToBuild
+    shutil.rmtree(self.installdir, True)
+    MozInstaller(src=pathToBuild, dest=self.installdir, dest_app=macAppName)
+
+    # remove the downloaded archive
+    os.remove(pathToBuild)
+
+    # calculate path to binary
+    platform = get_platform()
+    if platform['name'] == 'Mac':
+      binary = '%s/%s/Contents/MacOS/%s-bin' % (installdir,
+                                                macAppName,
+                                                appname)
+    else:
+      binary = '%s/%s/%s%s' % (installdir,
+                               appname,
+                               appname,
+                               '.exe' if platform['name'] == 'Windows' else '')
+
+    return binary
+
+  def get_respository_info(self):
+    """Read repository information from application.ini and platform.ini."""
+    import ConfigParser
+
+    config = ConfigParser.RawConfigParser()
+    dirname = os.path.dirname(self.runner.binary)
+    repository = { }
+
+    for entry in [['application', 'App'], ['platform', 'Build']]:
+        (file, section) = entry
+        config.read(os.path.join(dirname, '%s.ini' % file))
+
+        for entry in [['SourceRepository', 'repository'], ['SourceStamp', 'changeset']]:
+            (key, id) = entry
+
+            try:
+                repository['%s_%s' % (file, id)] = config.get(section, key);
+            except:
+                repository['%s_%s' % (file, id)] = None
+
+    return repository
+
+  def run(self, profile=None, timeout=PROCESS_TIMEOUT, env=None, args=None):
+    """Runs the given FirefoxRunner with the given Profile, waits
+       for completion, then returns the process exit code
+    """
+    if profile is None:
+      profile = Profile()
+    self.profile = profile
+
+    if self.binary is None and self.url:
+      self.binary = self.download_build()
+
+    if self.runner is None:
+      self.runner = FirefoxRunner(self.profile, binary=self.binary)
+
+    self.runner.profile = self.profile
+
+    if env is not None:
+      self.runner.env.update(env)
+
+    if args is not None:
+      self.runner.cmdargs = copy.copy(args)
+
+    self.runner.start()
+
+    status = self.runner.process_handler.waitForFinish(timeout=timeout)
+
+    return status
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/phase.py
@@ -0,0 +1,106 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import os
+import re
+
+class TPSTestPhase(object):
+
+  lineRe = re.compile(
+      r"^(.*?)test phase (?P<matchphase>\d+): (?P<matchstatus>.*)$")
+
+  def __init__(self, phase, profile, testname, testpath, logfile, env,
+               firefoxRunner, logfn):
+    self.phase = phase
+    self.profile = profile
+    self.testname = str(testname) # this might be passed in as unicode
+    self.testpath = testpath
+    self.logfile = logfile
+    self.env = env
+    self.firefoxRunner = firefoxRunner
+    self.log = logfn
+    self._status = None
+    self.errline = ''
+
+  @property
+  def phasenum(self):
+    match = re.match('.*?(\d+)', self.phase)
+    if match:
+      return match.group(1)
+
+  @property
+  def status(self):
+    return self._status if self._status else 'unknown'
+
+  def run(self):
+    # launch Firefox
+    args = [ '-tps', self.testpath, 
+             '-tpsphase', self.phasenum, 
+             '-tpslogfile', self.logfile ]
+
+    self.log("\nlaunching firefox for phase %s with args %s\n" % 
+             (self.phase, str(args)))
+    returncode = self.firefoxRunner.run(env=self.env,
+                                        args=args, 
+                                        profile=self.profile)
+
+    # parse the logfile and look for results from the current test phase
+    found_test = False
+    f = open(self.logfile, 'r')
+    for line in f:
+
+      # skip to the part of the log file that deals with the test we're running
+      if not found_test:
+        if line.find("Running test %s" % self.testname) > -1:
+          found_test = True
+        else:
+          continue
+
+      # look for the status of the current phase
+      match = self.lineRe.match(line)
+      if match:
+        if match.group("matchphase") == self.phasenum:
+          self._status = match.group("matchstatus")
+          break
+
+      # set the status to FAIL if there is TPS error
+      if line.find("CROSSWEAVE ERROR: ") > -1 and not self._status:
+        self._status = "FAIL"
+        self.errline = line[line.find("CROSSWEAVE ERROR: ") + len("CROSSWEAVE ERROR: "):]
+
+    f.close()
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/pulse.py
@@ -0,0 +1,102 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import json
+import logging
+import socket
+
+from pulsebuildmonitor import PulseBuildMonitor
+
+from tps.thread import TPSTestThread
+
+class TPSPulseMonitor(PulseBuildMonitor):
+  """Listens to pulse messages, and initiates a TPS test run when
+     a relevant 'build complete' message is received.
+  """
+
+  def __init__(self, extensionDir, platform='linux', config=None,
+               autolog=False, emailresults=False, testfile=None,
+               logfile=None, rlock=None, **kwargs):
+    self.buildtype = 'opt'
+    self.autolog = autolog
+    self.emailresults = emailresults
+    self.testfile = testfile
+    self.logfile = logfile
+    self.rlock = rlock
+    self.extensionDir = extensionDir
+    self.config = config
+    self.tree = self.config.get('tree', ['services-central', 'places'])
+    self.platform = self.config.get('platform', 'linux')
+    self.label=('crossweave@mozilla.com|tps_build_monitor_' +
+                socket.gethostname())
+
+    self.logger = logging.getLogger('tps_pulse')
+    self.logger.setLevel(logging.DEBUG)
+    handler = logging.FileHandler('tps_pulse.log')
+    self.logger.addHandler(handler)
+
+    PulseBuildMonitor.__init__(self,
+                               tree=self.tree,
+                               label=self.label,
+                               mobile=False,
+                               logger=self.logger,
+                               **kwargs)
+
+  def onPulseMessage(self, data):
+    key = data['_meta']['routing_key']
+    #print key
+
+  def onBuildComplete(self, builddata):
+    print "================================================================="
+    print json.dumps(builddata)
+    print "================================================================="
+    try:
+      if not (builddata['platform'] == self.platform and
+              builddata['buildtype'] == self.buildtype):
+        return
+    except KeyError:
+      return
+    thread = TPSTestThread(self.extensionDir,
+                           builddata=builddata,
+                           emailresults=self.emailresults,
+                           autolog=self.autolog,
+                           testfile=self.testfile,
+                           logfile=self.logfile,
+                           rlock=self.rlock,
+                           config=self.config)
+    thread.daemon = True
+    thread.start()
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/sendemail.py
@@ -0,0 +1,83 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import smtplib
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+def SendEmail(From=None, To=None, Subject='No Subject', 
+              TextData=None, HtmlData=None,
+              Server='mail.mozilla.com', Port=465,
+              Username=None, Password=None):
+  """Sends an e-mail.
+  
+     From is an e-mail address, To is a list of e-mail adresses.
+     
+     TextData and HtmlData are both strings.  You can specify one or both.
+     If you specify both, the e-mail will be sent as a MIME multipart
+     alternative; i.e., the recipient will see the HTML content if his
+     viewer supports it, otherwise he'll see the text content.
+  """
+
+  if From is None or To is None:
+    raise Exception("Both From and To must be specified")
+  if TextData is None and HtmlData is None:
+    raise Exception("Must specify either TextData or HtmlData")
+
+  server = smtplib.SMTP_SSL(Server, Port)
+
+  if Username is not None and Password is not None:
+    server.login(Username, Password)
+
+  if HtmlData is None:
+    msg = MIMEText(TextData)
+  elif TextData is None:
+    msg = MIMEMultipart()
+    msg.preamble = Subject
+    msg.attach(MIMEText(HtmlData, 'html'))
+  else:
+    msg = MIMEMultipart('alternative')
+    msg.attach(MIMEText(TextData, 'plain'))
+    msg.attach(MIMEText(HtmlData, 'html'))
+
+  msg['Subject'] = Subject
+  msg['From'] = From
+  msg['To'] = ', '.join(To)
+
+  server.sendmail(From, To, msg.as_string())
+
+  server.quit()
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/testrunner.py
@@ -0,0 +1,526 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import httplib
+import json
+import os
+import platform
+import random
+import re
+import socket
+import tempfile
+import time
+import traceback
+import urllib
+
+from threading import RLock
+
+from mozprofile import Profile
+
+from tps.firefoxrunner import TPSFirefoxRunner
+from tps.phase import TPSTestPhase
+
+
+class TempFile(object):
+  """Class for temporary files that delete themselves when garbage-collected.
+  """
+
+  def __init__(self, prefix=None):
+    self.fd, self.filename = self.tmpfile = tempfile.mkstemp(prefix=prefix)
+
+  def write(self, data):
+    if self.fd:
+      os.write(self.fd, data)
+
+  def close(self):
+    if self.fd:
+      os.close(self.fd)
+      self.fd = None
+
+  def cleanup(self):
+    if self.fd:
+      self.close()
+    if os.access(self.filename, os.F_OK):
+      os.remove(self.filename)
+
+  __del__ = cleanup
+
+
+class TPSTestRunner(object):
+
+  default_env = { 'MOZ_CRASHREPORTER_DISABLE': '1',
+                  'GNOME_DISABLE_CRASH_DIALOG': '1',
+                  'XRE_NO_WINDOWS_CRASH_DIALOG': '1',
+                  'MOZ_NO_REMOTE': '1',
+                  'XPCOM_DEBUG_BREAK': 'warn',
+                }
+  default_preferences = { 'app.update.enabled' : False,
+                          'extensions.update.enabled'    : False,
+                          'extensions.update.notifyUser' : False,
+                          'browser.shell.checkDefaultBrowser' : False,
+                          'browser.tabs.warnOnClose' : False,
+                          'browser.warnOnQuit': False,
+                          'browser.sessionstore.resume_from_crash': False,
+                          'services.sync.firstSync': 'notReady',
+                          'services.sync.lastversion': '1.0',
+                          'services.sync.log.rootLogger': 'Trace',
+                          'services.sync.log.logger.service.main': 'Trace',
+                          'services.sync.log.logger.engine.bookmarks': 'Trace',
+                          'services.sync.log.appender.console': 'Trace',
+                          'services.sync.log.appender.debugLog.enabled': True,
+                          'browser.dom.window.dump.enabled': True,
+                          'extensions.checkCompatibility.4.0': False,
+                        }
+  syncVerRe = re.compile(
+      r"Weave version: (?P<syncversion>.*)\n")
+  ffVerRe = re.compile(
+      r"Firefox version: (?P<ffver>.*)\n")
+  ffDateRe = re.compile(
+      r"Firefox builddate: (?P<ffdate>.*)\n")
+
+  def __init__(self, extensionDir, emailresults=False, testfile="sync.test",
+               binary=None, config=None, rlock=None, mobile=False,
+               autolog=False, logfile="tps.log"):
+    self.extensions = []
+    self.emailresults = emailresults
+    self.testfile = testfile
+    self.logfile = os.path.abspath(logfile)
+    self.binary = binary
+    self.config = config if config else {}
+    self.repo = None
+    self.changeset = None
+    self.branch = None
+    self.numfailed = 0
+    self.numpassed = 0
+    self.nightly = False
+    self.rlock = rlock
+    self.mobile = mobile
+    self.autolog = autolog
+    self.tpsxpi = None
+    self.firefoxRunner = None
+    self.extensionDir = extensionDir
+    self.productversion = None
+    self.addonversion = None
+    self.postdata = {}
+    self.errorlogs = {}
+
+  @property
+  def mobile(self):
+    return self._mobile
+
+  @mobile.setter
+  def mobile(self, value):
+    self._mobile = value
+    self.synctype = 'desktop' if not self._mobile else 'mobile'
+
+  def log(self, msg, printToConsole=False):
+    """Appends a string to the logfile"""
+
+    f = open(self.logfile, 'a')
+    f.write(msg)
+    f.close()
+    if printToConsole:
+      print msg
+
+  def _zip_add_file(self, zip, file, rootDir):
+    zip.write(os.path.join(rootDir, file), file)
+
+  def _zip_add_dir(self, zip, dir, rootDir):
+    try:
+      zip.write(os.path.join(rootDir, dir), dir)
+    except:
+      # on some OS's, adding directory entries doesn't seem to work
+      pass
+    for root, dirs, files in os.walk(os.path.join(rootDir, dir)):
+      for f in files:
+        zip.write(os.path.join(root, f), os.path.join(dir, f))
+
+  def make_xpi(self):
+    """Build the test extension."""
+
+    if self.tpsxpi is None:
+      tpsxpi = os.path.join(self.extensionDir, "tps.xpi")
+
+      if os.access(tpsxpi, os.F_OK):
+        os.remove(tpsxpi)
+      if not os.access(os.path.join(self.extensionDir, "install.rdf"), os.F_OK):
+        raise Exception("extension code not found in %s" % self.extensionDir)
+
+      from zipfile import ZipFile
+      z = ZipFile(tpsxpi, 'w')
+      self._zip_add_file(z, 'chrome.manifest', self.extensionDir)
+      self._zip_add_file(z, 'install.rdf', self.extensionDir)
+      self._zip_add_dir(z, 'components', self.extensionDir)
+      self._zip_add_dir(z, 'modules', self.extensionDir)
+      z.close()
+
+      self.tpsxpi = tpsxpi
+
+    return self.tpsxpi
+
+  def run_single_test(self, testdir, testname):
+    testpath = os.path.join(testdir, testname)
+    self.log("Running test %s\n" % testname)
+
+    # Create a random account suffix that is used when creating test
+    # accounts on a staging server.
+    account_suffix = {"account-suffix": ''.join([str(random.randint(0,9))
+                                                 for i in range(1,6)])}
+    self.config['account'].update(account_suffix)
+
+    # Read and parse the test file, merge it with the contents of the config
+    # file, and write the combined output to a temporary file.
+    f = open(testpath, 'r')
+    testcontent = f.read()
+    f.close()
+    try:
+      test = json.loads(testcontent)
+    except:
+      test = json.loads(testcontent[testcontent.find("{"):testcontent.find("}") + 1])
+
+    testcontent += 'var config = %s;\n' % json.dumps(self.config, indent=2)
+    testcontent += 'var seconds_since_epoch = %d;\n' % int(time.time())
+
+    tmpfile = TempFile(prefix='tps_test_')
+    tmpfile.write(testcontent)
+    tmpfile.close()
+
+    # generate the profiles defined in the test, and a list of test phases
+    profiles = {}
+    phaselist = []
+    for phase in test:
+      profilename = test[phase]
+
+      # create the profile if necessary
+      if not profilename in profiles:
+        profiles[profilename] = Profile(preferences = self.preferences,
+                                        addons = self.extensions)
+
+      # create the test phase
+      phaselist.append(TPSTestPhase(phase,
+                                    profiles[profilename],
+                                    testname,
+                                    tmpfile.filename,
+                                    self.logfile,
+                                    self.env,
+                                    self.firefoxRunner,
+                                    self.log))
+
+    # sort the phase list by name
+    phaselist = sorted(phaselist, key=lambda phase: phase.phase)
+
+    # run each phase in sequence, aborting at the first failure
+    for phase in phaselist:
+      phase.run()
+
+      # if a failure occurred, dump the entire sync log into the test log
+      if phase.status != "PASS":
+        for profile in profiles:
+          self.log("\nDumping sync log for profile %s\n" %  profiles[profile].profile)
+          for root, dirs, files in os.walk(os.path.join(profiles[profile].profile, 'weave', 'logs')):
+            for f in files:
+              weavelog = os.path.join(profiles[profile].profile, 'weave', 'logs', f)
+              if os.access(weavelog, os.F_OK):
+                f = open(weavelog, 'r')
+                msg = f.read()
+                self.log(msg)
+                f.close()
+              self.log("\n")
+        break;
+
+    # grep the log for FF and sync versions
+    f = open(self.logfile)
+    logdata = f.read()
+    match = self.syncVerRe.search(logdata)
+    sync_version = match.group("syncversion") if match else 'unknown'
+    match = self.ffVerRe.search(logdata)
+    firefox_version = match.group("ffver") if match else 'unknown'
+    match = self.ffDateRe.search(logdata)
+    firefox_builddate = match.group("ffdate") if match else 'unknown'
+    f.close()
+    if phase.status == 'PASS':
+      logdata = ''
+    else:
+      # we only care about the log data for this specific test
+      logdata = logdata[logdata.find('Running test %s' % (str(testname))):]
+
+    result = {
+      'PASS': lambda x: ('TEST-PASS', ''),
+      'FAIL': lambda x: ('TEST-UNEXPECTED-FAIL', x.rstrip()),
+      'unknown': lambda x: ('TEST-UNEXPECTED-FAIL', 'test did not complete')
+    } [phase.status](phase.errline)
+    logstr = "\n%s | %s%s\n" % (result[0], testname, (' | %s' % result[1] if result[1] else ''))
+
+    repoinfo = self.firefoxRunner.get_respository_info()
+    apprepo = repoinfo.get('application_repository', '')
+    appchangeset = repoinfo.get('application_changeset', '')
+
+    # save logdata to a temporary file for posting to the db
+    tmplogfile = None
+    if logdata:
+      tmplogfile = TempFile(prefix='tps_log_')
+      tmplogfile.write(logdata)
+      tmplogfile.close()
+      self.errorlogs[testname] = tmplogfile
+
+    resultdata = ({ "productversion": { "version": firefox_version,
+                                        "buildid": firefox_builddate,
+                                        "builddate": firefox_builddate[0:8],
+                                        "product": "Firefox",
+                                        "repository": apprepo,
+                                        "changeset": appchangeset,
+                                      },
+                    "addonversion": { "version": sync_version,
+                                      "product": "Firefox Sync" },
+                    "name": testname,
+                    "message": result[1],
+                    "state": result[0],
+                    "logdata": logdata
+                  })
+
+    self.log(logstr, True)
+    for phase in phaselist:
+      print "\t%s: %s" % (phase.phase, phase.status)
+      if phase.status == 'FAIL':
+        break
+
+    return resultdata
+
+  def run_tests(self):
+    # delete the logfile if it already exists
+    if os.access(self.logfile, os.F_OK):
+      os.remove(self.logfile)
+
+    # Make a copy of the default env variables and preferences, and update
+    # them for mobile settings if needed.
+    self.env = self.default_env.copy()
+    self.preferences = self.default_preferences.copy()
+    if self.mobile:
+      self.preferences.update({'services.sync.client.type' : 'mobile'})
+
+    # Acquire a lock to make sure no other threads are running tests
+    # at the same time.
+    if self.rlock:
+      self.rlock.acquire()
+
+    try:
+      # Create the Firefox runner, which will download and install the
+      # build, as needed.
+      if not self.firefoxRunner:
+        self.firefoxRunner = TPSFirefoxRunner(self.binary)
+
+      # now, run the test group
+      self.run_test_group()
+
+    except:
+      traceback.print_exc()
+      self.numpassed = 0
+      self.numfailed = 1
+      if self.emailresults:
+        try:
+          self.sendEmail('<pre>%s</pre>' % traceback.format_exc(),
+                         sendTo='crossweave@mozilla.com')
+        except:
+          traceback.print_exc()
+      else:
+        raise
+
+    else:
+      try:
+        if self.autolog:
+          self.postToAutolog()
+        if self.emailresults:
+          self.sendEmail()
+      except:
+        traceback.print_exc()
+        try:
+          self.sendEmail('<pre>%s</pre>' % traceback.format_exc(),
+                         sendTo='crossweave@mozilla.com')
+        except:
+          traceback.print_exc()
+
+    # release our lock
+    if self.rlock:
+      self.rlock.release()
+
+    # dump out a summary of test results
+    print 'Test Summary\n'
+    for test in self.postdata.get('tests', {}):
+      print '%s | %s | %s' % (test['state'], test['name'], test['message'])
+
+  def run_test_group(self):
+    self.results = []
+    self.extensions = []
+
+    # set the OS we're running on
+    os_string = platform.uname()[2] + " " + platform.uname()[3]
+    if os_string.find("Darwin") > -1:
+      os_string = "Mac OS X " + platform.mac_ver()[0]
+    if platform.uname()[0].find("Linux") > -1:
+      os_string = "Linux " + platform.uname()[5]
+    if platform.uname()[0].find("Win") > -1:
+      os_string = "Windows " + platform.uname()[3]
+
+    # reset number of passed/failed tests
+    self.numpassed = 0
+    self.numfailed = 0
+
+    # build our tps.xpi extension
+    self.extensions.append(self.make_xpi())
+
+    # build the test list
+    try:
+      f = open(self.testfile)
+      jsondata = f.read()
+      f.close()
+      testfiles = json.loads(jsondata)
+      testlist = testfiles['tests']
+    except ValueError:
+      testlist = [os.path.basename(self.testfile)]
+    testdir = os.path.dirname(self.testfile)
+
+    # run each test, and save the results
+    for test in testlist:
+      result = self.run_single_test(testdir, test)
+
+      if not self.productversion:
+        self.productversion = result['productversion']
+      if not self.addonversion:
+        self.addonversion = result['addonversion']
+
+      self.results.append({'state': result['state'], 
+                           'name': result['name'], 
+                           'message': result['message'],
+                           'logdata': result['logdata']})
+      if result['state'] == 'TEST-PASS':
+        self.numpassed += 1
+      else:
+        self.numfailed += 1
+
+    # generate the postdata we'll use to post the results to the db
+    self.postdata = { 'tests': self.results, 
+                      'os':os_string,
+                      'testtype': 'crossweave',
+                      'productversion': self.productversion,
+                      'addonversion': self.addonversion,
+                      'synctype': self.synctype,
+                    }
+
+  def sendEmail(self, body=None, sendTo=None):
+    # send the result e-mail
+    if self.config.get('email') and self.config['email'].get('username') \
+       and self.config['email'].get('password'):
+
+      from tps.sendemail import SendEmail
+      from tps.emailtemplate import GenerateEmailBody
+
+      if body is None:
+        body = GenerateEmailBody(self.postdata, self.numpassed, self.numfailed, self.config['account']['serverURL'])
+
+      subj = "TPS Report: "
+      if self.numfailed == 0 and self.numpassed > 0:
+        subj += "YEEEAAAHHH"
+      else:
+        subj += "PC LOAD LETTER"
+
+      changeset = self.postdata['productversion']['changeset'] if \
+          self.postdata and self.postdata.get('productversion') and \
+          self.postdata['productversion'].get('changeset') \
+          else 'unknown'
+      subj +=", changeset " + changeset + "; " + str(self.numfailed) + \
+             " failed, " + str(self.numpassed) + " passed"
+
+      To = [sendTo] if sendTo else None
+      if not To:
+        if self.numfailed > 0 or self.numpassed == 0:
+          To = self.config['email'].get('notificationlist')
+        else:
+          To = self.config['email'].get('passednotificationlist')
+
+      if To:
+        SendEmail(From=self.config['email']['username'],
+                  To=To,
+                  Subject=subj,
+                  HtmlData=body,
+                  Username=self.config['email']['username'],
+                  Password=self.config['email']['password'])
+
+  def postToAutolog(self):
+    from mozautolog import RESTfulAutologTestGroup as AutologTestGroup
+
+    group = AutologTestGroup(
+              harness='crossweave',
+              testgroup='crossweave-%s' % self.synctype,
+              server=self.config.get('es'),
+              restserver=self.config.get('restserver'),
+              machine=socket.gethostname(),
+              platform=self.config.get('platform', None),
+              os=self.config.get('os', None),
+            )
+    tree = self.postdata['productversion']['repository']
+    group.set_primary_product(
+              tree=tree[tree.rfind("/")+1:],
+              version=self.postdata['productversion']['version'],
+              buildid=self.postdata['productversion']['buildid'],
+              buildtype='opt',
+              revision=self.postdata['productversion']['changeset'],
+            )
+    group.add_test_suite(
+              passed=self.numpassed,
+              failed=self.numfailed,
+              todo=0,
+            )
+    for test in self.results:
+      if test['state'] != "TEST-PASS":
+        errorlog = self.errorlogs.get(test['name'])
+        errorlog_filename = errorlog.filename if errorlog else None
+        group.add_test_failure(
+              test = test['name'],
+              status = test['state'],
+              text = test['message'],
+              logfile = errorlog_filename
+            )
+    group.submit()
+
+    # Iterate through all testfailure objects, and update the postdata
+    # dict with the testfailure logurl's, if any.
+    for tf in group.testsuites[-1].testfailures:
+      result = [x for x in self.results if x.get('name') == tf.test]
+      if not result:
+        continue
+      result[0]['logurl'] = tf.logurl
+
new file mode 100644
--- /dev/null
+++ b/testing/tps/tps/thread.py
@@ -0,0 +1,102 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is TPS.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Jonathan Griffin <jgriffin@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+from threading import Thread
+
+from testrunner import TPSTestRunner
+
+class TPSTestThread(Thread):
+
+  def __init__(self, extensionDir, builddata=None, emailresults=False,
+               testfile=None, logfile=None, rlock=None, config=None,
+               autolog=False):
+    assert(builddata)
+    assert(config)
+    self.extensionDir = extensionDir
+    self.builddata = builddata
+    self.emailresults = emailresults
+    self.testfile = testfile
+    self.logfile = logfile
+    self.rlock = rlock
+    self.config = config
+    self.autolog = autolog
+    Thread.__init__(self)
+
+  def run(self):
+    # run the tests in normal mode ...
+    TPS = TPSTestRunner(self.extensionDir,
+                        emailresults=self.emailresults,
+                        testfile=self.testfile,
+                        logfile=self.logfile,
+                        binary=self.builddata['buildurl'],
+                        config=self.config,
+                        rlock=self.rlock,
+                        mobile=False,
+                        autolog=self.autolog)
+    TPS.run_tests()
+
+    # ... and then again in mobile mode
+    TPS = TPSTestRunner(self.extensionDir,
+                        emailresults=self.emailresults,
+                        testfile=self.testfile,
+                        logfile=self.logfile,
+                        binary=self.builddata['buildurl'],
+                        config=self.config,
+                        rlock=self.rlock,
+                        mobile=True,
+                        autolog=self.autolog)
+    TPS.run_tests()
+
+    # ... and again via the staging server, if credentials are present
+    stageaccount = self.config.get('stageaccount')
+    if stageaccount:
+      username = stageaccount.get('username')
+      password = stageaccount.get('password')
+      passphrase = stageaccount.get('passphrase')
+      if username and password and passphrase:
+        stageconfig = self.config.copy()
+        stageconfig['account'] = stageaccount.copy()
+        TPS = TPSTestRunner(self.extensionDir,
+                            emailresults=self.emailresults,
+                            testfile=self.testfile,
+                            logfile=self.logfile,
+                            binary=self.builddata['buildurl'],
+                            config=stageconfig,
+                            rlock=self.rlock,
+                            mobile=False,
+                            autolog=self.autolog)
+        TPS.run_tests()