Repository: rwjblue/ember-template-lint
Pull request #334 (2018-01-11 15:37:00)

fix(lint-attribute-indentation)
#332 


Patch:
diff --git a/lib/get-config.js b/lib/get-config.js
index 4c428b7..8165bf5 100644
--- a/lib/get-config.js
+++ b/lib/get-config.js
@@ -31,25 +31,49 @@ function resolveConfigPath(configPath, pluginPath) {
 
 }
 
-function ensureRootProperties(config, source, options) {
-  let logger = options.console || console;
+function forEachPluginConfiguration(plugins, callback) {
+  if (!plugins) {
+    return;
+  }
 
-  config.rules = assign({}, source.rules || {});
-  config.pending = (source.pending || []).slice();
-  config.ignore = (source.ignore || []).slice();
-  config.plugins = (source.plugins || []).slice();
+  for (let pluginName in plugins) {
+
+    let pluginConfigurations = plugins[pluginName].configurations;
+    if (!pluginConfigurations) {
+      continue;
+    }
+
+    for (let configurationName in pluginConfigurations) {
+      let configuration = pluginConfigurations[configurationName];
+
+      callback(configuration, configurationName, pluginName);
+    }
+
+  }
+}
+
+function normalizeExtends(config, options) {
+  let logger = options.console || console;
 
   let extendedList = [];
-  if (source.extends) {
-    if (typeof source.extends === 'string') {
-      extendedList = [source.extends];
-    } else if (Array.isArray(source.extends)) {
-      extendedList = source.extends.slice();
+  if (config.extends) {
+    if (typeof config.extends === 'string') {
+      extendedList = [config.extends];
+    } else if (Array.isArray(config.extends)) {
+      extendedList = config.extends.slice();
     } else {
       logger.log(chalk.yellow('config.extends should be string or array '));
     }
   }
-  config.extends = extendedList;
+  return extendedList;
+}
+
+function ensureRootProperties(config, source) {
+  config.rules = assign({}, source.rules || {});
+  config.pending = (source.pending || []).slice();
+  config.ignore = (source.ignore || []).slice();
+  config.plugins = (source.plugins || []).slice();
+  config.extends = source.extends;
 }
 
 function migrateRulesFromRoot(config, source, options) {
@@ -76,54 +100,74 @@ function migrateRulesFromRoot(config, source, options) {
   }
 }
 
-function processPlugins(config, options) {
+function processPlugins(config, options, checkForCircularReference) {
 
   let logger = options.console || console;
 
   let configPlugins = config.plugins;
   let pluginsHash = {};
 
-  for (let i = 0; i < configPlugins.length; i++) {
+  if (configPlugins) {
 
-    let plugin = configPlugins[i];
-    let pluginName;
+    for (let i = 0; i < configPlugins.length; i++) {
+
+      let plugin = configPlugins[i];
+      let pluginName;
+
+      if (typeof plugin === 'string') {
+        pluginName = plugin;
+        plugin = resolveConfigPath(options.configPath, pluginName);
+      }
 
-    if (typeof plugin === 'string') {
-      pluginName = plugin;
-      plugin = resolveConfigPath(options.configPath, pluginName);
-    }
 
+      let errorMessage;
+      if (typeof plugin === 'object') {
 
-    let errorMessage;
-    if (typeof plugin === 'object') {
+        if (plugin.name) {
+          if (!checkForCircularReference || !checkForCircularReference[plugin.name]) {
+            pluginsHash[plugin.name] = plugin;
+          }
+        } else if (pluginName) {
+          errorMessage = `Plugin (${pluginName}) has not defined the plugin \`name\` property`;
+        } else {
+          errorMessage = 'Inline plugin object has not defined the plugin `name` property';
+        }
 
-      if (plugin.name) {
-        pluginsHash[plugin.name] = plugin;
       } else if (pluginName) {
-        errorMessage = `Plugin (${pluginName}) has not defined the plugin \`name\` property`;
+        errorMessage = `Plugin (${pluginName}) did not return a plain object`;
       } else {
-        errorMessage = 'Inline plugin object has not defined the plugin `name` property';
+        errorMessage = 'Inline plugin is not a plain object';
       }
 
-    } else if (pluginName) {
-      errorMessage = `Plugin (${pluginName}) did not return a plain object`;
-    } else {
-      errorMessage = 'Inline plugin is not a plain object';
-    }
+      if (errorMessage) {
+        logger.log(chalk.yellow(errorMessage));
+      }
 
-    if (errorMessage) {
-      logger.log(chalk.yellow(errorMessage));
     }
 
   }
 
+  forEachPluginConfiguration(pluginsHash, configuration => {
+
+    // process plugins recursively
+    assign(pluginsHash, processPlugins(configuration, options, pluginsHash));
+
+  });
+
   config.plugins = pluginsHash;
+
+  return pluginsHash;
 }
 
 function processLoadedRules(config) {
 
-  //load all the default rules in `ember-template-lint`
-  let loadedRules = assign({}, defaultRules);
+  let loadedRules;
+  if (config.loadedRules) {
+    loadedRules = config.loadedRules;
+  } else {
+    //load all the default rules in `ember-template-lint`
+    loadedRules = assign({}, defaultRules);
+  }
 
   //load plugin rules
   for (let name in config.plugins) {
@@ -135,31 +179,42 @@ function processLoadedRules(config) {
 
   }
 
+  forEachPluginConfiguration(config.plugins, configuration => {
+
+    // process plugins recursively
+    processLoadedRules({
+      plugins: configuration.plugins,
+      loadedRules
+    });
+
+  });
+
   config.loadedRules = loadedRules;
 
 }
 
 function processLoadedConfigurations(config) {
 
-  //load all the default configurations in `ember-template-lint`
-  let loadedConfigurations = assign({}, defaultConfigurations);
-
-  //load plugin configurations
-  for (let pluginName in config.plugins) {
-
-    let pluginConfigurations = config.plugins[pluginName].configurations;
-    if (pluginConfigurations) {
+  let loadedConfigurations;
+  if (config.loadedConfigurations) {
+    loadedConfigurations = config.loadedConfigurations;
+  } else {
+    //load all the default configurations in `ember-template-lint`
+    loadedConfigurations = assign({}, defaultConfigurations);
+  }
 
-      for (let configurationName in pluginConfigurations) {
+  forEachPluginConfiguration(config.plugins, (configuration, configurationName, pluginName) => {
 
+    let name = `${pluginName}:${configurationName}`;
+    loadedConfigurations[name] = configuration;
 
-        let name = `${pluginName}:${configurationName}`;
-        loadedConfigurations[name] = pluginConfigurations[configurationName];
+    // load plugins recursively
+    processLoadedConfigurations({
+      plugins: configuration.plugins,
+      loadedConfigurations
+    });
 
-      }
-    }
-
-  }
+  });
 
   config.loadedConfigurations = loadedConfigurations;
 
@@ -169,32 +224,48 @@ function processExtends(config, options) {
 
   let logger = options.console || console;
 
-  let extendedList = config.extends;
+  let extendedList = normalizeExtends(config, options);
 
-  for (let i = 0; i < extendedList.length; i++) {
+  if (extendedList) {
 
-    let extendName = extendedList[i];
+    for (let i = 0; i < extendedList.length; i++) {
 
-    let configuration = config.loadedConfigurations[extendName];
-    if (configuration) {
+      let extendName = extendedList[i];
 
-      if (configuration.rules) {
+      let configuration = config.loadedConfigurations[extendName];
+      if (configuration) {
 
-        config.rules = assign({}, configuration.rules, config.rules);
+        // ignore loops
+        if (!configuration.loadedConfigurations) {
 
-      } else {
+          configuration.loadedConfigurations = config.loadedConfigurations;
 
-        logger.log(chalk.yellow(`Missing rules for extends: ${extendName}`));
+          // continue chaining `extends` from plugins until done
+          processExtends(configuration, options);
 
-      }
+          delete configuration.loadedConfigurations;
 
-    } else {
+          if (configuration.rules) {
+
+            config.rules = assign({}, configuration.rules, config.rules);
+
+          } else {
 
-      logger.log(chalk.yellow(`Cannot find configuration for extends: ${extendName}`));
+            logger.log(chalk.yellow(`Missing rules for extends: ${extendName}`));
+
+          }
+
+        }
+
+      } else {
+
+        logger.log(chalk.yellow(`Cannot find configuration for extends: ${extendName}`));
+
+      }
 
+      delete config.extends;
     }
 
-    delete config.extends;
   }
 
 }
@@ -228,7 +299,7 @@ module.exports = function(options) {
     // don't mutate a `require`d object, you'll have a bad time
     config = {};
 
-    ensureRootProperties(config, source, options);
+    ensureRootProperties(config, source);
     migrateRulesFromRoot(config, source, options);
 
     processPlugins(config, options);
diff --git a/lib/rules/lint-attribute-indentation.js b/lib/rules/lint-attribute-indentation.js
index f89276f..bd4bf8e 100644
--- a/lib/rules/lint-attribute-indentation.js
+++ b/lib/rules/lint-attribute-indentation.js
@@ -234,16 +234,23 @@ module.exports = class AttributeSpacing extends Rule {
     if (type === 'positional') {
       paramType = 'positional param';
       namePath = 'original';
+      if (node.path.loc.source === '(synthetic)') {
+        expectedLineStart--;
+        if (node.type === 'BlockStatement') {
+          node.params[0].loc.start.column--;
+        }
+      }
     } else {
       paramType = 'attribute';
       namePath = 'key';
     }
+
     params.forEach((param) => {
       let actualStartLocation = param.loc.start;
       if (expectedLineStart !== actualStartLocation.line ||
         expectedColumnStart !== actualStartLocation.column) {
         let paramName = param[namePath];
-        let message = `Incorrect indentation of ${paramType} '${paramName}' beginning at L${actualStartLocation.line}:C${actualStartLocation.column}. Expected '${paramName}' to be indentation at an of ${expectedColumnStart} but was found at ${actualStartLocation.column}`;
+        let message = `Incorrect indentation of ${paramType} '${paramName}' beginning at L${actualStartLocation.line}:C${actualStartLocation.column}. Expected '${paramName}' to be at L${expectedLineStart}:C${expectedColumnStart} with an indentation of ${expectedColumnStart} but was found at ${actualStartLocation.column}`;
         this.log({
           message,
           line: actualStartLocation.line,
@@ -251,8 +258,15 @@ module.exports = class AttributeSpacing extends Rule {
           source: this.sourceForNode(node)
         });
       }
+
+      const type = param.value ? param.value.type : param.type;
+      if (type === 'SubExpression') {
+        //TODO check subexpressions
+        expectedLineStart = param.loc.end.line;
+      }
       expectedLineStart++;
     });
+    return expectedLineStart;
   }
 
   validateParamsAndHashPairs (node) {
@@ -268,22 +282,30 @@ module.exports = class AttributeSpacing extends Rule {
     let expectedColumnStart = node.loc.start.column + 2; //params should be after 2 positions from component start node
     let expectedLineStart = node.loc.start.line + 1;
 
-    this.iterateParams(node.params, 'positional', expectedLineStart, expectedColumnStart, node);
-    this.iterateParams(node.hash.pairs, 'attribute', expectedLineStart + node.params.length, expectedColumnStart, node);
+    expectedLineStart = this.iterateParams(node.params, 'positional', expectedLineStart, expectedColumnStart, node);
+    this.iterateParams(node.hash.pairs, 'attribute', expectedLineStart, expectedColumnStart, node);
   }
 
   validateCloseBrace(node) {
     /*
       Validates the close brace (`}}`) of the non-block form.
     */
-    let actualStartLocation = {
+    const actualStartLocation = {
       line: node.loc.end.line,
       column: node.loc.end.column - 2
     };
-    let expectedStartLocation = {
-      line: node.hash.loc.end.line + 1,
+
+    let expectedStartLine = node.hash.loc.end.line + 1;
+    const lastPositionalParam = node.params.pop();
+    if (lastPositionalParam && lastPositionalParam.loc.end.line > node.hash.loc.end.line) {
+      expectedStartLine = lastPositionalParam.loc.end.line + 1;
+    }
+
+    const expectedStartLocation = {
+      line: expectedStartLine,
       column: node.loc.start.column
     };
+
     let componentName = node.path.original;
     if (actualStartLocation.line !== expectedStartLocation.line ||
       actualStartLocation.column !== expectedStartLocation.column) {
diff --git a/lib/rules/lint-invalid-interactive.js b/lib/rules/lint-invalid-interactive.js
index 1680603..c938dfd 100644
--- a/lib/rules/lint-invalid-interactive.js
+++ b/lib/rules/lint-invalid-interactive.js
@@ -47,6 +47,11 @@ module.exports = class InvalidInteractive extends Rule {
             return;
           }
 
+          // Allow {{action "foo" on="reset"}} on form tags
+          if (this._element.tag === 'form' && isResetAction(node)) {
+            return;
+          }
+
           this.log({
             message: 'Interaction added to non-interactive element',
             line: node.loc && node.loc.start.line,
@@ -71,6 +76,11 @@ module.exports = class InvalidInteractive extends Rule {
             return;
           }
 
+          // Allow onreset={{action "foo"}} on form tags
+          if (this._element.tag === 'form' && node.name === 'onreset') {
+            return;
+          }
+
           this.log({
             message: 'Interaction added to non-interactive element',
             line: node.loc && node.loc.start.line,
@@ -86,7 +96,7 @@ module.exports = class InvalidInteractive extends Rule {
   }
 };
 
-function isSubmitAction(node) {
+function isActionName(node, name) {
   let hashPairs = node.hash.pairs || [];
   let i;
   let l = hashPairs.length;
@@ -94,10 +104,18 @@ function isSubmitAction(node) {
 
   for (i = 0; i < l; i++) {
     hashItem = hashPairs[i];
-    if (hashItem.key === 'on' && hashItem.value.value === 'submit') {
+    if (hashItem.key === 'on' && hashItem.value.value === name) {
       return true;
     }
   }
 
   return false;
 }
+
+function isSubmitAction(node) {
+  return isActionName(node, 'submit');
+}
+
+function isResetAction(node) {
+  return isActionName(node, 'reset');
+}


Test patch:
diff --git a/test/unit/get-config-test.js b/test/unit/get-config-test.js
index 1e0caec..12ec256 100644
--- a/test/unit/get-config-test.js
+++ b/test/unit/get-config-test.js
@@ -202,6 +202,128 @@ describe('get-config', function() {
     expect(actual.loadedRules['foo-bar']).to.equal('plugin-function-placeholder');
   });
 
+  it('can chain extends and load rules across chained plugins', function() {
+    let message;
+    let actual = getConfig({
+      console: { log(_message) {
+        message = _message;
+      }},
+
+      config: {
+        extends: 'plugin1:recommended',
+
+        plugins: [{
+          name: 'plugin1',
+
+          configurations: {
+            recommended: {
+              extends: 'plugin2:recommended',
+
+              plugins: [{
+                name: 'plugin2',
+
+                rules: {
+                  'foo-bar': true
+                },
+
+                configurations: {
+                  recommended: {
+                    extends: 'recommended',
+
+                    rules: {
+                      'foo-bar': true
+                    }
+                  }
+                }
+              }]
+            }
+          }
+        }]
+      }
+
+    });
+
+    expect(message).to.be.not.ok;
+    expect(actual.rules['foo-bar']).to.equal(true);
+    expect(actual.rules['block-indentation']).to.equal(2);
+  });
+
+  it('handles circular reference in config', function() {
+    let message;
+
+    let config = {
+      extends: 'plugin1:recommended',
+
+      rules: {
+        'foo-bar': true
+      },
+
+      plugins: [{
+        name: 'plugin1',
+
+        rules: {
+          'foo-bar': true
+        },
+
+        configurations: {}
+      }]
+    };
+
+    config.plugins[0].configurations.recommended = config;
+
+    let actual = getConfig({
+      console: { log(_message) {
+        message = _message;
+      }},
+
+      config
+
+    });
+
+    expect(message).to.be.not.ok;
+    expect(actual.rules['foo-bar']).to.equal(true);
+  });
+
+  it('handles circular reference in plugin', function() {
+    let message;
+
+    let plugin = {
+      name: 'plugin1',
+
+      rules: {
+        'foo-bar': true
+      },
+
+      configurations: {
+        recommended: {
+          extends: 'plugin1:recommended',
+
+          rules: {
+            'foo-bar': true
+          },
+        }
+      }
+    };
+
+    plugin.configurations.recommended.plugins = [plugin];
+
+    let actual = getConfig({
+      console: { log(_message) {
+        message = _message;
+      }},
+
+      config: {
+        extends: 'plugin1:recommended',
+
+        plugins: [plugin]
+      }
+
+    });
+
+    expect(message).to.be.not.ok;
+    expect(actual.rules['foo-bar']).to.equal(true);
+  });
+
   it('getting config is idempotent', function() {
     let firstMessage;
     let secondMessage;
diff --git a/test/unit/rules/lint-attribute-indentation-test.js b/test/unit/rules/lint-attribute-indentation-test.js
index a3259c1..e886dc4 100644
--- a/test/unit/rules/lint-attribute-indentation-test.js
+++ b/test/unit/rules/lint-attribute-indentation-test.js
@@ -27,6 +27,20 @@ generateRuleTests({
     '  firstName=firstName' + '\n' +
     '  lastName=lastName' + '\n' +
     '}}',
+    // positional params
+    '{{contact-details' + '\n' +
+    '  firstName' + '\n' +
+    '  lastName' + '\n' +
+    '}}',
+    // positional null
+    '{{contact-null' + '\n' +
+    '  null' + '\n' +
+    '}}',
+    // component
+    '{{component' + '\n' +
+    '  field' + '\n' +
+    '  action=(action reaction)' + '\n' +
+    '}}',
 
     //Multiple open-invocations with multiple lines.
     '{{contact-details' + '\n' +
@@ -37,6 +51,31 @@ generateRuleTests({
     '  firstName=firstName' + '\n' +
     '  lastName=lastName' + '\n' +
     '}}',
+    //with component from hash
+    '{{t.body' + '\n' +
+    '  canExpand=true' + '\n' +
+    '}}',
+    //with helper
+    '{{print-debug' + '\n' +
+    '  foo=(or' + '\n' +
+    '    foo' + '\n' +
+    '    bar' + '\n' +
+    '  )' + '\n' +
+    '  baz=baz' + '\n' +
+    '}}',
+    //with positional helper
+    '{{print-debug' + '\n' +
+    '  (hash' + '\n' +
+    '    foo="bar"' + '\n' +
+    '  )' + '\n' +
+    '  title="baz"' + '\n' +
+    '}}',
+    '{{yield' + '\n' +
+    '  (hash' + '\n' +
+    '    header=(component "x-very-long-name-header")' + '\n' +
+    '    body=(component "x-very-long-name-body")' + '\n' +
+    '  )' + '\n' +
+    '}}',
 
     //Block form within 80 characters
     //with positional params
@@ -47,10 +86,24 @@ generateRuleTests({
     '{{#contact-details firstName=firstName lastName=lastName}}' + '\n' +
     ' {{contactImage}}' + '\n' +
     '{{/contact-details}}',
+    //component from hash
+    '{{#t.body' + '\n' +
+    '  canExpand=true' + '\n' +
+    '  multiRowExpansion=false' + '\n' +
+    '}}' + '\n' +
+    '  {{foo}}' + '\n' +
+    '{{/t.body}}',
     //with block params
     '{{#contact-details firstName=firstName lastName=lastName as |contact|}}' + '\n' +
     ' {{contact.fullName}}' + '\n' +
     '{{/contact-details}}',
+    //component from positional
+    '{{#t.body' + '\n' +
+    '  canExpand=(helper help)' + '\n' +
+    '  multiRowExpansion=false' + '\n' +
+    'as |body|}}' + '\n' +
+    '  {{foo}}' + '\n' +
+    '{{/t.body}}',
 
     //Block form with open-invocation more than 80 characters
     {
@@ -84,13 +137,13 @@ generateRuleTests({
     results: [{
       'column': 18,
       'line': 1,
-      'message': 'Incorrect indentation of attribute \'firstName\' beginning at L1:C18. Expected \'firstName\' to be indentation at an of 2 but was found at 18',
+      'message': 'Incorrect indentation of attribute \'firstName\' beginning at L1:C18. Expected \'firstName\' to be at L2:C2 with an indentation of 2 but was found at 18',
       'moduleId': 'layout.hbs',
       'source': '{{contact-details firstName=firstName lastName=lastName}}'
     }, {
       'column': 38,
       'line': 1,
-      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L1:C38. Expected \'lastName\' to be indentation at an of 2 but was found at 38',
+      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L1:C38. Expected \'lastName\' to be at L3:C2 with an indentation of 2 but was found at 38',
       'moduleId': 'layout.hbs',
       'source': '{{contact-details firstName=firstName lastName=lastName}}'
     }, {
@@ -109,13 +162,13 @@ generateRuleTests({
     results: [{
       'column': 1,
       'line': 2,
-      'message': `Incorrect indentation of attribute 'firstName' beginning at L2:C1. Expected 'firstName' to be indentation at an of 2 but was found at 1`,
+      'message': `Incorrect indentation of attribute 'firstName' beginning at L2:C1. Expected 'firstName' to be at L2:C2 with an indentation of 2 but was found at 1`,
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
     }, {
       'column': 21,
       'line': 2,
-      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L2:C21. Expected \'lastName\' to be indentation at an of 2 but was found at 21',
+      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L2:C21. Expected \'lastName\' to be at L3:C2 with an indentation of 2 but was found at 21',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details\n firstName=firstName lastName=lastName as |contact|}}\n {{contact.fullName}}\n{{/contact-details}}'
     }, {
@@ -133,25 +186,25 @@ generateRuleTests({
     results: [{
       'column': 19,
       'line': 1,
-      'message': 'Incorrect indentation of attribute \'firstName\' beginning at L1:C19. Expected \'firstName\' to be indentation at an of 2 but was found at 19',
+      'message': 'Incorrect indentation of attribute \'firstName\' beginning at L1:C19. Expected \'firstName\' to be at L2:C2 with an indentation of 2 but was found at 19',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
     }, {
       'column': 39,
       'line': 1,
-      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L1:C39. Expected \'lastName\' to be indentation at an of 2 but was found at 39',
+      'message': 'Incorrect indentation of attribute \'lastName\' beginning at L1:C39. Expected \'lastName\' to be at L3:C2 with an indentation of 2 but was found at 39',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
     }, {
       'column': 57,
       'line': 1,
-      'message': 'Incorrect indentation of attribute \'age\' beginning at L1:C57. Expected \'age\' to be indentation at an of 2 but was found at 57',
+      'message': 'Incorrect indentation of attribute \'age\' beginning at L1:C57. Expected \'age\' to be at L4:C2 with an indentation of 2 but was found at 57',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
     }, {
       'column': 65,
       'line': 1,
-      'message': 'Incorrect indentation of attribute \'avatar\' beginning at L1:C65. Expected \'avatar\' to be indentation at an of 2 but was found at 65',
+      'message': 'Incorrect indentation of attribute \'avatar\' beginning at L1:C65. Expected \'avatar\' to be at L5:C2 with an indentation of 2 but was found at 65',
       'moduleId': 'layout.hbs',
       'source': '{{#contact-details firstName=firstName lastName=lastName age=age avatar=avatar as |contact|}}\n  {{fullName}}\n{{/contact-details}}'
     }, {
diff --git a/test/unit/rules/lint-invalid-interactive-test.js b/test/unit/rules/lint-invalid-interactive-test.js
index b2d5002..8e23d9e 100644
--- a/test/unit/rules/lint-invalid-interactive-test.js
+++ b/test/unit/rules/lint-invalid-interactive-test.js
@@ -13,6 +13,8 @@ generateRuleTests({
     '<li><button {{action "foo"}}></button></li>',
     '<form {{action "foo" on="submit"}}></form>',
     '<form onsubmit={{action "foo"}}></form>',
+    '<form {{action "foo" on="reset"}}></form>',
+    '<form onreset={{action "foo"}}></form>',
     {
       config: { additionalInteractiveTags: ['div'] },
       template: '<div {{action "foo"}}></div>'