diff --git a/core/lib/proto-preprocessor.js b/core/lib/proto-preprocessor.js index fbe561cc325d..b85a489373dc 100644 --- a/core/lib/proto-preprocessor.js +++ b/core/lib/proto-preprocessor.js @@ -83,28 +83,44 @@ function processForProto(lhr) { } /** - * Remove any found empty strings, as they are dropped after round-tripping anyway + * Execute `cb(obj, key)` on every object property where obj[key] is a string, recursively. * @param {any} obj + * @param {(obj: Record, key: string) => void} cb */ - function removeStrings(obj) { + function iterateStrings(obj, cb) { if (obj && typeof obj === 'object' && !Array.isArray(obj)) { Object.keys(obj).forEach(key => { - if (typeof obj[key] === 'string' && obj[key] === '') { - delete obj[key]; - } else if (typeof obj[key] === 'object' || Array.isArray(obj[key])) { - removeStrings(obj[key]); + if (typeof obj[key] === 'string') { + cb(obj, key); + } else { + iterateStrings(obj[key], cb); } }); } else if (Array.isArray(obj)) { obj.forEach(item => { if (typeof item === 'object' || Array.isArray(item)) { - removeStrings(item); + iterateStrings(item, cb); } }); } } - removeStrings(reportJson); + iterateStrings(reportJson, (obj, key) => { + const value = obj[key]; + + // Remove empty strings, as they are dropped after round-tripping anyway. + if (value === '') { + delete obj[key]; + return; + } + + // Sanitize lone surrogates. + // @ts-expect-error node 20 + if (String.prototype.isWellFormed && !value.isWellFormed()) { + // @ts-expect-error node 20 + obj[key] = value.toWellFormed(); + } + }); return reportJson; } diff --git a/core/test/lib/proto-preprocessor-test.js b/core/test/lib/proto-preprocessor-test.js index 6047c878d388..59c859cda868 100644 --- a/core/test/lib/proto-preprocessor-test.js +++ b/core/test/lib/proto-preprocessor-test.js @@ -164,6 +164,28 @@ Object { expect(output).toMatchObject(expectation); }); + + it('sanitizes lone surrogates', () => { + // Don't care about Node 18 here. We just need this to work in Chrome, and it does. + if (!String.prototype.toWellFormed) { + return; + } + + const input = { + 'audits': { + 'critical-request-chains': { + 'details': { + 'chains': { + '1': 'hello \uD83E', + }, + }, + }, + }, + }; + const output = processForProto(input); + + expect(output.audits['critical-request-chains'].details.chains[1]).toEqual('hello �'); + }); }); describeIfProtoExists('round trip JSON comparison subsets', () => {