Repository: GoogleChrome/lighthouse
Pull request #9580 (2019-08-20 01:19:48)

core(i18n): throw on excess placeholder replacement values
we have a check that all arguments in an ICU message have values passed into our i18n functions; this PR adds a check that all the values passed in have a place to be used in the ICU message. This is useful when strings change since we have no type checking that our localized strings match the values being passed in for replacement.

I was originally going to include this in #9570, but ran into a ton of errors in our unit tests. Turns out this is just because `LHError` always includes `errorCode` in the replacement values so you don't have to write `'NO_FCP'` three times just to get a `NO_FCP` error. I added a comment to remind the next person about `errorCode`.

This condition currently only happens in one place (the change in #9486 removing `itemCount`), but removing `itemCount` reveals a flaw in our system: if the ICU arguments in a string aren't provided in `values`, we throw an error (and if we don't throw one, `intl-messageformat` does). This is an issue when the other locale files have strings with different ICU arguments than the en-US one does (as is currently case for `third-party-summary.js | displayValue`).

This is a pretty fundamental issue so going to fix that before landing this :)

patrickhulce: some unit tests are still unhappy about spanish locale swap though
brendankenny: ok, with #9598 in, the tests for this should now all pass 🤞 

Patch:
diff --git a/lighthouse-core/audits/third-party-summary.js b/lighthouse-core/audits/third-party-summary.js
index 9aac6184e..53e7a4f3c 100644
--- a/lighthouse-core/audits/third-party-summary.js
+++ b/lighthouse-core/audits/third-party-summary.js
@@ -165,7 +165,6 @@ class ThirdPartySummary extends Audit {
     return {
       score: Number(summary.wastedMs <= PASS_THRESHOLD_IN_MS),
       displayValue: str_(UIStrings.displayValue, {
-        itemCount: results.length,
         timeInMs: summary.wastedMs,
       }),
       details: Audit.makeTableDetails(headings, results, summary),
diff --git a/lighthouse-core/lib/i18n/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index 2d176e7bb..12ce647ac 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -227,6 +227,17 @@ function _processParsedElements(icuMessage, argumentElements, values = {}) {
     }
   }
 
+  // Throw an error if a value is provided but has no placeholder in the message.
+  for (const valueId of Object.keys(values)) {
+    // errorCode is a special case always allowed to help ease of LHError use.
+    if (valueId === 'errorCode') continue;
+
+    if (!argumentElements.find(el => el.id === valueId)) {
+      throw new Error(`Provided value "${valueId}" does not match any placeholder in ` +
+        `ICU message "${icuMessage}"`);
+    }
+  }
+
   return values;
 }
 
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index f1809ff15..51153e4a9 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -100,7 +100,8 @@ class LighthouseError extends Error {
     super(errorDefinition.code);
     this.name = 'LHError';
     this.code = errorDefinition.code;
-    // Insert the i18n reference with errorCode and all additional ICU replacement properties.
+    // Add additional properties to be ICU replacements in the error string.
+    // `code` is always added as `errorCode` so callers don't need to specify the code multiple times.
     this.friendlyMessage = str_(errorDefinition.message, {errorCode: this.code, ...properties});
     this.lhrRuntimeError = !!errorDefinition.lhrRuntimeError;
     if (properties) Object.assign(this, properties);


Test patch:
diff --git a/lighthouse-core/test/lib/i18n/i18n-test.js b/lighthouse-core/test/lib/i18n/i18n-test.js
index 74b078ff1..5d45b0323 100644
--- a/lighthouse-core/test/lib/i18n/i18n-test.js
+++ b/lighthouse-core/test/lib/i18n/i18n-test.js
@@ -44,14 +44,19 @@ describe('i18n', () => {
 
   describe('#replaceIcuMessageInstanceIds', () => {
     it('replaces the references in the LHR', () => {
-      const templateID = 'lighthouse-core/test/lib/i18n/fake-file.js | daString';
-      const reference = templateID + ' # 0';
-      const lhr = {audits: {'fake-audit': {title: reference}}};
+      const fakeFile = path.join(__dirname, 'fake-file-number-2.js');
+      const UIStrings = {aString: 'different {x}!'};
+      const formatter = i18n.createMessageInstanceIdFn(fakeFile, UIStrings);
+
+      const title = formatter(UIStrings.aString, {x: 1});
+      const lhr = {audits: {'fake-audit': {title}}};
 
       const icuMessagePaths = i18n.replaceIcuMessageInstanceIds(lhr, 'en-US');
-      expect(lhr.audits['fake-audit'].title).toBe('use me!');
+      expect(lhr.audits['fake-audit'].title).toBe('different 1!');
+
+      const expectedPathId = 'lighthouse-core/test/lib/i18n/fake-file-number-2.js | aString';
       expect(icuMessagePaths).toEqual({
-        [templateID]: [{path: 'audits[fake-audit].title', values: {x: 1}}]});
+        [expectedPathId]: [{path: 'audits[fake-audit].title', values: {x: 1}}]});
     });
   });
 
@@ -191,5 +196,15 @@ describe('i18n', () => {
         // eslint-disable-next-line max-len
         .toThrow(`ICU Message "Hello {timeInMs, number, seconds} World" contains a numeric reference ("timeInMs") but provided value was not a number`);
     });
+
+    it('throws an error if a value is provided that has no placeholder in the message', () => {
+      const helloStr = str_(UIStrings.helloTimeInMsWorld, {
+        timeInMs: 55,
+        sirNotAppearingInThisString: 66,
+      });
+      expect(_ => i18n.getFormatted(helloStr, 'en-US'))
+        // eslint-disable-next-line max-len
+        .toThrow(`Provided value "sirNotAppearingInThisString" does not match any placeholder in ICU message "Hello {timeInMs, number, seconds} World"`);
+    });
   });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 9d17305a9..c8a59e6de 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -5605,7 +5605,6 @@
       "lighthouse-core/audits/third-party-summary.js | displayValue": [
         {
           "values": {
-            "itemCount": 1,
             "timeInMs": 22.918000000000006
           },
           "path": "audits[third-party-summary].displayValue"