Revision: 4807
Author:   [email protected]
Date:     Thu Mar 15 12:38:53 2012
Log:      Move CSS Stylesheet Rewriter tests into a JavaScript file
http://codereview.appspot.com/5784076

This reworks CssRewriterTest so that we can use tests specified in JavaScript
both in the Java version and to test the JavaScript version.

A later CL will wire the tests into sanitize-css-test.html.

This change also factors out sanitizeCss from html-emitter, moving it to sanitize-css.js.

[email protected]

http://code.google.com/p/google-caja/source/detail?r=4807

Added:
 /trunk/tests/com/google/caja/plugin/css-stylesheet-tests.js
Modified:
 /trunk/src/com/google/caja/plugin/html-emitter.js
 /trunk/src/com/google/caja/plugin/sanitizecss.js
 /trunk/tests/com/google/caja/plugin/CssRewriterTest.java
 /trunk/tests/com/google/caja/util/CajaTestCase.java

=======================================
--- /dev/null
+++ /trunk/tests/com/google/caja/plugin/css-stylesheet-tests.js Thu Mar 15 12:38:53 2012
@@ -0,0 +1,490 @@
+// Copyright (C) 2012 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/*
+ * @fileoverview
+ * These test cases are run in JS against santizeCss.js and against the
+ * CssRewriter in Java.
+ * This file is meant to be in JSONP.  If you strip comments and the
+ * runtest call, you can parse the rest as a JSON object.
+ */
+
+runTests([
+  {
+    "test_name": "UnknownTagsRemoved",
+    "tests": [
+      {
+        "cssText": "bogus { display: none }",
+        "golden": ""
+      },
+      {
+        "cssText": "a, bogus, i { display: none }",
+        "golden": "a, i {\n  display: none\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "BadTagsRemoved",
+    "tests": [
+      {
+        "cssText": "script { display: none }",
+        "golden": "",
+        "messages": [
+          {
+            "type": "UNSAFE_TAG",
+            "level": "ERROR",
+            "args": ["script"]
+          }
+        ]
+      },
+      {
+        "cssText": "strike, script, strong { display: none }",
+        "golden": "strike, strong {\n  display: none\n}",
+        "messages": [
+          {
+            "type": "UNSAFE_TAG",
+            "level": "ERROR",
+            "args": ["script"]
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "test_name": "BadAttribsRemoved",
+    "tests": [
+      {
+        "cssText": "div[zwop] { color: blue }",
+        "golden": ""
+      },
+    ]
+  },
+  {
+    "test_name": "InvalidPropertiesRemoved",
+    "tests": [
+      // visibility takes "hidden", not "none"
+      {
+        "cssText": "a { visibility: none }",
+        "golden": ""
+      },
+      {
+        "cssText": "a { visibility: hidden; }",
+        "golden": "a {\n  visibility: hidden\n}"
+      },
+      // no such property
+      {
+        "cssText": "a { bogus: bogus }",
+        "golden": ""
+      },
+      // make sure it doesn't interfere with others
+      {
+        "cssText": "a { visibility: none; font-weight: bold }",
+        "golden": "a {\n  font-weight: bold\n}"
+      },
+      {
+        "cssText": "a { font-weight: bold; visibility: none }",
+        "golden": "a {\n  font-weight: bold\n}"
+      },
+      {
+        "cssText": "a { bogus: bogus; font-weight: bold }",
+        "golden": "a {\n  font-weight: bold\n}"
+      },
+      {
+        "cssText": "a { font-weight: bold; bogus: bogus }",
+        "golden": "a {\n  font-weight: bold\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "ContentRemoved",
+    "tests": [
+      {
+        "cssText":
+ "a { color: blue; content: 'booyah'; text-decoration: underline; }",
+        "golden": "a {\n  color: blue;\n  text-decoration: underline\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "AttrRemoved",
+    "tests": [
+      {
+        "cssText": "a:attr(href) { color: blue }",
+        "golden": ""
+      },
+      {
+ "cssText": "a:attr(href) { color: blue } b { font-weight: bolder }",
+        "golden": "b {\n  font-weight: bolder\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "FontNamesQuoted",
+    "tests": [
+      {
+        "cssText":
+ "a { font:12pt Times New Roman, Times,\"Times Old Roman\",serif }",
+        "golden": "a {\n  font: 12pt 'Times New Roman', 'Times',"
+            + " 'Times Old Roman', serif\n}"
+      },
+      {
+        "cssText": "a { font:bold 12pt Arial Black }",
+        "golden": "a {\n  font: bold 12pt 'Arial Black'\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "Namespacing",
+    "tests": [
+      {
+        "cssText": "a.foo { color:blue }",
+        "golden": "a.foo {\n  color: blue\n}"
+      },
+      {
+        "cssText": "#foo { color: blue }",
+        "golden": "#foo {\n  color: blue\n}"
+      },
+      {
+        "cssText": "body.ie6 p { color: blue }",
+        "golden": "body.ie6 p {\n  color: blue\n}"
+      },
+      {
+        "cssText": "body { margin: 0; }",
+        "golden": ""
+      },  // Not allowed
+      {
+        "cssText": "body.ie6 { margin: 0; }",
+        "golden": ""
+      },  // Not allowed
+      {
+        "cssText": "* html p { margin: 0; }",
+        "golden": "* html p {\n  margin: 0\n}"
+      },
+      {
+        "cssText": "* html { margin: 0; }",
+        "golden": ""
+      },  // Not allowed
+      {
+        "cssText": "* html > * > p { margin: 0; }",
+        "golden": ""
+      },  // Not allowed
+      {
+        "cssText": "#foo > #bar { color: blue }",
+        "golden": "#foo > #bar {\n  color: blue\n}"
+      },
+      {
+        "cssText": "#foo .bar { color: blue }",
+        "golden": "#foo .bar {\n  color: blue\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "UnsafeIdentifiers",
+    "tests": [
+      {
+ "cssText": "a.foo, b#c\\2c d, .e { color:blue }", // "\\2c " -> ","
+        "golden": "a.foo, .e {\n  color: blue\n}"
+      },
+      {
+        "cssText": "a.foo, .b_c {color: blue}",
+        "golden": "a.foo, .b_c {\n  color: blue\n}"
+      },
+      {
+        "cssText": "a.foo, ._c {color: blue}",
+        "golden": "a.foo {\n  color: blue\n}"
+      },
+      {
+        "cssText": "a._c {_color: blue; margin:0;}",
+        "golden": ""
+      },
+      {
+        "cssText": "a#_c {_color: blue; margin:0;}",
+        "golden": ""
+      },
+      {
+        "cssText": ".c__ {_color: blue; margin:0;}",
+        "golden": ""
+      },
+      {
+        "cssText": "#c__ {_color: blue; margin:0;}",
+        "golden": ""
+      }
+    ]
+  },
+  {
+    "test_name": "PseudosWhitelisted",
+    "tests": [
+      {
+        "cssText": "a:link, a:badness { color:blue }",
+        "golden": "a:link {\n  color: blue\n}"
+      },
+      {
+        "cssText": "a:visited { color:blue }",
+        "golden": "a:visited {\n  color: blue\n}",
+        "messages": []
+      },
+
+    // Properties that are on DOMita's HISTORY_INSENSITIVE_STYLE_WHITELIST
+    // should not be allowed in any rule that correlates with the :visited
+    // pseudo selector.
+    // TODO: How is this a whitelist then?
+      {
+        "cssText":
+          "a:visited { color:blue; float:left; _float:left; *float:left }",
+        "golden": "a:visited {\n  color: blue\n}",
+        "messages": [
+          {
+            "type": "DISALLOWED_CSS_PROPERTY_IN_SELECTOR",
+            "level": "ERROR",
+            "args": [
+              "test:1+25@25 - 30@30",
+              "float",
+              "test:1+1@1 - 10@10"
+            ]
+          },
+          {
+            "type": "DISALLOWED_CSS_PROPERTY_IN_SELECTOR",
+            "level": "ERROR",
+            "args": [
+              "test:1+37@37 - 43@43",
+              "_float",
+              "test:1+1@1 - 10@10"
+            ]
+          },
+          {
+            "type": "DISALLOWED_CSS_PROPERTY_IN_SELECTOR",
+            "level": "ERROR",
+            "args": [
+              "test:1+51@51 - 56@56",
+              "float",
+              "test:1+1@1 - 10@10"
+            ]
+          }
+        ]
+      },
+      {
+        "cssText":
+          "a:visited { COLOR:blue; FLOAT:left; _FLOAT:left; *FLOAT:left }",
+        "golden": "a:visited {\n  color: blue\n}"
+      },
+
+      {
+        "cssText": "*:visited { color: blue; }",
+        "golden": "a:visited {\n  color: blue\n}"
+      },
+      {
+        "cssText": "#foo:visited { color: blue; }",
+        "golden": "a#foo:visited {\n  color: blue\n}"
+      },
+      {
+        "cssText": ".foo:link { color: blue; }",
+        "golden": "a.foo:link {\n  color: blue\n}"
+      },
+
+      {
+        "cssText": ""
+        + "#foo:visited, div, .bar:link, p {\n"
+        + "  padding: 1px;\n"
+        + "  color: blue;\n"
+        + "}",
+        "golden": ""
+        + "a#foo:visited, a.bar:link {\n"
+        + "  color: blue\n"
+        + "}\n"
+        + "div, p {\n"
+        + "  padding: 1px;\n"
+        + "  color: blue\n"
+        + "}"
+      },
+
+      {
+        "cssText": ""
+        + "a#foo-bank {"
+        + "  background: 'http://whitelisted-host.com/?bank=X&u=Al';"
+        + "  color: purple"
+        + "}",
+        "golden": ""
+        + "a#foo-bank {\n"
+ + " background: url('http://whitelisted-host.com/?bank=X&u=Al');\n"
+        + "  color: purple\n"
+        + "}",
+        "messages": []
+      },
+      // Differs from the previous only in that it has the :visited pseudo
+ // selector which means we can't allow it to cause a network fetch because
+      // that could leak user history state.
+
+      {
+        "cssText": ""
+        + "a#foo-bank:visited {"
+        + "  background-image: 'http://whitelisted-host.com/?bank=X&u=Al';"
+        + "  color: purple"
+        + "}",
+        "golden": ""
+        + "a#foo-bank:visited {\n"
+        + "  color: purple\n"
+        + "}"
+      }
+    ]
+  },
+  {
+    "test_name": "NoBadUrls",
+    "tests": [
+      // ok
+      {
+        "cssText": "#foo { background: url(/bar.png) }",
+        "golden": "#foo {\n  background: url('/foo/bar.png')\n}"
+      },
+      {
+        "cssText": "#foo { background: url('/bar.png') }",
+        "golden": "#foo {\n  background: url('/foo/bar.png')\n}"
+      },
+      {
+        "cssText": "#foo { background: '/bar.png' }",
+        "golden": "#foo {\n  background: url('/foo/bar.png')\n}"
+      },
+      {
+        "cssText":
+          "#foo { background: 'http://whitelisted-host.com/blinky.gif' }",
+        "golden":
+ "#foo {\n background: url('http://whitelisted-host.com/blinky.gif')\n}"
+      },
+
+      // disallowed
+      {
+        "cssText": "#foo { background: url('http://cnn.com/bar.png') }",
+        "golden": ""
+      },
+      {
+        "cssText": "#foo { background: 'http://cnn.com/bar.png' }",
+        "golden": ""
+      }
+    ]
+  },
+  {
+    // "*" selectors should rewrite properly.
+    // http://code.google.com/p/google-caja/issues/detail?id=57
+    "test_name": "WildcardSelectors",
+    "tests": [
+      {
+        "cssText": "div * { margin: 0; }",
+        "golden": "div * {\n  margin: 0\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "UnitlessLengths",
+    "tests": [
+      {
+        "cssText": "div { padding: 10 0 5.0 4 }",
+        "golden": "div {\n  padding: 10px 0 5.0px 4px\n}"
+      },
+      {
+        "cssText": "div { margin: -5 5; z-index: 2 }",
+        "golden": "div {\n  margin: -5px 5px;\n  z-index: 2\n}"
+      }
+    ]
+  },
+  {
+    "test_name": "UserAgentHacks",
+    "tests": [
+      {
+        "cssText": ""
+        + "p {\n"
+        + "  color: blue;\n"
+        + "  *color: red;\n"
+        + "  background-color: green;\n"
+        + "  *background-color: yelow;\n"  // misspelled
+        + "  font-weight: bold\n"
+        + "}",
+        "golden": ""
+        + "p {\n"
+        + "  color: blue;\n"
+        + "  *color: red;\n"  // Good user agent hack
+        + "  background-color: green;\n"
+        // Bad user-agent hack removed.
+        + "  font-weight: bold\n"
+        + "}",
+        "messages": [
+          {
+            "type": "MALFORMED_CSS_PROPERTY_VALUE",
+            "level": "WARNING",
+            "args": [
+              "background-color",
+              "==>yelow<=="
+            ]
+          }
+        ]
+      },
+      {
+        "cssText": "a.c {_color: blue; margin:0;}",
+        "golden": "a.c {\n  _color: blue;\n  margin: 0\n}",
+        "messages": []
+      }
+    ]
+  },
+  {
+    "test_name": "NonStandardColors",
+    "tests": [
+      {
+        "cssText": "a.c { color: LightSlateGray; background: ivory; }",
+        "golden": "a.c {\n  color: #789;\n  background: #fffff0\n}",
+        "messages": [
+          {
+            "type": "NON_STANDARD_COLOR",
+            "level": "LINT",
+            "args": [
+              "lightslategray",
+              "#789"
+            ]
+          },
+          {
+            "type": "NON_STANDARD_COLOR",
+            "level": "LINT",
+            "args": [
+              "ivory",
+              "#fffff0"
+            ]
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "test_name": "FixedPositioning",
+    "tests": [
+      {
+        "cssText": "#foo { position: absolute; left: 0px; top: 0px }",
+ "golden": "#foo {\n position: absolute;\n left: 0px;\n top: 0px\n}",
+        "messages": []
+      },
+      {
+        "cssText": "#foo { position: fixed; left: 0px; top: 0px }",
+        "golden": "#foo {\n  left: 0px;\n  top: 0px\n}",
+        "messages": [
+          // TODO(mikesamuel): fix message.
+          // "fixed" is well-formed but disallowed.
+          {
+            "type": "MALFORMED_CSS_PROPERTY_VALUE",
+            "level": "WARNING",
+            "args": [
+              "position",
+              "==>fixed<=="
+            ]
+          }
+        ]
+      }
+    ]
+  }]
+)
=======================================
--- /trunk/src/com/google/caja/plugin/html-emitter.js Tue Feb 28 23:15:18 2012 +++ /trunk/src/com/google/caja/plugin/html-emitter.js Thu Mar 15 12:38:53 2012
@@ -24,8 +24,8 @@
  *
  * @author [email protected]
  * @provides HtmlEmitter
- * @requires bridalMaker html html4 cajaVM parseCssStylesheet console
- * @requires cssSchema sanitizeCssProperty sanitizeCssSelectors
+ * @requires bridalMaker html html4 cajaVM
+ * @requires sanitizeStylesheet
  */

 /**
@@ -370,164 +370,10 @@
         // Ignore problems dispatching error.
       }
     }
-
-    var allowed = {};
-    var cssMediaTypeWhitelist = {
-      'braille': allowed,
-      'embossed': allowed,
-      'handheld': allowed,
-      'print': allowed,
-      'projection': allowed,
-      'screen': allowed,
-      'speech': allowed,
-      'tty': allowed,
-      'tv': allowed
-    };
-
-    function sanitizeHistorySensitive(blockOfProperties) {
-      return '{}';  // TODO: implement me.
-    }

     function defineUntrustedStylesheet(cssText) {
-      var safeCss = void 0;
-      // A stack describing the { ... } regions.
-      // Null elements indicate blocks that should not be emitted.
-      var blockStack = [];
- // True when the content of the current block should be left off safeCss. - // If we don't have a domicile then we don't have a way to sanitize CSS
-      // properties.
-      var elide = !domicile;
-      parseCssStylesheet(
-          cssText,
-          {
-            startStylesheet: function () {
-              safeCss = [];
-            },
-            endStylesheet: function () {
-            },
-            startAtrule: function (atIdent, headerArray) {
-              if (elide) {
-                atIdent = null;
-              } else if (atIdent === '@media') {
-                headerArray = headerArray.filter(
-                  function (mediaType) {
-                    return cssMediaTypeWhitelist[mediaType] == allowed;
-                  });
-                if (headerArray.length) {
-                  safeCss.push(atIdent, headerArray.join(','), '{');
-                } else {
-                  atIdent = null;
-                }
-              } else {
-                if (atIdent === '@import') {
-                  if ('undefined' !== typeof console) {
- console.log('@import ' + headerArray.join(' ') + ' elided');
-                  }
-                }
-                atIdent = null;  // Elide the block.
-              }
-              elide = !atIdent;
-              blockStack.push(atIdent);
-            },
-            endAtrule: function () {
-              var atIdent = blockStack.pop();
-              if (!elide) {
-                safeCss.push(';');
-              }
-              checkElide();
-            },
-            startBlock: function () {
-              // There are no bare blocks in CSS, so we do not change the
-              // block stack here, but instead in the events that bracket
-              // blocks.
-              if (!elide) {
-                safeCss.push('{');
-              }
-            },
-            endBlock: function () {
-              if (!elide) {
-                safeCss.push('}');
-                elide = true;  // skip any semicolon from endAtRule.
-              }
-            },
-            startRuleset: function (selectorArray) {
-              var historySensitiveSelectors = void 0;
-              var removeHistoryInsensitiveSelectors = false;
-              if (!elide) {
-                var selectors = sanitizeCssSelectors(selectorArray);
-                var historyInsensitiveSelectors = selectors[0];
-                historySensitiveSelectors = selectors[1];
-                if (!historyInsensitiveSelectors.length
-                    && !historySensitiveSelectors.length) {
-                  elide = true;
-                } else {
-                  var selector = historyInsensitiveSelectors.join(', ');
-                  if (!selector) {
-                    // If we have only history sensitive selectors,
- // use an impossible rule so that we can capture the content
-                    // for later processing by
-                    // history insenstive content for use below.
-                    selector = 'head > html';
-                    removeHistoryInsensitiveSelectors = true;
-                  }
-                  safeCss.push(selector);
-                }
-              }
-              blockStack.push(
-                  elide
-                  ? null
-                  // Sometimes a single list of selectors is split in two,
-                  //   div, a:visited
-                  // because we want to allow some properties for DIV that
-                  // we don't want to allow for A:VISITED to avoid leaking
-                  // user history.
-                  // Store the history sensitive selectors and the position
-                  // where the block starts so we can later create a copy
-                  // of the permissive tokens, and filter it to handle the
-                  // history sensitive case.
-                  : {
-                      historySensitiveSelectors: historySensitiveSelectors,
-                      endOfSelecctors: safeCss.length,
-                      removeHistoryInsensitiveSelectors:
-                         removeHistoryInsensitiveSelectors
-                    });
-            },
-            endRuleset: function () {
-              var rules = blockStack.pop();
-              var propertiesEnd = safeCss.length;
-              if (!elide && rules) {
-                var extraSelectors = rules.historySensitiveSelectors;
-                if (extraSelectors.length) {
- var propertyGroupTokens = safeCss.slice(rules.endOfSelectors);
-                  safeCss.push(extraSelectors.join(', '));
-                  safeCss.push.apply(
- safeCss, sanitizeHistorySensitive(propertyGroupTokens));
-                }
-              }
-              if (rules && rules.removeHistoryInsensitiveSelectors) {
-                safeCss.splice(rules.endOfSelectors - 1, propertiesEnd);
-              }
-              checkElide();
-            },
-            declaration: function (property, valueArray) {
-              if (!elide && domicile) {
-                var schema = cssSchema[property];
-                var sanitizeUri = void 0;  // TODO
-                if (schema) {
-                  sanitizeCssProperty(property, valueArray, sanitizeUri);
-                  if (valueArray.length) {
-                    safeCss.push(property, ':', valueArray.join(' '), ';');
-                  }
-                }
-              }
-            }
-          });
-      function checkElide() {
-        elide = blockStack.length === 0
-            || blockStack[blockStack.length-1] !== null;
-      }
+      var safeCssText = sanitizeStylesheet(cssText);
       var document = insertionPoint.ownerDocument;
-      var safeCssText = safeCss.join('');
       document.getElementsByTagName('head')[0].appendChild(
           bridal.createStylesheet(document, safeCssText));
     }
=======================================
--- /trunk/src/com/google/caja/plugin/sanitizecss.js Mon Feb 27 09:52:24 2012 +++ /trunk/src/com/google/caja/plugin/sanitizecss.js Thu Mar 15 12:38:53 2012
@@ -24,10 +24,14 @@
  * @requires CSS_PROP_BIT_QSTRING_CONTENT
  * @requires CSS_PROP_BIT_QSTRING_URL
  * @requires CSS_PROP_BIT_QUANTITY
+ * @requires console
+ * @requires cssSchema
  * @requires decodeCss
  * @requires html4
+ * @requires parseCssStylesheet
  * @provides sanitizeCssProperty
  * @provides sanitizeCssSelectors
+ * @provides sanitizeStylesheet
  */

 /**
@@ -345,3 +349,162 @@

   return [historyInsensitiveSelectors, historySensitiveSelectors];
 }
+
+var sanitizeStylesheet = (function () {
+  var allowed = {};
+  var cssMediaTypeWhitelist = {
+    'braille': allowed,
+    'embossed': allowed,
+    'handheld': allowed,
+    'print': allowed,
+    'projection': allowed,
+    'screen': allowed,
+    'speech': allowed,
+    'tty': allowed,
+    'tv': allowed
+  };
+
+  function sanitizeHistorySensitive(blockOfProperties) {
+    return '{}';  // TODO: implement me.
+  }
+
+  return function /*sanitizeStylesheet*/(cssText) {
+    var safeCss = void 0;
+    // A stack describing the { ... } regions.
+    // Null elements indicate blocks that should not be emitted.
+    var blockStack = [];
+ // True when the content of the current block should be left off safeCss.
+    var elide = false;
+    parseCssStylesheet(
+        cssText,
+        {
+          startStylesheet: function () {
+            safeCss = [];
+          },
+          endStylesheet: function () {
+          },
+          startAtrule: function (atIdent, headerArray) {
+            if (elide) {
+              atIdent = null;
+            } else if (atIdent === '@media') {
+              headerArray = headerArray.filter(
+                function (mediaType) {
+                  return cssMediaTypeWhitelist[mediaType] == allowed;
+                });
+              if (headerArray.length) {
+                safeCss.push(atIdent, headerArray.join(','), '{');
+              } else {
+                atIdent = null;
+              }
+            } else {
+              if (atIdent === '@import') {
+                if ('undefined' !== typeof console) {
+                  // TODO: Use a logger instead.
+ console.log('@import ' + headerArray.join(' ') + ' elided');
+                }
+              }
+              atIdent = null;  // Elide the block.
+            }
+            elide = !atIdent;
+            blockStack.push(atIdent);
+          },
+          endAtrule: function () {
+            var atIdent = blockStack.pop();
+            if (!elide) {
+              safeCss.push(';');
+            }
+            checkElide();
+          },
+          startBlock: function () {
+            // There are no bare blocks in CSS, so we do not change the
+            // block stack here, but instead in the events that bracket
+            // blocks.
+            if (!elide) {
+              safeCss.push('{');
+            }
+          },
+          endBlock: function () {
+            if (!elide) {
+              safeCss.push('}');
+              elide = true;  // skip any semicolon from endAtRule.
+            }
+          },
+          startRuleset: function (selectorArray) {
+            var historySensitiveSelectors = void 0;
+            var removeHistoryInsensitiveSelectors = false;
+            if (!elide) {
+              var selectors = sanitizeCssSelectors(selectorArray);
+              var historyInsensitiveSelectors = selectors[0];
+              historySensitiveSelectors = selectors[1];
+              if (!historyInsensitiveSelectors.length
+                  && !historySensitiveSelectors.length) {
+                elide = true;
+              } else {
+                var selector = historyInsensitiveSelectors.join(', ');
+                if (!selector) {
+                  // If we have only history sensitive selectors,
+ // use an impossible rule so that we can capture the content
+                  // for later processing by
+                  // history insenstive content for use below.
+                  selector = 'head > html';
+                  removeHistoryInsensitiveSelectors = true;
+                }
+                safeCss.push(selector);
+              }
+            }
+            blockStack.push(
+                elide
+                ? null
+                // Sometimes a single list of selectors is split in two,
+                //   div, a:visited
+                // because we want to allow some properties for DIV that
+                // we don't want to allow for A:VISITED to avoid leaking
+                // user history.
+                // Store the history sensitive selectors and the position
+                // where the block starts so we can later create a copy
+                // of the permissive tokens, and filter it to handle the
+                // history sensitive case.
+                : {
+                    historySensitiveSelectors: historySensitiveSelectors,
+                    endOfSelecctors: safeCss.length,
+                    removeHistoryInsensitiveSelectors:
+                       removeHistoryInsensitiveSelectors
+                  });
+          },
+          endRuleset: function () {
+            var rules = blockStack.pop();
+            var propertiesEnd = safeCss.length;
+            if (!elide && rules) {
+              var extraSelectors = rules.historySensitiveSelectors;
+              if (extraSelectors.length) {
+ var propertyGroupTokens = safeCss.slice(rules.endOfSelectors);
+                safeCss.push(extraSelectors.join(', '));
+                safeCss.push.apply(
+ safeCss, sanitizeHistorySensitive(propertyGroupTokens));
+              }
+            }
+            if (rules && rules.removeHistoryInsensitiveSelectors) {
+              safeCss.splice(rules.endOfSelectors - 1, propertiesEnd);
+            }
+            checkElide();
+          },
+          declaration: function (property, valueArray) {
+            if (!elide) {
+              var schema = cssSchema[property];
+              var sanitizeUri = void 0;  // TODO
+              if (schema) {
+                sanitizeCssProperty(schema, valueArray, sanitizeUri);
+                if (valueArray.length) {
+                  safeCss.push(property, ':', valueArray.join(' '), ';');
+                }
+              }
+            }
+          }
+        });
+    function checkElide() {
+      elide = blockStack.length === 0
+          || blockStack[blockStack.length-1] !== null;
+    }
+    return safeCss.join('');
+  };
+})();
=======================================
--- /trunk/tests/com/google/caja/plugin/CssRewriterTest.java Thu Jan 19 09:04:11 2012 +++ /trunk/tests/com/google/caja/plugin/CssRewriterTest.java Thu Mar 15 12:38:53 2012
@@ -18,25 +18,28 @@
 import com.google.caja.lang.html.HtmlSchema;
 import com.google.caja.lexer.ExternalReference;
 import com.google.caja.lexer.FilePosition;
+import com.google.caja.lexer.InputSource;
 import com.google.caja.lexer.ParseException;
 import com.google.caja.parser.AncestorChain;
+import com.google.caja.parser.MutableParseTreeNode;
 import com.google.caja.parser.ParseTreeNode;
 import com.google.caja.parser.Visitor;
 import com.google.caja.parser.css.CssTree;
-import com.google.caja.parser.html.ElKey;
-import com.google.caja.parser.html.Namespaces;
 import com.google.caja.parser.js.ArrayConstructor;
+import com.google.caja.parser.js.Expression;
+import com.google.caja.parser.js.ObjProperty;
+import com.google.caja.parser.js.ObjectConstructor;
+import com.google.caja.parser.js.Operation;
+import com.google.caja.parser.js.Operator;
 import com.google.caja.parser.js.StringLiteral;
-import com.google.caja.parser.quasiliteral.QuasiBuilder;
-import com.google.caja.reporting.MessageLevel;
+import com.google.caja.parser.js.ValueProperty;
+import com.google.caja.reporting.Message;
 import com.google.caja.reporting.MessagePart;
 import com.google.caja.util.CajaTestCase;
-import com.google.caja.util.Executor;
-import com.google.caja.util.Join;
+import com.google.caja.util.Function;
 import com.google.caja.util.Lists;
 import com.google.caja.util.MoreAsserts;
 import com.google.caja.util.Name;
-import com.google.caja.util.RhinoTestBed;
 import com.google.caja.util.Sets;

 import java.net.URI;
@@ -47,206 +50,128 @@
 import java.util.Map;
 import java.util.Set;

+import junit.framework.AssertionFailedError;
+
 /**
  *
  * @author [email protected]
  */
 public class CssRewriterTest extends CajaTestCase {
-  public final void testUnknownTagsRemoved() throws Exception {
-    runTest("bogus { display: none }", "");
-    runTest("a, bogus, i { display: none }",
-            "a, i {\n  display: none\n}");
-  }
-
-  public final void testBadTagsRemoved() throws Exception {
-    runTest("script { display: none }", "");
-    assertMessage(
-        true, PluginMessageType.UNSAFE_TAG, MessageLevel.ERROR,
-        ElKey.forElement(Namespaces.HTML_DEFAULT, "script"));
-    assertNoErrors();
-    runTest("strike, script, strong { display: none }",
-            "strike, strong {\n  display: none\n}");  // See error
-    assertMessage(
-        true, PluginMessageType.UNSAFE_TAG, MessageLevel.ERROR,
-        ElKey.forElement(Namespaces.HTML_DEFAULT, "script"));
-    assertNoErrors();
-  }
-
-  public final void testBadAttribsRemoved() throws Exception {
-    runTest("div[zwop] { color: blue }", "");
-  }
-
-  public final void testInvalidPropertiesRemoved() throws Exception {
-    // visibility takes "hidden", not "none"
-    runTest("a { visibility: none }", "");
-    runTest("a { visibility: hidden; }", "a {\n  visibility: hidden\n}");
-    // no such property
-    runTest("a { bogus: bogus }", "");
-    // make sure it doesn't interfere with others
-    runTest("a { visibility: none; font-weight: bold }",
-            "a {\n  font-weight: bold\n}");
-    runTest("a { font-weight: bold; visibility: none }",
-            "a {\n  font-weight: bold\n}");
-    runTest("a { bogus: bogus; font-weight: bold }",
-            "a {\n  font-weight: bold\n}");
-    runTest("a { font-weight: bold; bogus: bogus }",
-            "a {\n  font-weight: bold\n}");
-  }
-
-  public final void testContentRemoved() throws Exception {
- runTest("a { color: blue; content: 'booyah'; text-decoration: underline; }",
-            "a {\n  color: blue;\n  text-decoration: underline\n}");
-  }
-
-  public final void testAttrRemoved() throws Exception {
-    runTest("a:attr(href) { color: blue }", "");
-    runTest("a:attr(href) { color: blue } b { font-weight: bolder }",
-            "b {\n  font-weight: bolder\n}");
-  }
-
-  public final void testFontNamesQuoted() throws Exception {
- runTest("a { font:12pt Times New Roman, Times,\"Times Old Roman\",serif }",
-            "a {\n  font: 12pt 'Times New Roman', 'Times',"
-            + " 'Times Old Roman', serif\n}");
-    runTest("a { font:bold 12pt Arial Black }",
-            "a {\n  font: bold 12pt 'Arial Black'\n}");
-  }
-
-  public final void testNamespacing() throws Exception {
-    runTest("a.foo { color:blue }", "a.foo {\n  color: blue\n}");
-    runTest("#foo { color: blue }", "#foo {\n  color: blue\n}");
-    runTest("body.ie6 p { color: blue }",
-            "body.ie6 p {\n  color: blue\n}");
-    runTest("body { margin: 0; }", "");  // Not allowed
-    runTest("body.ie6 { margin: 0; }", "");  // Not allowed
-    runTest("* html p { margin: 0; }", "* html p {\n  margin: 0\n}");
-    runTest("* html { margin: 0; }", "");  // Not allowed
-    runTest("* html > * > p { margin: 0; }", "");  // Not allowed
-    runTest("#foo > #bar { color: blue }",
-            "#foo > #bar {\n  color: blue\n}");
-    runTest("#foo .bar { color: blue }",
-            "#foo .bar {\n  color: blue\n}");
-  }
-
-  public final void testUnsafeIdentifiers() throws Exception {
-    runTest("a.foo, b#c\\2c d, .e { color:blue }",  // "\\2c " -> ","
-            "a.foo, .e {\n  color: blue\n}");
-    runTest("a.foo, .b_c {color: blue}",
-            "a.foo, .b_c {\n  color: blue\n}");
-    runTest("a.foo, ._c {color: blue}",
-            "a.foo {\n  color: blue\n}");
-    runTest("a._c {_color: blue; margin:0;}", "");
-    runTest("a#_c {_color: blue; margin:0;}", "");
-    runTest(".c__ {_color: blue; margin:0;}", "");
-    runTest("#c__ {_color: blue; margin:0;}", "");
-  }
-
-  public final void testPseudosWhitelisted() throws Exception {
-    runTest("a:link, a:badness { color:blue }",
-            "a:link {\n  color: blue\n}");
-    mq.getMessages().clear();
-    runTest("a:visited { color:blue }",
-            "a:visited {\n  color: blue\n}");
-    assertNoErrors();
-
-    // Properties that are on DOMita's HISTORY_INSENSITIVE_STYLE_WHITELIST
-    // should not be allowed in any rule that correlates with the :visited
-    // pseudo selector.
-    // TODO: How is this a whitelist then?
-    mq.getMessages().clear();
-    runTest(
-        "a:visited { color:blue; float:left; _float:left; *float:left }",
-        "a:visited {\n  color: blue\n}");
-    assertMessage(
-        PluginMessageType.DISALLOWED_CSS_PROPERTY_IN_SELECTOR,
-        MessageLevel.ERROR,
-        FilePosition.instance(is, 1, 25, 25, 5), Name.css("float"),
-        FilePosition.instance(is, 1, 1, 1, 9));
-    assertMessage(
-        PluginMessageType.DISALLOWED_CSS_PROPERTY_IN_SELECTOR,
-        MessageLevel.ERROR,
-        FilePosition.instance(is, 1, 37, 37, 6), Name.css("_float"),
-        FilePosition.instance(is, 1, 1, 1, 9));
-    assertMessage(
-        PluginMessageType.DISALLOWED_CSS_PROPERTY_IN_SELECTOR,
-        MessageLevel.ERROR,
-        FilePosition.instance(is, 1, 51, 51, 5), Name.css("float"),
-        FilePosition.instance(is, 1, 1, 1, 9));
-
-    runTest(
-        "a:visited { COLOR:blue; FLOAT:left; _FLOAT:left; *FLOAT:left }",
-        "a:visited {\n  color: blue\n}");
-
-    runTest(
-        "*:visited { color: blue; }",
-        "a:visited {\n  color: blue\n}");
-    runTest(
-        "#foo:visited { color: blue; }",
-        "a#foo:visited {\n  color: blue\n}");
-    runTest(
-        ".foo:link { color: blue; }",
-        "a.foo:link {\n  color: blue\n}");
-
-    runTest(
-        ""
-        + "#foo:visited, div, .bar:link, p {\n"
-        + "  padding: 1px;\n"
-        + "  color: blue;\n"
-        + "}",
-        ""
-        + "a#foo:visited, a.bar:link {\n"
-        + "  color: blue\n"
-        + "}\n"
-        + "div, p {\n"
-        + "  padding: 1px;\n"
-        + "  color: blue\n"
-        + "}");
-
-    runTest(
-        ""
-        + "a#foo-bank {"
-        + "  background: 'http://whitelisted-host.com/?bank=X&u=Al';"
-        + "  color: purple"
-        + "}",
-        ""
-        + "a#foo-bank {\n"
- + " background: url('http://whitelisted-host.com/?bank=X&u=Al');\n"
-        + "  color: purple\n"
-        + "}");
-    // Differs from the previous only in that it has the :visited pseudo
- // selector which means we can't allow it to cause a network fetch because
-    // that could leak user history state.
-    mq.getMessages().clear();
-    runTest(
-        ""
-        + "a#foo-bank:visited {"
-        + "  background-image: 'http://whitelisted-host.com/?bank=X&u=Al';"
-        + "  color: purple"
-        + "}",
-        ""
-        + "a#foo-bank:visited {\n"
-        + "  color: purple\n"
-        + "}");
-  }
-
-  public final void testNoBadUrls() throws Exception {
-    // ok
-    runTest("#foo { background: url(/bar.png) }",
-            "#foo {\n  background: url('/foo/bar.png')\n}");
-    runTest("#foo { background: url('/bar.png') }",
-            "#foo {\n  background: url('/foo/bar.png')\n}");
-    runTest("#foo { background: '/bar.png' }",
-            "#foo {\n  background: url('/foo/bar.png')\n}");
-    runTest(
-        "#foo { background: 'http://whitelisted-host.com/blinky.gif' }",
- "#foo {\n background: url('http://whitelisted-host.com/blinky.gif')\n}"
-        );
-
-    // disallowed
-    runTest("#foo { background: url('http://cnn.com/bar.png') }",
-            "");
-    runTest("#foo { background: 'http://cnn.com/bar.png' }",
-            "");
+
+  public final void testCssRewriterEquivalence() throws Exception {
+    Expression tests = jsExpr(fromResource("css-stylesheet-tests.js"));
+    // tests is a JSONP style JavaScript expression.
+    // Normalize "foo" + "bar" -> "foo bar"
+    tests.acceptPostOrder(new Visitor() {
+      @Override
+      public boolean visit(AncestorChain<?> chain) {
+        if (Operation.is(chain.node, Operator.ADDITION)) {
+          Operation op = chain.cast(Operation.class).node;
+          Expression left = op.children().get(0);
+          Expression right = op.children().get(1);
+ if (left instanceof StringLiteral && right instanceof StringLiteral) {
+            StringLiteral concatenation = StringLiteral.valueOf(
+                FilePosition.span(
+                    left.getFilePosition(), right.getFilePosition()),
+                ((StringLiteral) left).getUnquotedValue()
+                + ((StringLiteral) right).getUnquotedValue());
+ chain.parent.cast(MutableParseTreeNode.class).node.replaceChild(
+                concatenation, op);
+          }
+        }
+        return true;
+      }
+    }, null);
+
+    AssertionFailedError failure = null;
+
+    // InputSource for file positions in error message goldens.
+    is = new InputSource(new URI("test://example.org/test"));
+
+    // Extract the JSON style-object from the call.
+    assertTrue(render(tests), Operation.is(tests, Operator.FUNCTION_CALL));
+    Operation call = (Operation) tests;
+    assertEquals(2, call.children().size());
+    Expression testArray = call.children().get(1);
+    // testArray is an array like
+    // [{ test_name: ..., tests: [] }]
+    for (Expression test : ((ArrayConstructor) testArray).children()) {
+      ObjectConstructor obj = (ObjectConstructor) test;
+      String name = (String)
+           ((ValueProperty) obj.propertyWithName("test_name"))
+           .getValueExpr().getValue();
+ ValueProperty testcases = (ValueProperty) obj.propertyWithName("tests");
+      // testcases is an object like
+      // [{ cssText: ..., golden: ..., messages: ... }]
+      for (Expression testCase
+           : ((ArrayConstructor) testcases.getValueExpr()).children()) {
+        ObjectConstructor testCaseObj = (ObjectConstructor) testCase;
+        String cssText = null;
+        String golden = null;
+        ArrayConstructor messages = null;
+        for (ObjProperty oprop : testCaseObj.children()) {
+          ValueProperty prop = (ValueProperty) oprop;
+          String pname = prop.getPropertyName();
+          try {
+            if ("cssText".equals(pname)) {
+              cssText = ((StringLiteral) prop.getValueExpr())
+                  .getUnquotedValue();
+            } else if ("golden".equals(pname)) {
+              golden = ((StringLiteral) prop.getValueExpr())
+                  .getUnquotedValue();
+            } else if ("messages".equals(pname)) {
+              messages = (ArrayConstructor) prop.getValueExpr();
+            } else {
+              fail(
+                  "Unrecognized testcase property " + pname + " in "
+ + render(testCase) + " at " + testCase.getFilePosition());
+            }
+          } catch (RuntimeException ex) {
+            System.err.println(
+                "Type mismatch in " + name
+                + " at " + testCase.getFilePosition());
+            throw ex;
+          }
+        }
+
+        mq.getMessages().clear();
+        try {
+          runTest(cssText, golden);
+          if (messages != null) {
+            for (Expression message : messages.children()) {
+              ObjectConstructor messageObj = (ObjectConstructor) message;
+              String type = ((StringLiteral)
+                  ((ValueProperty) messageObj.propertyWithName("type"))
+                  .getValueExpr())
+                  .getUnquotedValue();
+              String level = ((StringLiteral)
+                  ((ValueProperty) messageObj.propertyWithName("level"))
+                  .getValueExpr())
+                  .getUnquotedValue();
+              List<String> args = Lists.newArrayList();
+              ArrayConstructor argsArray = (ArrayConstructor)
+                  ((ValueProperty) messageObj.propertyWithName("args"))
+                  .getValueExpr();
+              for (Expression argExpr : argsArray.children()) {
+                args.add(((StringLiteral) argExpr).getUnquotedValue());
+              }
+              consumeMessage(message.getFilePosition(), type, level, args);
+            }
+            assertNoErrors();
+          }
+        } catch (Exception ex) {
+          System.err.println("Test " + name + "\n" + render(testCase));
+          throw ex;
+        } catch (AssertionFailedError ex) {
+          System.err.println("Test " + name + "\n" + render(testCase));
+          ex.printStackTrace();
+          if (failure == null) {
+            failure = ex;
+          }
+        }
+      }
+    }
+    if (failure != null) { throw failure; }
   }

   public final void testSubstitutions() throws Exception {
@@ -261,61 +186,8 @@
             "#foo {\n  left: ${x * 4}px;\n  top: ${y * 4}px\n}",
             true);
   }
-
-  /**
-   * "*" selectors should rewrite properly.
- * <a href="http://code.google.com/p/google-caja/issues/detail?id=57";>bug</a>
-   */
-  public final void testWildcardSelectors() throws Exception {
-    runTest("div * { margin: 0; }", "div * {\n  margin: 0\n}", false);
-  }
-
-  public final void testUnitlessLengths() throws Exception {
-    runTest("div { padding: 10 0 5.0 4 }",
-            "div {\n  padding: 10px 0 5.0px 4px\n}", false);
-    runTest("div { margin: -5 5; z-index: 2 }",
-            "div {\n  margin: -5px 5px;\n  z-index: 2\n}", false);
-  }
-
-  public final void testUserAgentHacks() throws Exception {
-    runTest(
-        ""
-        + "p {\n"
-        + "  color: blue;\n"
-        + "  *color: red;\n"
-        + "  background-color: green;\n"
-        + "  *background-color: yelow;\n"  // misspelled
-        + "  font-weight: bold\n"
-        + "}",
-        ""
-        + "p {\n"
-        + "  color: blue;\n"
-        + "  *color: red;\n"  // Good user agent hack
-        + "  background-color: green;\n"
-        // Bad user-agent hack removed.
-        + "  font-weight: bold\n"
-        + "}"
-        );
-    assertMessage(PluginMessageType.MALFORMED_CSS_PROPERTY_VALUE,
-                  MessageLevel.WARNING,
-                  Name.css("background-color"),
-                  MessagePart.Factory.valueOf("==>yelow<=="));
-    runTest("a.c {_color: blue; margin:0;}",
-            "a.c {\n  _color: blue;\n  margin: 0\n}");
-    assertNoErrors();
-  }

   public final void testNonStandardColors() throws Exception {
-    runTest("a.c { color: LightSlateGray; background: ivory; }",
-            "a.c {\n  color: #789;\n  background: #fffff0\n}");
-    assertMessage(PluginMessageType.NON_STANDARD_COLOR,
-                  MessageLevel.LINT, Name.css("lightslategray"),
-                  MessagePart.Factory.valueOf("#789"));
-    assertMessage(PluginMessageType.NON_STANDARD_COLOR,
-                  MessageLevel.LINT, Name.css("ivory"),
-                  MessagePart.Factory.valueOf("#fffff0"));
-    assertNoErrors();
-
     FilePosition u = FilePosition.UNKNOWN;
     assertNull(CssRewriter.colorHash(u, Name.css("invisible")));
     // Can get color hashes even for standard colors.
@@ -335,19 +207,6 @@
     assertEquals(
"#112220", CssRewriter.colorHash(u, 0x112233 ^ 0x000013).getValue());
   }
-
-  public final void testFixedPositioning() throws Exception {
-    runTest("#foo { position: absolute; left: 0px; top: 0px }",
-            "#foo {\n  position: absolute;\n  left: 0px;\n  top: 0px\n}");
-    assertNoErrors();
-    runTest("#foo { position: fixed; left: 0px; top: 0px }",
-            "#foo {\n  left: 0px;\n  top: 0px\n}");
- // TODO(mikesamuel): fix message. "fixed" is well-formed but disallowed.
-    assertMessage(true, PluginMessageType.MALFORMED_CSS_PROPERTY_VALUE,
-                  MessageLevel.WARNING, Name.css("position"),
-                  MessagePart.Factory.valueOf("==>fixed<=="));
-    assertNoErrors();
-  }

public final void testUrisCalledWithProperPropertyPart() throws Exception {
     // The CssRewriter needs to rewrite URIs.
@@ -489,86 +348,6 @@
     }

     assertEquals(msg, golden, render(t));
-
- // Check that the server side rewriter is consistent with the client side
-    // rewriter.
-    // TODO: rewrite rules as well.
-    if (!allowSubstitutions) {
-      ArrayConstructor[] propArrays = new ArrayConstructor[2];
-      int i = 0;
-      for (CssTree tree : new CssTree[] { css(fromString(css)), t }) {
-        final List<StringLiteral> namesAndValues = Lists.newArrayList();
-        tree.acceptPreOrder(new Visitor() {
-          @Override
-          public boolean visit(AncestorChain<?> ac) {
-            String namePrefix;
-            CssTree.PropertyDeclaration d;
-            if (ac.node instanceof CssTree.UserAgentHack) {
-              namePrefix = "*";
- d = ac.cast(CssTree.UserAgentHack.class).node.getDeclaration();
-            } else if (ac.node instanceof CssTree.PropertyDeclaration) {
-              namePrefix = "";
-              d = ac.cast(CssTree.PropertyDeclaration.class).node;
-            } else {
-              return true;
-            }
-            namesAndValues.add(StringLiteral.valueOf(
-                FilePosition.UNKNOWN,
-                namePrefix
-                + d.getProperty().getPropertyName().getCanonicalForm()));
-            namesAndValues.add(StringLiteral.valueOf(
-                FilePosition.UNKNOWN, render(d.getExpr())));
-            return false;
-          }
-        }, null);
-        propArrays[i++] = new ArrayConstructor(
-            FilePosition.UNKNOWN, namesAndValues);
-      }
-      ArrayConstructor input = propArrays[0];
-      ArrayConstructor want = propArrays[1];
-      String testJs = render(QuasiBuilder.substV(
-          Join.join("\n",
-              "(function () {",
-              "  var input = @input;",
-              "  var want = @want;",
-              "  var urlRewriter = function (url) { return null; };",
-              "  var actual = {}, golden = {};",
-              "  for (var i = 0; i < input.length; i += 2) {",
-              "    var tokens = lexCss(input[i+1]), name = input[i];",
-              "    sanitizeCssProperty(",
-              // Handle user agent hacks and undefined properties.
-              "        cssSchema[name.replace(/^[_*]/, '')] || {},",
-              "        tokens, urlRewriter);",
-              "    golden[name] = '';",
-              "    actual[name] = tokens.join(' ');",
-              "  }",
-              "  for (var i = 0; i < want.length; i += 2) {",
-              "    golden[want[i]] = want[i + 1];",
-              "  }",
-              "  golden = JSON.stringify(golden);",
-              "  actual = JSON.stringify(actual);",
-              "  if (golden !== actual) {",
-              "    throw new Error(golden + '\\n\\t!=\\n' + actual);",
-              "  }",
-              "})();"),
-          "input", input,
-          "want", want));
-      try {
-        RhinoTestBed.runJs(
-            new Executor.Input(getClass(), "css-defs.js"),
-            new Executor.Input(getClass(), "csslexer.js"),
-            new Executor.Input(getClass(), "sanitizecss.js"),
-            new Executor.Input(testJs, getName()));
-      } catch (RuntimeException ex) {
-        if (ex.getMessage().contains("}\n\t!=\n{")) {
-          // JavaScript inconsistencies are just advisory for now.
-          // TODO: start enforcing these.
- System.err.println("WARNING:" + getName() + ":" + ex.getMessage());
-        } else {
-          throw ex;
-        }
-      }
-    }
   }

   private void assertCallsUriRewriterWithPropertyPart(
@@ -600,4 +379,39 @@
         Arrays.asList(expectedParts),
         Lists.newArrayList(propertyParts));
   }
-}
+
+  private void consumeMessage(
+      FilePosition pos, final String type, final String level,
+      final List<String> parts) {
+    try {
+      assertMessage(
+          true,
+          new Function<Message, Integer>() {
+            @Override
+            public Integer apply(Message msg) {
+              int score = 0;
+              if (msg.getMessageType().name().equals(type)) { ++score; }
+              if (msg.getMessageLevel().name().equals(level)) { ++score; }
+              score -= partsMissing(msg, parts);
+              return (score == 2) ? Integer.MAX_VALUE : score;
+            }
+          }, "type=" + type + ", level=" + level);
+    } catch (AssertionFailedError err) {
+      System.err.println("Message specified at " + pos + " was not found");
+      throw err;
+    }
+  }
+
+ private static int partsMissing(Message msg, List<? extends String> parts) {
+    int missing = 0;
+    outerLoop:
+    for (String expectedPart : parts) {
+      for (MessagePart candidate : msg.getMessageParts()) {
+        String candidatePart = candidate.toString();
+        if (candidatePart.equals(expectedPart)) { continue outerLoop; }
+      }
+      ++missing;
+    }
+    return missing;
+  }
+}
=======================================
--- /trunk/tests/com/google/caja/util/CajaTestCase.java Fri Mar 2 15:59:21 2012 +++ /trunk/tests/com/google/caja/util/CajaTestCase.java Thu Mar 15 12:38:53 2012
@@ -390,16 +390,29 @@
   }

   protected void assertMessage(
-      boolean consume, MessageTypeInt type, MessageLevel level,
-      MessagePart... expectedParts) {
+      boolean consume, final MessageTypeInt type, final MessageLevel level,
+      final MessagePart... expectedParts) {
+    assertMessage(
+        consume,
+        new Function<Message, Integer>() {
+          public Integer apply(Message msg) {
+            int score = 0;
+            if (msg.getMessageType() == type) { ++score; }
+            if (msg.getMessageLevel() == level) { ++score; }
+            score -= partsMissing(msg, expectedParts);
+            return score == 2 ? Integer.MAX_VALUE : score;
+          }
+        },
+        "type " + type + " and level " + level);
+  }
+
+  protected void assertMessage(
+ boolean consume, Function<Message, Integer> scorer, String description) {
     Message closest = null;
     int closestScore = Integer.MIN_VALUE;
     for (Message msg : mq.getMessages()) {
-      int score = 0;
-      if (msg.getMessageType() == type) { ++score; }
-      if (msg.getMessageLevel() == level) { ++score; }
-      score -= partsMissing(msg, expectedParts);
-      if (score == 2) {
+      final int score = scorer.apply(msg);
+      if (score == Integer.MAX_VALUE) {
         if (consume) {
           mq.getMessages().remove(msg);
         }
@@ -411,7 +424,7 @@
       }
     }
     if (closest == null) {
-      fail("No message found of type " + type + " and level " + level);
+      fail("No message found like " + description);
     } else {
fail("Failed to find message. Closest match was " + closest.format(mc)
            + " with parts " + closest.getMessageParts());

Reply via email to