diff --git a/package-lock.json b/package-lock.json
index f30c5a999..33c00fd0d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1248,9 +1248,9 @@
}
},
"@qawolf/sandbox": {
- "version": "0.1.18",
- "resolved": "https://registry.npmjs.org/@qawolf/sandbox/-/sandbox-0.1.18.tgz",
- "integrity": "sha512-hIk0WCpGWCafhq/WYXYpgnc/3hJVp+fyfuXan109Lz1xtebiyEVLfog+7lj+7cCUZxZ+Z1Q0g5ze2NKNC9cLJA==",
+ "version": "0.1.19",
+ "resolved": "https://registry.npmjs.org/@qawolf/sandbox/-/sandbox-0.1.19.tgz",
+ "integrity": "sha512-Plg0VMJlIuOL9H/HU4Oyo1v3O8qDyZ5lwJUCuD060sY76BpG/+BHDJm8JunbaSiIIZOSDGYDIqIcWdMRAN8CEQ==",
"dev": true,
"requires": {
"serve": "^11.3.0"
diff --git a/package.json b/package.json
index ebd8e8df1..a64e0b2fe 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,7 @@
},
"devDependencies": {
"@ffmpeg-installer/ffmpeg": "^1.0.20",
- "@qawolf/sandbox": "0.1.18",
+ "@qawolf/sandbox": "0.1.19",
"@types/debug": "^4.1.5",
"@types/fs-extra": "^9.0.1",
"@types/glob": "^7.1.3",
diff --git a/packages/sandbox/package-lock.json b/packages/sandbox/package-lock.json
index 61bae3efc..2427c83f0 100644
--- a/packages/sandbox/package-lock.json
+++ b/packages/sandbox/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "@qawolf/sandbox",
- "version": "0.1.18",
+ "version": "0.1.19",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json
index 3dbf7a447..9151f5288 100644
--- a/packages/sandbox/package.json
+++ b/packages/sandbox/package.json
@@ -1,6 +1,6 @@
{
"name": "@qawolf/sandbox",
- "version": "0.1.18",
+ "version": "0.1.19",
"files": [
"bin",
"build"
diff --git a/packages/sandbox/src/App.js b/packages/sandbox/src/App.js
index 569c93b93..e4bca023e 100644
--- a/packages/sandbox/src/App.js
+++ b/packages/sandbox/src/App.js
@@ -11,6 +11,7 @@ import CheckboxInputs from './pages/CheckboxInputs';
import ContentEditables from './pages/ContentEditables';
import DatePickers from './pages/DatePickers';
import Images from './pages/Images';
+import InlineFrames from './pages/InlineFrames';
import InfiniteScroll from './pages/InfiniteScroll';
import Large from './pages/Large';
import LogIn from './pages/LogIn';
@@ -41,6 +42,9 @@ function Navigation() {
Images
+
+ Inline Frames
+
Infinite scroll
@@ -82,6 +86,7 @@ function App() {
+
diff --git a/packages/sandbox/src/pages/InlineFrames/index.js b/packages/sandbox/src/pages/InlineFrames/index.js
new file mode 100644
index 000000000..9fb8e186e
--- /dev/null
+++ b/packages/sandbox/src/pages/InlineFrames/index.js
@@ -0,0 +1,36 @@
+import React from "react";
+
+const style = {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ height: '100vh',
+ position: 'absolute',
+ alignContent: 'space-around',
+ width: '100vw'
+};
+
+const frameStyle = {
+ flexGrow: 1,
+ margin: 20,
+ width: 'calc(100% - 40px)'
+};
+
+const buttonStyle = {
+ flexGrow: 0,
+ height: 30,
+ margin: 20,
+ width: 'calc(100% - 40px)'
+};
+
+function InlineFrames() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default InlineFrames;
diff --git a/src/build-code/buildStepLines.ts b/src/build-code/buildStepLines.ts
index 9b15d3955..2701b16eb 100644
--- a/src/build-code/buildStepLines.ts
+++ b/src/build-code/buildStepLines.ts
@@ -1,19 +1,21 @@
import { ScrollValue, Step } from '../types';
import { isUndefined } from 'util';
-export const didPageChange = (step: Step, previous?: Step): boolean => {
- if (!previous) return false;
-
- return step.event.page !== previous.event.page;
+export type StepLineBuildContext = {
+ initializedFrames: Map;
+ initializedPages: Set;
};
-export const buildPageLine = (step: Step): string => {
- return `page = await qawolf.waitForPage(page.context(), ${step.event.page});`;
+/**
+ * @summary Given a step, returns the correct page variable for it,
+ * such as `page` for the main page or `page2` for the second page.
+ */
+export const getStepPageVariableName = (step: Step): string => {
+ const { page } = step.event;
+ return `page${page === 0 ? '' : page + 1}`;
};
-export const buildSelector = (step: Step): string => {
- const { selector } = step.event;
-
+export const escapeSelector = (selector: string): string => {
if (!selector.includes(`"`)) return `"${selector}"`;
if (!selector.includes(`'`)) return `'${selector}'`;
@@ -31,31 +33,65 @@ export const buildValue = ({ action, value }: Step): string => {
return JSON.stringify(value);
};
-export const buildExpressionLine = (step: Step): string => {
- const { action } = step;
+export const buildExpressionLine = (
+ step: Step,
+ frameVariable?: string,
+): string => {
+ const { action, event } = step;
- const args: string[] = [buildSelector(step)];
+ const args: string[] = [escapeSelector(event.selector)];
const value = buildValue(step);
if (value) args.push(value);
- let methodOpen = `page.${action}(`;
+ const browsingContext = frameVariable || getStepPageVariableName(step);
+
+ let methodOpen = `${browsingContext}.${action}(`;
if (action === 'scroll') {
- methodOpen = `qawolf.scroll(page, `;
+ methodOpen = `qawolf.scroll(${browsingContext}, `;
}
const expression = `await ${methodOpen}${args.join(', ')});`;
return expression;
};
-export const buildStepLines = (step: Step, previous?: Step): string[] => {
+export const buildStepLines = (
+ step: Step,
+ buildContext: StepLineBuildContext = {
+ initializedFrames: new Map(),
+ initializedPages: new Set(),
+ },
+): string[] => {
const lines: string[] = [];
- if (didPageChange(step, previous)) {
- lines.push(buildPageLine(step));
+ const { frameSelector, page } = step.event;
+ const { initializedFrames, initializedPages } = buildContext;
+
+ const pageVariableName = getStepPageVariableName(step);
+ if (page > 0 && !initializedPages.has(page)) {
+ lines.push(
+ `const ${pageVariableName} = await qawolf.waitForPage(page.context(), ${page});`,
+ );
+ initializedPages.add(page);
+ }
+
+ let frameVariableName: string;
+ if (frameSelector) {
+ frameVariableName = initializedFrames.get(frameSelector);
+ if (!frameVariableName) {
+ frameVariableName = `frame${
+ initializedFrames.size ? initializedFrames.size + 1 : ''
+ }`;
+ lines.push(
+ `const ${frameVariableName} = await (await ${pageVariableName}.$(${escapeSelector(
+ frameSelector,
+ )})).contentFrame();`,
+ );
+ initializedFrames.set(frameSelector, frameVariableName);
+ }
}
- lines.push(buildExpressionLine(step));
+ lines.push(buildExpressionLine(step, frameVariableName));
return lines;
};
diff --git a/src/build-code/buildVirtualCode.ts b/src/build-code/buildVirtualCode.ts
index 1c1334305..a8635f42d 100644
--- a/src/build-code/buildVirtualCode.ts
+++ b/src/build-code/buildVirtualCode.ts
@@ -1,14 +1,16 @@
-import { buildStepLines } from './buildStepLines';
+import { buildStepLines, StepLineBuildContext } from './buildStepLines';
import { Step } from '../types';
import { VirtualCode } from './VirtualCode';
export const buildVirtualCode = (steps: Step[]): VirtualCode => {
- let previous: Step = null;
const lines: string[] = [];
+ const buildContext: StepLineBuildContext = {
+ initializedFrames: new Map(),
+ initializedPages: new Set(),
+ };
steps.forEach(step => {
- lines.push(...buildStepLines(step, previous));
- previous = step;
+ lines.push(...buildStepLines(step, buildContext));
});
return new VirtualCode(lines);
diff --git a/src/create-code/ContextEventCollector.ts b/src/create-code/ContextEventCollector.ts
index 2280a1102..7e29d54fe 100644
--- a/src/create-code/ContextEventCollector.ts
+++ b/src/create-code/ContextEventCollector.ts
@@ -1,14 +1,51 @@
import Debug from 'debug';
import { EventEmitter } from 'events';
-import { BrowserContext } from 'playwright';
+import { BrowserContext, Frame, Page } from 'playwright';
+import { loadConfig } from '../config';
+import { ElementEvent } from '../types';
import { IndexedPage } from '../utils/context/indexPages';
+import { QAWolfWeb } from '../web';
+import { DEFAULT_ATTRIBUTE_LIST } from '../web/attribute';
import { isRegistered } from '../utils/context/register';
-import { ElementEvent } from '../types';
const debug = Debug('qawolf:ContextEventCollector');
+type BindingOptions = {
+ frame: Frame;
+ page: Page;
+};
+
+export const buildFrameSelector = async (
+ frame: Frame,
+ attributes: string[],
+): Promise => {
+ // build the frame selector if this is one frame down from the parent
+ const parentFrame = frame.parentFrame();
+
+ if (parentFrame && !parentFrame.parentFrame()) {
+ const frameElement = await frame.frameElement();
+
+ const frameSelector = await parentFrame.evaluate(
+ ({ attributes, frameElement }) => {
+ const web: QAWolfWeb = (window as any).qawolf;
+
+ return web.buildSelector({
+ attributes,
+ isClick: false,
+ target: frameElement as HTMLElement,
+ });
+ },
+ { attributes, frameElement },
+ );
+
+ return frameSelector;
+ }
+
+ return undefined;
+};
+
export class ContextEventCollector extends EventEmitter {
- readonly _attribute: string;
+ readonly _attributes: string[];
readonly _context: BrowserContext;
public static async create(
@@ -21,6 +58,9 @@ export class ContextEventCollector extends EventEmitter {
protected constructor(context: BrowserContext) {
super();
+ this._attributes = (loadConfig().attribute || DEFAULT_ATTRIBUTE_LIST).split(
+ ',',
+ );
this._context = context;
}
@@ -31,10 +71,12 @@ export class ContextEventCollector extends EventEmitter {
await this._context.exposeBinding(
'qawElementEvent',
- ({ page }, elementEvent: ElementEvent) => {
+ async ({ frame, page }: BindingOptions, elementEvent: ElementEvent) => {
const pageIndex = (page as IndexedPage).createdIndex;
const event: ElementEvent = { ...elementEvent, page: pageIndex };
debug(`emit %j`, event);
+
+ event.frameSelector = await buildFrameSelector(frame, this._attributes);
this.emit('elementevent', event);
},
);
diff --git a/src/types.ts b/src/types.ts
index c6ec3ab8e..8712dd476 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -16,12 +16,14 @@ export interface Doc {
}
export interface ElementEvent {
+ frameSelector?: string;
isTrusted: boolean;
name: ElementEventName;
page: number;
selector: string;
target: Doc;
time: number;
+ value?: string | ScrollValue | null;
}
export type ElementEventName =
diff --git a/src/web/PageEventCollector.ts b/src/web/PageEventCollector.ts
index 580963692..944aaaa2d 100644
--- a/src/web/PageEventCollector.ts
+++ b/src/web/PageEventCollector.ts
@@ -54,7 +54,7 @@ export class PageEventCollector {
const target = event.target as HTMLElement;
const isTargetVisible = isVisible(target, window.getComputedStyle(target));
- const elementEvent = {
+ const elementEvent: types.ElementEvent = {
isTrusted: event.isTrusted && isTargetVisible,
name: eventName,
page: -1, // set in ContextEventCollector
diff --git a/src/web/selectorEngine.ts b/src/web/selectorEngine.ts
index 9b6014ed1..f6504462f 100644
--- a/src/web/selectorEngine.ts
+++ b/src/web/selectorEngine.ts
@@ -54,7 +54,9 @@ export const getElementText = (element: HTMLElement): string | undefined => {
};
export const isMatch = ({ selectorParts, target }: IsMatch): boolean => {
- const result = querySelectorAll({ parts: selectorParts }, document);
+ // We must pass `target.ownerDocument` rather than `document`
+ // because sometimes this is called from other frames.
+ const result = querySelectorAll({ parts: selectorParts }, target.ownerDocument);
return result[0] === target;
};
diff --git a/test/build-code/buildStepLines.test.ts b/test/build-code/buildStepLines.test.ts
index 4c593a374..b03df7a3a 100644
--- a/test/build-code/buildStepLines.test.ts
+++ b/test/build-code/buildStepLines.test.ts
@@ -1,36 +1,24 @@
import {
- buildSelector,
buildStepLines,
+ escapeSelector,
} from '../../src/build-code/buildStepLines';
import { Action } from '../../src/types';
import { baseStep } from './fixtures';
-describe('buildSelector', () => {
- const expectEqual = (selector: string, expected: string) => {
- const built = buildSelector({
- ...baseStep,
- event: {
- ...baseStep.event,
- selector,
- },
- });
- expect(built).toEqual(expected);
- };
-
+describe('escapeSelector', () => {
it('uses double quotes by default', () => {
- expectEqual('a', `"a"`);
+ expect(escapeSelector('a')).toBe(`"a"`);
});
it('uses single quotes when there are double quotes in the selector', () => {
- expectEqual('"a"', `'"a"'`);
+ expect(escapeSelector('"a"')).toBe(`'"a"'`);
});
it('uses backtick when there are double and single quotes', () => {
- expectEqual(`text="a" and 'b'`, '`text="a" and \'b\'`');
+ expect(escapeSelector(`text="a" and 'b'`)).toBe('`text="a" and \'b\'`');
// escapes backtick
- expectEqual(
- 'text="a" and \'b\' and `c`',
+ expect(escapeSelector('text="a" and \'b\' and `c`')).toBe(
'`text="a" and \'b\' and \\`c\\``',
);
});
@@ -38,22 +26,90 @@ describe('buildSelector', () => {
describe('buildStepLines', () => {
test('consecutive steps on different pages', () => {
+ const lines = buildStepLines({
+ ...baseStep,
+ event: {
+ ...baseStep.event,
+ page: 1,
+ },
+ index: 1,
+ });
+
+ expect(lines).toMatchInlineSnapshot(`
+ Array [
+ "const page2 = await qawolf.waitForPage(page.context(), 1);",
+ "await page2.click('[data-qa=\\"test-input\\"]');",
+ ]
+ `);
+ });
+
+ test('iframe step', () => {
+ const lines = buildStepLines({
+ ...baseStep,
+ event: {
+ ...baseStep.event,
+ frameSelector: '#frameId',
+ },
+ index: 1,
+ });
+
+ expect(lines).toMatchInlineSnapshot(`
+ Array [
+ "const frame = await (await page.$(\\"#frameId\\")).contentFrame();",
+ "await frame.click('[data-qa=\\"test-input\\"]');",
+ ]
+ `);
+ });
+
+ test('iframe step, variable init already done', () => {
+ const initializedFrames = new Map();
+ initializedFrames.set('#frameId', 'frame');
+
const lines = buildStepLines(
{
...baseStep,
event: {
...baseStep.event,
- page: 1,
+ frameSelector: '#frameId',
},
index: 1,
},
- baseStep,
+ {
+ initializedFrames,
+ initializedPages: new Set(),
+ },
);
expect(lines).toMatchInlineSnapshot(`
Array [
- "page = await qawolf.waitForPage(page.context(), 1);",
- "await page.click('[data-qa=\\"test-input\\"]');",
+ "await frame.click('[data-qa=\\"test-input\\"]');",
+ ]
+ `);
+ });
+
+ test('second iframe step', () => {
+ const initializedFrames = new Map();
+ initializedFrames.set('#frameId', 'frame');
+
+ const lines = buildStepLines(
+ {
+ ...baseStep,
+ event: {
+ ...baseStep.event,
+ frameSelector: '#frameId2',
+ },
+ index: 1,
+ },
+ {
+ initializedFrames,
+ initializedPages: new Set(),
+ },
+ );
+
+ expect(lines).toMatchInlineSnapshot(`
+ Array [
+ "const frame2 = await (await page.$(\\"#frameId2\\")).contentFrame();",
+ "await frame2.click('[data-qa=\\"test-input\\"]');",
]
`);
});
diff --git a/test/create-code/ContextEventCollector.test.ts b/test/create-code/ContextEventCollector.test.ts
index aa9eeedc8..5c5080459 100644
--- a/test/create-code/ContextEventCollector.test.ts
+++ b/test/create-code/ContextEventCollector.test.ts
@@ -1,6 +1,6 @@
import { Browser, BrowserContext, ChromiumBrowserContext } from 'playwright';
-import { ContextEventCollector } from '../../src/create-code/ContextEventCollector';
import { isKeyEvent } from '../../src/build-workflow/event';
+import { ContextEventCollector } from '../../src/create-code/ContextEventCollector';
import {
ElementEvent,
InputEvent,
@@ -8,7 +8,7 @@ import {
PasteEvent,
ScrollEvent,
} from '../../src/types';
-import { getLaunchOptions, launch, register } from '../../src/utils';
+import { getLaunchOptions, launch, register, waitFor } from '../../src/utils';
import { sleep, TEST_URL } from '../utils';
describe('ContextEventCollector', () => {
@@ -53,7 +53,29 @@ describe('ContextEventCollector', () => {
]);
});
- it('records paste', async () => {
+ it('collects frameSelector', async () => {
+ // does not work on firefox (which is fine since we only record with chromium)
+ if (getLaunchOptions().browserName === 'firefox') return;
+
+ const page = await context.newPage();
+
+ await page.goto(`${TEST_URL}iframes`);
+
+ const frame = await (
+ await page.$('iframe[src="/text-inputs"]')
+ ).contentFrame();
+ await frame.fill('[data-qa="html-text-input"]', 'hello');
+
+ await waitFor(() => events.length);
+
+ await page.close();
+
+ expect(events[0].frameSelector).toEqual('[data-qa="second"]');
+ expect(events[0].selector).toEqual('[data-qa="html-text-input"]');
+ expect(events[0].value).toEqual('hello');
+ });
+
+ it('collects paste', async () => {
const page = await context.newPage();
await page.goto(`${TEST_URL}text-inputs`);
@@ -78,7 +100,7 @@ describe('ContextEventCollector', () => {
expect(events[0].selector).toEqual('[data-qa="html-text-input"]');
});
- it('records scroll event', async () => {
+ it('collects scroll event', async () => {
// only test this on chrome for now
if (getLaunchOptions().browserName !== 'chromium') return;
@@ -116,11 +138,10 @@ describe('ContextEventCollector', () => {
expect(value.y).toBeGreaterThan(200);
expect(isTrusted).toEqual(true);
- // TODO: page.close broken https://github.com/microsoft/playwright/issues/1258
- // await page.close();
+ await page.close();
});
- it('records input event for select', async () => {
+ it('collects input event for select', async () => {
const page = await context.newPage();
await page.goto(`${TEST_URL}selects`);
@@ -137,7 +158,7 @@ describe('ContextEventCollector', () => {
expect(value).toEqual('hedgehog');
});
- it('records type', async () => {
+ it('collects type', async () => {
const page = await context.newPage();
await page.goto(`${TEST_URL}text-inputs`);