diff --git a/test/driver.js b/test/driver.js
new file mode 100644
index 0000000000000000000000000000000000000000..83ad4a179f43016e3b38b1560764485ebe8ec8c9
--- /dev/null
+++ b/test/driver.js
@@ -0,0 +1,42 @@
+var tests = [], runOnly = null;
+
+function Failure(why) {this.message = why;}
+
+function test(name, run) {tests.push({name: name, func: run});}
+function testCM(name, run, opts) {
+  test(name, function() {
+    var place = document.getElementById("testground"), cm = CodeMirror(place, opts);
+    try {run(cm);}
+    finally {place.removeChild(cm.getWrapperElement());}
+  });
+}
+
+function runTests(callback) {
+  function step(i) {
+    if (i == tests.length) return callback("done");
+    var test = tests[i];
+    if (runOnly != null && runOnly != test.name) return step(i + 1);
+    try {test.func(); callback("ok", test.name);}
+    catch(e) {
+      if (e instanceof Failure)
+        callback("fail", test.name, e.message);
+      else
+        callback("error", test.name, e.toString());
+    }
+    setTimeout(function(){step(i + 1);}, 20);
+  }
+  step(0);
+}
+
+function eq(a, b, msg) {
+  if (a != b) throw new Failure(a + " != " + b + (msg ? " (" + msg + ")" : ""));
+}
+function eqPos(a, b, msg) {
+  if (a == b) return;
+  if (a == null || b == null) throw new Failure("comparing point to null");
+  eq(a.line, b.line, msg);
+  eq(a.ch, b.ch, msg);
+}
+function is(a, msg) {
+  if (!a) throw new Failure("assertion failed" + (msg ? " (" + msg + ")" : ""));
+}
diff --git a/test/index.html b/test/index.html
index 53471b1b92d85b128c3a11e69c723dad57cf1626..3f55eb6c0566af35e2b0b3a8aa4ed1d67bd90522 100644
--- a/test/index.html
+++ b/test/index.html
@@ -8,7 +8,7 @@
 
     <style type="text/css">
       .ok {color: #0e0;}
-      .failure {color: #e00;}
+      .fail {color: #e00;}
       .error {color: #c90;}
     </style>
   </head>
@@ -18,11 +18,37 @@
     <p>A limited set of programmatic sanity tests for CodeMirror.</p>
 
     <pre id=output></pre>
+    <pre id=status></pre>
 
     <div style="visibility: hidden" id=testground>
       <form><textarea id="code" name="code"></textarea><input type=submit value=ok name=submit></form>
     </div>
 
+    <script src="driver.js"></script>
     <script src="test.js"></script>
+    <script>
+      window.onload = function() {
+        runTests(displayTest);
+      };
+
+      var output = document.getElementById("output"), statusElt = document.getElementById("status");
+      var count = 0, failed = 0, bad = "";
+      function displayTest(type, name, msg) {
+        if (type != "done") ++count;
+        var countText = "Ran " + count + " of " + tests.length + " tests\n";
+        if (type == "ok") {
+          statusElt.innerHTML = countText + "<span class=ok>Test '" + CodeMirror.htmlEscape(name) + "' succeeded</span>";
+        } else if (type == "error" || type == "fail") {
+          ++failed;
+          statusElt.innerHTML = countText;
+          bad += CodeMirror.htmlEscape(name) + ": <span class=" + type + ">" + CodeMirror.htmlEscape(msg) + "</span>\n";
+          output.innerHTML = bad;
+        } else if (type == "done") {
+          statusElt.innerHTML = "Ran " + count + " tests\n" +
+            (failed ? "<span class=fail>" + failed + " failure" + (failed > 1 ? "s" : "") + "</span>"
+                    : "<span class=ok>All passed</span>");
+        }
+      }
+    </script>
   </body>
 </html>
diff --git a/test/test.js b/test/test.js
index a7958d0c2e27f08e523fd20393ab7325bbddc536..aba81697a30e1968c84746793c49bce21f3c4618 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1,4 +1,6 @@
-var tests = [];
+function forEach(arr, f) {
+  for (var i = 0, e = arr.length; i < e; ++i) f(arr[i]);
+}
 
 test("fromTextArea", function() {
   var te = document.getElementById("code");
@@ -115,12 +117,17 @@ testCM("lineInfo", function(cm) {
   eq(cm.lineInfo(1).markerText, null);
 }, {value: "111111\n222222\n333333"});
 
+function addBigDoc(cm, width, height) {
+  var content = [], line = "";
+  for (var i = 0; i < width; ++i) line += "x";
+  for (var i = 0; i < height; ++i) content.push(line);
+  cm.setValue(content.join("\n"));
+}
+
 testCM("coords", function(cm) {
   var scroller = cm.getScrollerElement();
   scroller.style.height = "100px";
-  var content = [];
-  for (var i = 0; i < 200; ++i) content.push("------------------------------" + i);
-  cm.setValue(content.join("\n"));
+  addBigDoc(cm, 32, 200);
   var top = cm.charCoords({line: 0, ch: 0});
   var bot = cm.charCoords({line: 200, ch: 30});
   is(top.x < bot.x);
@@ -133,9 +140,7 @@ testCM("coords", function(cm) {
 });
 
 testCM("coordsChar", function(cm) {
-  var content = [];
-  for (var i = 0; i < 70; ++i) content.push("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
-  cm.setValue(content.join("\n"));
+  addBigDoc(cm, 35, 70);
   for (var ch = 0; ch < 35; ch += 2) {
     for (var line = 0; line < 70; line += 5) {
       cm.setCursor(line, ch);
@@ -293,59 +298,18 @@ testCM("bug577", function(cm) {
   cm.undo();
 });
 
-// Scaffolding
-
-function htmlEscape(str) {
-  return str.replace(/[<&]/g, function(str) {return str == "&" ? "&amp;" : "&lt;";});
-}
-function forEach(arr, f) {
-  for (var i = 0, e = arr.length; i < e; ++i) f(arr[i]);
-}
-
-function Failure(why) {this.message = why;}
-
-function test(name, run) {tests.push({name: name, func: run});}
-function testCM(name, run, opts) {
-  test(name, function() {
-    var place = document.getElementById("testground"), cm = CodeMirror(place, opts);
-    try {run(cm);}
-    finally {place.removeChild(cm.getWrapperElement());}
-  });
-}
-
-function runTests() {
-  var failures = [], run = 0;
-  for (var i = 0; i < tests.length; ++i) {
-    var test = tests[i];
-    try {test.func();}
-    catch(e) {
-      if (e instanceof Failure)
-        failures.push({type: "failure", test: test.name, text: e.message});
-      else
-        failures.push({type: "error", test: test.name, text: e.toString()});
-    }
-    run++;
-  }
-  var html = [run + " tests run."];
-  if (failures.length)
-    forEach(failures, function(fail) {
-      html.push(fail.test + ': <span class="' + fail.type + '">' + htmlEscape(fail.text) + "</span>");
-    });
-  else html.push('<span class="ok">All passed.</span>');
-  document.getElementById("output").innerHTML = html.join("\n");
-}
-
-function eq(a, b, msg) {
-  if (a != b) throw new Failure(a + " != " + b + (msg ? " (" + msg + ")" : ""));
-}
-function eqPos(a, b, msg) {
-  if (a == b) return;
-  if (a == null || b == null) throw new Failure("comparing point to null");
-  eq(a.line, b.line, msg);
-  eq(a.ch, b.ch, msg);
-}
-function is(a, msg) {
-  if (!a) throw new Failure("assertion failed" + (msg ? " (" + msg + ")" : ""));
-}
-
-window.onload = runTests;
+testCM("scrollSnap", function(cm) {
+  cm.getScrollerElement().style.height = "100px";
+  cm.getWrapperElement().style.width = "100px";
+  addBigDoc(cm, 200, 200);
+  cm.setCursor({line: 100, ch: 180});
+  var info = cm.getScrollInfo();
+  is(info.x > 0 && info.y > 0);
+  cm.setCursor({line: 0, ch: 0});
+  info = cm.getScrollInfo();
+  is(info.x == 0 && info.y == 0, "scrolled clean to top");
+  cm.setCursor({line: 100, ch: 180});
+  cm.setCursor({line: 199, ch: 0});
+  info = cm.getScrollInfo();
+  is(info.x == 0 && info.y == info.height - 100, "scrolled clean to bottom");
+});