diff --git a/src/LineWriter.ts b/src/LineWriter.ts new file mode 100644 index 00000000..edfdc4d6 --- /dev/null +++ b/src/LineWriter.ts @@ -0,0 +1,21 @@ +/** + 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. + */ + +export interface LineWriter { + appendLine(line: string): LineWriter; + startBlock(): LineWriter; + endBlock(): LineWriter; +} diff --git a/src/LineWriterImpl.ts b/src/LineWriterImpl.ts new file mode 100644 index 00000000..faf18d6c --- /dev/null +++ b/src/LineWriterImpl.ts @@ -0,0 +1,50 @@ +/** + 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 { LineWriter } from "./LineWriter.js"; + +export class LineWriterImpl implements LineWriter { + #indentation: string; + #currentIndentation = 0; + #lines: string[] = []; + + constructor(indentation: string) { + this.#indentation = indentation; + } + + appendLine(line: string): LineWriter { + const indentedLine = line + ? this.#indentation.repeat(this.#currentIndentation) + line.trimEnd() + : ""; + this.#lines.push(indentedLine); + return this; + } + + startBlock(): LineWriter { + this.#currentIndentation++; + return this; + } + + endBlock(): LineWriter { + this.#currentIndentation--; + return this; + } + + toString(): string { + // Scripts should end with a final blank line. + return this.#lines.join("\n") + "\n"; + } +} diff --git a/src/PuppeteerStringifyExtension.ts b/src/PuppeteerStringifyExtension.ts new file mode 100644 index 00000000..d64c5563 --- /dev/null +++ b/src/PuppeteerStringifyExtension.ts @@ -0,0 +1,420 @@ +/** + 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 type { LineWriter } from "./LineWriter.js"; +import type { + Step, + ClickStep, + StepWithFrame, + StepWithSelectors, + ChangeStep, + UserFlow, + EmulateNetworkConditionsStep, + KeyDownStep, + KeyUpStep, + CloseStep, + SetViewportStep, + ScrollStep, + NavigateStep, + WaitForElementStep, + WaitForExpressionStep, +} from "./Schema.js"; +import type { StringifyExtension } from "./StringifyExtension.js"; + +import { + assertAllStepTypesAreHandled, + typeableInputTypes, +} from "./SchemaUtils.js"; + +export class PuppeteerStringifyExtension implements StringifyExtension { + async beforeAllSteps(out: LineWriter, flow: UserFlow) { + out.appendLine( + "const puppeteer = require('puppeteer'); // v13.0.0 or later" + ); + out.appendLine(""); + out.appendLine("(async () => {").startBlock(); + out.appendLine("const browser = await puppeteer.launch();"); + out.appendLine("const page = await browser.newPage();"); + out.appendLine(`const timeout = ${flow.timeout || defaultTimeout};`); + out.appendLine("page.setDefaultTimeout(timeout);"); + out.appendLine(""); + + for (const line of helpers.split("\n")) { + out.appendLine(line); + } + } + + async afterAllSteps(out: LineWriter, flow: UserFlow) { + out.appendLine(""); + out.appendLine("await browser.close();").endBlock(); + out.appendLine("})();"); + } + + async stringifyStep(out: LineWriter, step: Step, flow: UserFlow) { + out.appendLine("{").startBlock(); + if (step.timeout !== undefined) { + out.appendLine(`const timeout = ${step.timeout};`); + } + this.#appendContext(out, step); + if (step.assertedEvents) { + out.appendLine("const promises = [];"); + for (const event of step.assertedEvents) { + switch (event.type) { + case "navigation": { + out.appendLine( + `promises.push(${ + "frame" in step && step.frame ? "frame" : "targetPage" + }.waitForNavigation());` + ); + break; + } + default: + throw new Error(`Event type ${event.type} is not supported`); + } + } + } + + this.#appendStepType(out, step); + + if (step.assertedEvents) { + out.appendLine("await Promise.all(promises);"); + } + + out.endBlock().appendLine("}"); + } + + #appendTarget(out: LineWriter, target: string): void { + if (target === "main") { + out.appendLine("const targetPage = page;"); + } else { + out.appendLine( + `const target = await browser.waitForTarget(t => t.url() === ${formatAsJSLiteral( + target + )}, { timeout });` + ); + out.appendLine("const targetPage = await target.page();"); + out.appendLine("targetPage.setDefaultTimeout(timeout);"); + } + } + + #appendFrame(out: LineWriter, path: number[]): void { + out.appendLine("let frame = targetPage.mainFrame();"); + for (const index of path) { + out.appendLine(`frame = frame.childFrames()[${index}];`); + } + } + + #appendContext(out: LineWriter, step: StepWithFrame): void { + // TODO fix optional target: should it be main? + this.#appendTarget(out, step.target || "main"); + // TODO fix optional frame: should it be required? + if (step.frame) { + this.#appendFrame(out, step.frame); + } + } + + #appendWaitForSelector(out: LineWriter, step: StepWithSelectors): void { + out.appendLine( + `const element = await waitForSelectors(${JSON.stringify( + step.selectors + )}, ${step.frame ? "frame" : "targetPage"}, { timeout, visible: true });` + ); + out.appendLine("await scrollIntoViewIfNeeded(element, timeout);"); + } + + #appendClickStep(out: LineWriter, step: ClickStep): void { + this.#appendWaitForSelector(out, step); + out.appendLine( + `await element.click({ offset: { x: ${step.offsetX}, y: ${step.offsetY}} });` + ); + } + + #appendChangeStep(out: LineWriter, step: ChangeStep): void { + this.#appendWaitForSelector(out, step); + out.appendLine("const type = await element.evaluate(el => el.type);"); + out.appendLine( + `if (${JSON.stringify(Array.from(typeableInputTypes))}.includes(type)) {` + ); + out.appendLine(` await element.type(${formatAsJSLiteral(step.value)});`); + out.appendLine("} else {"); + out.appendLine(" await element.focus();"); + out.appendLine(" await element.evaluate((el, value) => {"); + out.appendLine(" el.value = value;"); + out.appendLine( + " el.dispatchEvent(new Event('input', { bubbles: true }));" + ); + out.appendLine( + " el.dispatchEvent(new Event('change', { bubbles: true }));" + ); + out.appendLine(` }, ${JSON.stringify(step.value)});`); + out.appendLine("}"); + } + + #appendEmulateNetworkConditionsStep( + out: LineWriter, + step: EmulateNetworkConditionsStep + ): void { + out.appendLine("await targetPage.emulateNetworkConditions({"); + out.appendLine(` offline: ${!step.download && !step.upload},`); + out.appendLine(` downloadThroughput: ${step.download},`); + out.appendLine(` uploadThroughput: ${step.upload},`); + out.appendLine(` latency: ${step.latency},`); + out.appendLine("});"); + } + + #appendKeyDownStep(out: LineWriter, step: KeyDownStep): void { + out.appendLine( + `await targetPage.keyboard.down(${JSON.stringify(step.key)});` + ); + } + + #appendKeyUpStep(out: LineWriter, step: KeyUpStep): void { + out.appendLine( + `await targetPage.keyboard.up(${JSON.stringify(step.key)});` + ); + } + + #appendCloseStep(out: LineWriter, _step: CloseStep): void { + out.appendLine("await targetPage.close()"); + } + + #appendViewportStep(out: LineWriter, step: SetViewportStep): void { + out.appendLine( + `await targetPage.setViewport(${JSON.stringify({ + width: step.width, + height: step.height, + })})` + ); + } + + #appendScrollStep(out: LineWriter, step: ScrollStep): void { + if ("selectors" in step) { + this.#appendWaitForSelector(out, step); + out.appendLine( + `await element.evaluate((el, x, y) => { el.scrollTop = y; el.scrollLeft = x; }, ${step.x}, ${step.y});` + ); + } else { + out.appendLine( + `await targetPage.evaluate((x, y) => { window.scroll(x, y); }, ${step.x}, ${step.y})` + ); + } + } + + #appendStepType(out: LineWriter, step: Step): void { + switch (step.type) { + case "click": + return this.#appendClickStep(out, step); + case "change": + return this.#appendChangeStep(out, step); + case "emulateNetworkConditions": + return this.#appendEmulateNetworkConditionsStep(out, step); + case "keyDown": + return this.#appendKeyDownStep(out, step); + case "keyUp": + return this.#appendKeyUpStep(out, step); + case "close": + return this.#appendCloseStep(out, step); + case "setViewport": + return this.#appendViewportStep(out, step); + case "scroll": + return this.#appendScrollStep(out, step); + case "navigate": + return this.#appendNavigationStep(out, step); + case "waitForElement": + return this.#appendWaitForElementStep(out, step); + case "waitForExpression": + return this.#appendWaitExpressionStep(out, step); + case "customStep": + return; // TODO: implement these + default: + return assertAllStepTypesAreHandled(step); + } + } + + #appendNavigationStep(out: LineWriter, step: NavigateStep): void { + out.appendLine(`await targetPage.goto(${formatAsJSLiteral(step.url)});`); + } + + #appendWaitExpressionStep( + out: LineWriter, + step: WaitForExpressionStep + ): void { + out.appendLine( + `await ${ + step.frame ? "frame" : "targetPage" + }.waitForFunction(${formatAsJSLiteral(step.expression)}, { timeout });` + ); + } + + #appendWaitForElementStep(out: LineWriter, step: WaitForElementStep): void { + out.appendLine( + `await waitForElement(${JSON.stringify(step)}, ${ + step.frame ? "frame" : "targetPage" + }, timeout);` + ); + } +} + +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 { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); +} + +async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); +} + +async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); +} + +async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); +} + +async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; +} + +async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, timeout); +} + +async function querySelectorsAll(selectors, frame) { + for (const selector of selectors) { + const result = await querySelectorAll(selector, frame); + if (result.length) { + return result; + } + } + return []; +} + +async function querySelectorAll(selector, frame) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to querySelectorAll'); + } + let elements = []; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (i === 0) { + elements = await frame.$$(part); + } else { + const tmpElements = elements; + elements = []; + for (const el of tmpElements) { + elements.push(...(await el.$$(part))); + } + } + if (elements.length === 0) { + return []; + } + if (i < selector.length - 1) { + const tmpElements = []; + for (const el of elements) { + const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + if (newEl) { + tmpElements.push(newEl); + } + } + elements = tmpElements; + } + } + return elements; +} + +async function waitForFunction(fn, timeout) { + let isActive = true; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); +}`; diff --git a/src/StringifyExtension.ts b/src/StringifyExtension.ts new file mode 100644 index 00000000..c569fad7 --- /dev/null +++ b/src/StringifyExtension.ts @@ -0,0 +1,26 @@ +/** + 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 { LineWriter } from "./LineWriter.js"; +import { Step, UserFlow } from "./Schema.js"; + +export interface StringifyExtension { + beforeAllSteps?(out: LineWriter, flow: UserFlow): Promise; + afterAllSteps?(out: LineWriter, flow: UserFlow): Promise; + beforeEachStep?(out: LineWriter, step: Step, flow: UserFlow): Promise; + stringifyStep(out: LineWriter, step: Step, flow: UserFlow): Promise; + afterEachStep?(out: LineWriter, step: Step, flow: UserFlow): Promise; +} diff --git a/src/main.ts b/src/main.ts index 9393af27..a3110248 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,8 +14,7 @@ limitations under the License. */ -import { UserFlow } from "./Schema.js"; -import { parse } from "./SchemaUtils.js"; - -export { parse }; -export { UserFlow }; +export { UserFlow } from "./Schema.js"; +export { parse } from "./SchemaUtils.js"; +export { StringifyExtension } from "./StringifyExtension.js"; +export { stringify } from "./stringify.js"; diff --git a/src/stringify.ts b/src/stringify.ts new file mode 100644 index 00000000..c374d6ed --- /dev/null +++ b/src/stringify.ts @@ -0,0 +1,55 @@ +/** + 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 { LineWriterImpl } from "./LineWriterImpl.js"; +import { PuppeteerStringifyExtension } from "./PuppeteerStringifyExtension.js"; +import type { UserFlow } from "./Schema.js"; +import { StringifyExtension } from "./StringifyExtension.js"; + +interface StringifyOptions { + extension?: StringifyExtension; + indentation?: string; +} + +export async function stringify( + flow: UserFlow, + opts?: StringifyOptions +): Promise { + if (!opts) { + opts = {}; + } + if (!opts.extension) { + opts.extension = new PuppeteerStringifyExtension(); + } + if (!opts.indentation) { + opts.indentation = " "; + } + const out = new LineWriterImpl(opts.indentation); + const ext = opts.extension; + if (!ext) { + throw new Error("Internal error: StringifyExtension is not found."); + } + + await ext.beforeAllSteps?.(out, flow); + for (const step of flow.steps) { + await ext.beforeEachStep?.(out, step, flow); + await ext.stringifyStep(out, step, flow); + await ext.afterEachStep?.(out, step, flow); + } + await ext.afterAllSteps?.(out, flow); + + return out.toString(); +} diff --git a/test/LineWriterImpl_test.ts b/test/LineWriterImpl_test.ts new file mode 100644 index 00000000..428f698f --- /dev/null +++ b/test/LineWriterImpl_test.ts @@ -0,0 +1,34 @@ +/** + 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 { LineWriterImpl } from "../src/LineWriterImpl.js"; +import { assert } from "chai"; + +describe("LineWriterImpl", () => { + it("should open and close blocks", () => { + const out = new LineWriterImpl(" "); + out.appendLine("{").startBlock(); + out.appendLine('console.log("test");'); + out.endBlock().appendLine("}"); + assert.strictEqual( + out.toString(), + `{ + console.log("test"); +} +` + ); + }); +}); diff --git a/test/PuppeteerStringifyExtension_test.ts b/test/PuppeteerStringifyExtension_test.ts new file mode 100644 index 00000000..9a9b8a98 --- /dev/null +++ b/test/PuppeteerStringifyExtension_test.ts @@ -0,0 +1,165 @@ +/** + 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 { LineWriterImpl } from "../src/LineWriterImpl.js"; +import { PuppeteerStringifyExtension } from "../src/PuppeteerStringifyExtension.js"; +import { assert } from "chai"; + +describe("PuppeteerStringifyExtension", () => { + const ext = new PuppeteerStringifyExtension(); + + it("should print the correct script for a click step", async () => { + const step = { + type: "click" as const, + target: "main", + selectors: ["aria/Test"], + offsetX: 1, + offsetY: 1, + }; + const flow = { title: "test", steps: [step] }; + + const writer = new LineWriterImpl(" "); + await ext.stringifyStep(writer, step, flow); + assert.deepEqual( + writer.toString(), + `{ + const targetPage = page; + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ offset: { x: 1, y: 1} }); +} +` + ); + }); + + it("should print the correct script for asserted events", async () => { + const step = { + type: "click" as const, + target: "main", + selectors: ["aria/Test"], + offsetX: 1, + offsetY: 1, + assertedEvents: [{ type: "navigation" as const }], + }; + const flow = { title: "test", steps: [step] }; + + const writer = new LineWriterImpl(" "); + await ext.stringifyStep(writer, step, flow); + assert.deepEqual( + writer.toString(), + `{ + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ offset: { x: 1, y: 1} }); + await Promise.all(promises); +} +` + ); + }); + + it("should print the correct script with a chain selector", async () => { + const step = { + type: "click" as const, + target: "main", + selectors: [["aria/Test", "aria/Test2"]], + offsetX: 1, + offsetY: 1, + }; + const flow = { title: "test", steps: [step] }; + + const writer = new LineWriterImpl(" "); + await ext.stringifyStep(writer, step, flow); + assert.deepEqual( + writer.toString(), + `{ + const targetPage = page; + const element = await waitForSelectors([["aria/Test","aria/Test2"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ offset: { x: 1, y: 1} }); +} +` + ); + }); + + it("should print the correct script for a change step", async () => { + const step = { + type: "change" as const, + target: "main", + selectors: ["aria/Test"], + value: "Hello World", + }; + const flow = { title: "test", steps: [step] }; + + const writer = new LineWriterImpl(" "); + await ext.stringifyStep(writer, step, flow); + assert.deepEqual( + writer.toString(), + `{ + const targetPage = page; + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + const type = await element.evaluate(el => el.type); + if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) { + await element.type("Hello World"); + } else { + await element.focus(); + await element.evaluate((el, value) => { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }, "Hello World"); + } +} +` + ); + }); + + it("should print the correct script for a change step for non-text inputs", async () => { + const step = { + type: "change" as const, + target: "main", + selectors: ["aria/Test"], + value: "#333333", + }; + const flow = { title: "test", steps: [step] }; + + const writer = new LineWriterImpl(" "); + await ext.stringifyStep(writer, step, flow); + assert.deepEqual( + writer.toString(), + `{ + const targetPage = page; + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + const type = await element.evaluate(el => el.type); + if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) { + await element.type("#333333"); + } else { + await element.focus(); + await element.evaluate((el, value) => { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }, "#333333"); + } +} +` + ); + }); +}); diff --git a/test/stringify_test.ts b/test/stringify_test.ts new file mode 100644 index 00000000..c021250c --- /dev/null +++ b/test/stringify_test.ts @@ -0,0 +1,513 @@ +/** + 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 { stringify } from "../src/stringify.js"; +import { assert } from "chai"; + +describe("stringify", () => { + it("should print the correct script for a navigate step", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "navigate" as const, + url: "https://localhost/", + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + await targetPage.goto("https://localhost/"); + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script for a emulateNetworkCondition step", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "emulateNetworkConditions" as const, + download: 100, + upload: 100, + latency: 999, + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + await targetPage.emulateNetworkConditions({ + offline: false, + downloadThroughput: 100, + uploadThroughput: 100, + latency: 999, + }); + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script if the target is not the main page", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "click" as const, + target: "https://localhost/test", + selectors: ["aria/Test"], + offsetX: 1, + offsetY: 1, + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const target = await browser.waitForTarget(t => t.url() === "https://localhost/test", { timeout }); + const targetPage = await target.page(); + targetPage.setDefaultTimeout(timeout); + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ offset: { x: 1, y: 1} }); + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script if the step is within an iframe", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "click" as const, + target: "main", + frame: [1, 1], + selectors: ["aria/Test"], + offsetX: 1, + offsetY: 1, + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + let frame = targetPage.mainFrame(); + frame = frame.childFrames()[1]; + frame = frame.childFrames()[1]; + const element = await waitForSelectors(["aria/Test"], frame, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ offset: { x: 1, y: 1} }); + } + + await browser.close(); +})(); +` + ); + }); + + it("should fail when given an invalid step type", async () => { + try { + await stringify({ + title: "Test Recording", + steps: [ + { + // @ts-ignore + type: "invalid", + target: "main", + frame: [1, 1], + selectors: ["aria/Test"], + offsetX: 1, + offsetY: 1, + }, + ], + }); + assert.fail("Not reachable."); + } catch (err) { + assert.match((err as Error).message, /^Unknown step type: invalid$/); + } + }); + + it("should print the correct script for a keydown step", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "keyDown" as const, + target: "main", + key: "E" as const, + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + await targetPage.keyboard.down("E"); + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script for a keyup step", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "keyUp" as const, + target: "main", + key: "E" as const, + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + await targetPage.keyboard.up("E"); + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script for scroll events", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "scroll" as const, + target: "main", + selectors: ["body > div:nth-child(1)"], + x: 0, + y: 40, + }, + { + type: "scroll" as const, + target: "main", + x: 40, + y: 40, + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + const element = await waitForSelectors(["body > div:nth-child(1)"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.evaluate((el, x, y) => { el.scrollTop = y; el.scrollLeft = x; }, 0, 40); + } + { + const targetPage = page; + await targetPage.evaluate((x, y) => { window.scroll(x, y); }, 40, 40) + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script for waitForElement steps", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "waitForElement" as const, + selectors: ["body > div:nth-child(1)"], + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + await waitForElement({"type":"waitForElement","selectors":["body > div:nth-child(1)"]}, targetPage, timeout); + } + + await browser.close(); +})(); +` + ); + }); + + it("should print the correct script for waitForExpression steps", async () => { + const flow = { + title: "Test Recording", + steps: [ + { + type: "waitForExpression" as const, + expression: "1 + 2", + }, + ], + }; + assert.deepEqual( + await stringify(flow), + `const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + ${helpers} + { + const targetPage = page; + await targetPage.waitForFunction("1 + 2", { timeout }); + } + + await browser.close(); +})(); +` + ); + }); +}); + +const helpers = `async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, timeout); + } + + async function querySelectorsAll(selectors, frame) { + for (const selector of selectors) { + const result = await querySelectorAll(selector, frame); + if (result.length) { + return result; + } + } + return []; + } + + async function querySelectorAll(selector, frame) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to querySelectorAll'); + } + let elements = []; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (i === 0) { + elements = await frame.$$(part); + } else { + const tmpElements = elements; + elements = []; + for (const el of tmpElements) { + elements.push(...(await el.$$(part))); + } + } + if (elements.length === 0) { + return []; + } + if (i < selector.length - 1) { + const tmpElements = []; + for (const el of elements) { + const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + if (newEl) { + tmpElements.push(newEl); + } + } + elements = tmpElements; + } + } + return elements; + } + + async function waitForFunction(fn, timeout) { + let isActive = true; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + }`;