diff --git a/__snapshots__/JSONUtils.test.ts.js b/__snapshots__/JSONUtils.test.ts.js
new file mode 100644
index 00000000..28bc1b7d
--- /dev/null
+++ b/__snapshots__/JSONUtils.test.ts.js
@@ -0,0 +1,46 @@
+exports['JSONUtils should format JSON as JS 1'] = `
+{
+ title: 'test',
+ test: true,
+ steps: [
+ {
+ type: 'click',
+ target: 'main',
+ selectors: [
+ 'aria/Test',
+ [
+ '.cls',
+ '.cls'
+ ]
+ ],
+ offsetX: 1,
+ offsetY: 1,
+ assertedEvents: [
+ {
+ type: 'navigation'
+ }
+ ]
+ },
+ {
+ type: 'click',
+ target: 'main',
+ selectors: [
+ 'aria/Test'
+ ],
+ offsetX: 1,
+ offsetY: 1,
+ assertedEvents: [
+ {
+ type: 'navigation'
+ }
+ ]
+ }
+ ],
+ otherTest: 1.234,
+ nullTest: null
+}
+`;
+
+exports['JSONUtils should properly escape {
+ const patternsToEscape =
+ /(\\|<(?:!--|\/?script))|(\p{Control})|(\p{Surrogate})/gu;
+ const patternsToEscapePlusSingleQuote =
+ /(\\|'|<(?:!--|\/?script))|(\p{Control})|(\p{Surrogate})/gu;
+ const escapePattern = (
+ match: string,
+ pattern: string,
+ controlChar: string,
+ loneSurrogate: string
+ ): string => {
+ if (controlChar) {
+ if (escapedReplacements.has(controlChar)) {
+ // @ts-ignore https://github.com/microsoft/TypeScript/issues/13086
+ return escapedReplacements.get(controlChar);
+ }
+ const twoDigitHex = toHexadecimal(controlChar.charCodeAt(0), 2);
+ return '\\x' + twoDigitHex;
+ }
+ if (loneSurrogate) {
+ const fourDigitHex = toHexadecimal(loneSurrogate.charCodeAt(0), 4);
+ return '\\u' + fourDigitHex;
+ }
+ if (pattern) {
+ return escapedReplacements.get(pattern) || '';
+ }
+ return match;
+ };
+
+ let escapedContent = '';
+ let quote = '';
+ if (!content.includes("'")) {
+ quote = "'";
+ escapedContent = content.replace(patternsToEscape, escapePattern);
+ } else if (!content.includes('"')) {
+ quote = '"';
+ escapedContent = content.replace(patternsToEscape, escapePattern);
+ } else if (!content.includes('`') && !content.includes('${')) {
+ quote = '`';
+ escapedContent = content.replace(patternsToEscape, escapePattern);
+ } else {
+ quote = "'";
+ escapedContent = content.replace(
+ patternsToEscapePlusSingleQuote,
+ escapePattern
+ );
+ }
+ return `${quote}${escapedContent}${quote}`;
+};
diff --git a/src/PuppeteerReplayStringifyExtension.ts b/src/PuppeteerReplayStringifyExtension.ts
index 01e1b4fa..1eb98a03 100644
--- a/src/PuppeteerReplayStringifyExtension.ts
+++ b/src/PuppeteerReplayStringifyExtension.ts
@@ -17,6 +17,7 @@
import type { LineWriter } from './LineWriter.js';
import type { Step } from './Schema.js';
import { StringifyExtension } from './StringifyExtension.js';
+import { formatJSONAsJS } from './JSONUtils.js';
/**
* Stringifies a user flow to a script that uses \@puppeteer/replay's own API.
@@ -52,7 +53,7 @@ export class PuppeteerReplayStringifyExtension extends StringifyExtension {
override async stringifyStep(out: LineWriter, step: Step) {
out.appendLine(
- `await runner.runStep(${JSON.stringify(step, null, out.getIndent())});`
+ `await runner.runStep(${formatJSONAsJS(step, out.getIndent())});`
);
}
}
diff --git a/src/PuppeteerStringifyExtension.ts b/src/PuppeteerStringifyExtension.ts
index 83a91dfc..48cc7d90 100644
--- a/src/PuppeteerStringifyExtension.ts
+++ b/src/PuppeteerStringifyExtension.ts
@@ -42,6 +42,7 @@ import {
mouseButtonMap,
typeableInputTypes,
} from './SchemaUtils.js';
+import { formatJSONAsJS } from './JSONUtils.js';
export class PuppeteerStringifyExtension extends StringifyExtension {
override async beforeAllSteps(out: LineWriter, flow: UserFlow) {
@@ -108,8 +109,9 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
out.appendLine('const targetPage = page;');
} else {
out.appendLine(
- `const target = await browser.waitForTarget(t => t.url() === ${formatAsJSLiteral(
- target
+ `const target = await browser.waitForTarget(t => t.url() === ${formatJSONAsJS(
+ target,
+ out.getIndent()
)}, { timeout });`
);
out.appendLine('const targetPage = await target.page();');
@@ -135,13 +137,15 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
#appendWaitForSelector(out: LineWriter, step: StepWithSelectors): void {
out.appendLine(
- `await scrollIntoViewIfNeeded(${JSON.stringify(step.selectors)}, ${
- step.frame ? 'frame' : 'targetPage'
- }, timeout);`
+ `await scrollIntoViewIfNeeded(${formatJSONAsJS(
+ step.selectors,
+ out.getIndent()
+ )}, ${step.frame ? 'frame' : 'targetPage'}, timeout);`
);
out.appendLine(
- `const element = await waitForSelectors(${JSON.stringify(
- step.selectors
+ `const element = await waitForSelectors(${formatJSONAsJS(
+ step.selectors,
+ out.getIndent()
)}, ${step.frame ? 'frame' : 'targetPage'}, { timeout, visible: true });`
);
}
@@ -186,19 +190,29 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
out.appendLine('const inputType = await element.evaluate(el => el.type);');
out.appendLine(`if (inputType === 'select-one') {`);
out.appendLine(
- ` await changeSelectElement(element, ${formatAsJSLiteral(step.value)})`
+ ` await changeSelectElement(element, ${formatJSONAsJS(
+ step.value,
+ out.getIndent()
+ )})`
);
out.appendLine(
- `} else if (${JSON.stringify(
- Array.from(typeableInputTypes)
+ `} else if (${formatJSONAsJS(
+ Array.from(typeableInputTypes),
+ out.getIndent()
)}.includes(inputType)) {`
);
out.appendLine(
- ` await typeIntoElement(element, ${formatAsJSLiteral(step.value)});`
+ ` await typeIntoElement(element, ${formatJSONAsJS(
+ step.value,
+ out.getIndent()
+ )});`
);
out.appendLine('} else {');
out.appendLine(
- ` await changeElementValue(element, ${formatAsJSLiteral(step.value)});`
+ ` await changeElementValue(element, ${formatJSONAsJS(
+ step.value,
+ out.getIndent()
+ )});`
);
out.appendLine('}');
}
@@ -217,13 +231,19 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
#appendKeyDownStep(out: LineWriter, step: KeyDownStep): void {
out.appendLine(
- `await targetPage.keyboard.down(${JSON.stringify(step.key)});`
+ `await targetPage.keyboard.down(${formatJSONAsJS(
+ step.key,
+ out.getIndent()
+ )});`
);
}
#appendKeyUpStep(out: LineWriter, step: KeyUpStep): void {
out.appendLine(
- `await targetPage.keyboard.up(${JSON.stringify(step.key)});`
+ `await targetPage.keyboard.up(${formatJSONAsJS(
+ step.key,
+ out.getIndent()
+ )});`
);
}
@@ -233,10 +253,13 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
#appendViewportStep(out: LineWriter, step: SetViewportStep): void {
out.appendLine(
- `await targetPage.setViewport(${JSON.stringify({
- width: step.width,
- height: step.height,
- })})`
+ `await targetPage.setViewport(${formatJSONAsJS(
+ {
+ width: step.width,
+ height: step.height,
+ },
+ out.getIndent()
+ )})`
);
}
@@ -289,7 +312,9 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
}
#appendNavigationStep(out: LineWriter, step: NavigateStep): void {
- out.appendLine(`await targetPage.goto(${formatAsJSLiteral(step.url)});`);
+ out.appendLine(
+ `await targetPage.goto(${formatJSONAsJS(step.url, out.getIndent())});`
+ );
}
#appendWaitExpressionStep(
@@ -299,13 +324,16 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
out.appendLine(
`await ${
step.frame ? 'frame' : 'targetPage'
- }.waitForFunction(${formatAsJSLiteral(step.expression)}, { timeout });`
+ }.waitForFunction(${formatJSONAsJS(
+ step.expression,
+ out.getIndent()
+ )}, { timeout });`
);
}
#appendWaitForElementStep(out: LineWriter, step: WaitForElementStep): void {
out.appendLine(
- `await waitForElement(${JSON.stringify(step)}, ${
+ `await waitForElement(${formatJSONAsJS(step, out.getIndent())}, ${
step.frame ? 'frame' : 'targetPage'
}, timeout);`
);
@@ -314,12 +342,6 @@ export class PuppeteerStringifyExtension extends StringifyExtension {
const defaultTimeout = 5000;
-function formatAsJSLiteral(value: unknown): string {
- // TODO: replace JSON.stringify with a better looking JSLiteral implementation
- // that formats using '', "", `` depending on the content of the value.
- return JSON.stringify(value);
-}
-
const helpers = `async function waitForSelectors(selectors, frame, options) {
for (const selector of selectors) {
try {
diff --git a/src/lighthouse/LighthouseStringifyExtension.ts b/src/lighthouse/LighthouseStringifyExtension.ts
index f7bbe1ba..07dd81a7 100644
--- a/src/lighthouse/LighthouseStringifyExtension.ts
+++ b/src/lighthouse/LighthouseStringifyExtension.ts
@@ -20,6 +20,7 @@ import type { LineWriter } from '../LineWriter.js';
import { Step, StepType, UserFlow } from '../Schema.js';
import { isNavigationStep, isMobileFlow } from './helpers.js';
+import { formatJSONAsJS } from '../JSONUtils.js';
export class LighthouseStringifyExtension extends PuppeteerStringifyExtension {
#isProcessingTimespan = false;
@@ -34,7 +35,7 @@ export class LighthouseStringifyExtension extends PuppeteerStringifyExtension {
disabled: true,
},
};
- out.appendLine(`const flags = ${JSON.stringify(flags)}`);
+ out.appendLine(`const flags = ${formatJSONAsJS(flags, out.getIndent())}`);
if (isMobileFlow(flow)) {
out.appendLine(`const config = undefined;`);
} else {
@@ -47,8 +48,9 @@ export class LighthouseStringifyExtension extends PuppeteerStringifyExtension {
out.appendLine(`const lhApi = await import('lighthouse/core/api.js');`);
// eslint-disable-next-line max-len
out.appendLine(
- `const lhFlow = await lhApi.startFlow(page, {name: ${JSON.stringify(
- flow.title
+ `const lhFlow = await lhApi.startFlow(page, {name: ${formatJSONAsJS(
+ flow.title,
+ out.getIndent()
)}, config, flags});`
);
}
diff --git a/src/main.ts b/src/main.ts
index ad5140c4..c94d8589 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -38,3 +38,4 @@ export { PuppeteerRunnerOwningBrowserExtension } from './PuppeteerRunnerExtensio
export { PuppeteerStringifyExtension } from './PuppeteerStringifyExtension.js';
export { PuppeteerReplayStringifyExtension } from './PuppeteerReplayStringifyExtension.js';
export { LighthouseStringifyExtension } from './lighthouse/LighthouseStringifyExtension.js';
+export * from './JSONUtils.js';
diff --git a/test/JSONUtils.test.ts b/test/JSONUtils.test.ts
new file mode 100644
index 00000000..c764dd96
--- /dev/null
+++ b/test/JSONUtils.test.ts
@@ -0,0 +1,63 @@
+/**
+ Copyright 2022 Google LLC
+
+ 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
+
+ https://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.
+ */
+
+import snapshot from 'snap-shot-it';
+import { assert } from 'chai';
+import { formatJSONAsJS } from '../src/JSONUtils.js';
+import { StepType, AssertedEventType } from '../src/Schema.js';
+
+describe('JSONUtils', () => {
+ it('should format JSON as JS', async () => {
+ const json = {
+ title: 'test',
+ test: true,
+ steps: [
+ {
+ type: StepType.Click as const,
+ target: 'main',
+ selectors: ['aria/Test', ['.cls', '.cls']],
+ offsetX: 1,
+ offsetY: 1,
+ assertedEvents: [{ type: AssertedEventType.Navigation as const }],
+ },
+ {
+ type: StepType.Click as const,
+ target: 'main',
+ selectors: ['aria/Test'],
+ offsetX: 1,
+ offsetY: 1,
+ assertedEvents: [{ type: AssertedEventType.Navigation as const }],
+ },
+ ],
+ otherTest: 1.234,
+ undefinedTest: undefined,
+ nullTest: null,
+ };
+ const str = formatJSONAsJS(json, ' ');
+ snapshot(str);
+ delete json['undefinedTest'];
+ assert.deepStrictEqual(Function(`'use strict';return (${str})`)(), json);
+ });
+
+ it('should properly escape ',
+ ''
+ )
+ );
+ });
+});
diff --git a/test/benchmark.ts b/test/benchmark.test.ts
similarity index 100%
rename from test/benchmark.ts
rename to test/benchmark.test.ts