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 73683 5a1e829db409e9a853470e27b9db387d92419155
parent 73682 9d927d1f2ac82facccd6165ca47a4fe11c7f1e42
child 73684 3773e471ce368ee13d891eafa24353ab026cd8d9
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersphilikon, test-only, DONTBUILD
bugs674097
milestone8.0a1
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()