diff --git a/tests/unit/markup.js b/tests/unit/markup.js
new file mode 100644
index 000000000..5a1950031
--- /dev/null
+++ b/tests/unit/markup.js
@@ -0,0 +1,142 @@
+/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
+
+// Test cases for MessageTray markup parsing
+
+const JsUnit = imports.jsUnit;
+const Pango = imports.gi.Pango;
+
+const Environment = imports.ui.environment;
+Environment.init();
+
+const MessageTray = imports.ui.messageTray;
+
+// Assert that @input, assumed to be markup, gets "fixed" to @output,
+// which is valid markup. If @output is null, @input is expected to
+// convert to itself
+function assertConverts(input, output) {
+ if (!output)
+ output = input;
+ let fixed = MessageTray._fixMarkup(input, true);
+ JsUnit.assertEquals(output, fixed);
+
+ let parsed = false;
+ try {
+ Pango.parse_markup(fixed, -1, '');
+ parsed = true;
+ } catch (e) {}
+ JsUnit.assertEquals(true, parsed);
+}
+
+// Assert that @input, assumed to be plain text, gets escaped to @output,
+// which is valid markup.
+function assertEscapes(input, output) {
+ let fixed = MessageTray._fixMarkup(input, false);
+ JsUnit.assertEquals(output, fixed);
+
+ let parsed = false;
+ try {
+ Pango.parse_markup(fixed, -1, '');
+ parsed = true;
+ } catch (e) {}
+ JsUnit.assertEquals(true, parsed);
+}
+
+
+
+// CORRECT MARKUP
+
+assertConverts('foo');
+assertEscapes('foo', 'foo');
+
+assertConverts('foo');
+assertEscapes('foo', '<b>foo</b>');
+
+assertConverts('something foo');
+assertEscapes('something foo', 'something <i>foo</i>');
+
+assertConverts('foo something');
+assertEscapes('foo something', '<u>foo</u> something');
+
+assertConverts('bold italic and underlined');
+assertEscapes('bold italic and underlined', '<b>bold</b> <i>italic <u>and underlined</u></i>');
+
+assertConverts('this & that');
+assertEscapes('this & that', 'this & that');
+
+assertConverts('this < that');
+assertEscapes('this < that', 'this < that');
+
+assertConverts('this < that > the other');
+assertEscapes('this < that > the other', 'this < that > the other');
+
+assertConverts('this <that>');
+assertEscapes('this <that>', 'this <<i>that</i>>');
+
+assertConverts('this > that');
+assertEscapes('this > that', '<b>this</b> > <i>that</i>');
+
+
+
+// PARTIALLY CORRECT MARKUP
+// correct bits are kept, incorrect bits are escaped
+
+// unrecognized entity
+assertConverts('smile ☺!', 'smile ☺!');
+assertEscapes('smile ☺!', '<b>smile</b> ☺!');
+
+// stray '&'; this is really a bug, but it's easier to do it this way
+assertConverts('this & that', 'this & that');
+assertEscapes('this & that', '<b>this</b> & <i>that</i>');
+
+// likewise with stray '<'
+assertConverts('this < that', 'this < that');
+assertEscapes('this < that', 'this < that');
+
+assertConverts('this < that', 'this < that');
+assertEscapes('this < that', '<b>this</b> < <i>that</i>');
+
+assertConverts('this < that > the other', 'this < that > the other');
+assertEscapes('this < that > the other', 'this < that > the other');
+
+assertConverts('this <that>', 'this <that>');
+assertEscapes('this <that>', 'this <<i>that</i>>');
+
+// unknown tags
+assertConverts('tag', '<unknown>tag</unknown>');
+assertEscapes('tag', '<unknown>tag</unknown>');
+
+// make sure we check beyond the first letter
+assertConverts('tag', '<bunknown>tag</bunknown>');
+assertEscapes('tag', '<bunknown>tag</bunknown>');
+
+// with mix of good and bad, we keep the good and escape the bad
+assertConverts('known and tag', 'known and <unknown>tag</unknown>');
+assertEscapes('known and tag', '<i>known</i> and <unknown>tag</unknown>');
+
+
+
+// FULLY INCORRECT MARKUP
+// (fall back to escaping the whole thing)
+
+// tags not matched up
+assertConverts('incomplete', '<b>in<i>com</i>plete');
+assertEscapes('incomplete', '<b>in<i>com</i>plete');
+
+assertConverts('incomplete', 'in<i>com</i>plete</b>');
+assertEscapes('incomplete', 'in<i>com</i>plete</b>');
+
+// we don't support attributes, and it's too complicated to try
+// to escape both start and end tags, so we just treat it as bad
+assertConverts('good and bad', '<b>good</b> and <b style='bad'>bad</b>');
+assertEscapes('good and bad', '<b>good</b> and <b style='bad'>bad</b>');
+
+// this is just syntactically invalid
+assertConverts('unrecognized', '<b>unrecognized</b stuff>');
+assertEscapes('unrecognized', '<b>unrecognized</b stuff>');
+
+// mismatched tags
+assertConverts('mismatched', '<b>mismatched</i>');
+assertEscapes('mismatched', '<b>mismatched</i>');
+
+assertConverts('mismatched/unknown', '<b>mismatched/unknown</bunknown>');
+assertEscapes('mismatched/unknown', '<b>mismatched/unknown</bunknown>');