diff --git a/packages/selenium-ide/src/__test__/plugin/locatorResolver.spec.js b/packages/selenium-ide/src/__test__/plugin/locatorResolver.spec.js new file mode 100644 index 000000000..ea2506993 --- /dev/null +++ b/packages/selenium-ide/src/__test__/plugin/locatorResolver.spec.js @@ -0,0 +1,109 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 +// +// http://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 { registerLocator, canResolveLocator, resolveLocator } from "../../plugin/locatorResolver"; + +describe("locator resolver", () => { + it("should register a locator", () => { + expect(registerLocator("test", new Function())).toBeUndefined(); + }); + it("should fail to register a locator with no key", () => { + expect(() => registerLocator()).toThrowError("Expected to receive string instead received undefined"); + }); + it("should fail to register a locator with a key that is not string", () => { + expect(() => registerLocator(5, new Function())).toThrowError("Expected to receive string instead received number"); + }); + it("should fail to register a locator with no callback", () => { + expect(() => registerLocator("test")).toThrowError("Expected to receive function instead received undefined"); + }); + it("should fail to register a locator with a callback that is not a function", () => { + expect(() => registerLocator("test", 1)).toThrowError("Expected to receive function instead received number"); + }); + it("should fail to register a locator with the same key as a previous one", () => { + const key = "testTwo"; + registerLocator(key, new Function()); + expect(() => registerLocator(key, new Function())).toThrowError(`A locator named ${key} already exists`); + }); + it("should fail to override a default locator", () => { + expect(() => registerLocator("id", new Function())).toThrowError("Overriding default locator strategies is disallowed"); + expect(() => registerLocator("name", new Function())).toThrowError("Overriding default locator strategies is disallowed"); + expect(() => registerLocator("link", new Function())).toThrowError("Overriding default locator strategies is disallowed"); + expect(() => registerLocator("css", new Function())).toThrowError("Overriding default locator strategies is disallowed"); + expect(() => registerLocator("xpath", new Function())).toThrowError("Overriding default locator strategies is disallowed"); + }); + it("should check if a locator may be resolved", () => { + registerLocator("exists", new Function()); + expect(canResolveLocator("exists")).toBeTruthy(); + expect(canResolveLocator("nonExistent")).toBeFalsy(); + }); + it("should throw when resolving a locator that does not exist", () => { + const locator = "nonExistent"; + expect(() => resolveLocator(locator)).toThrowError(`The locator ${locator} is not registered with any plugin`); + }); + it("should successfully resolve a sync locator", () => { + const cb = () => { + return "//button"; + }; + registerLocator("syncLocator", cb); + expect(resolveLocator("syncLocator")).toBe("//button"); + }); + it("should fail to resolve a sync locator", () => { + const locator = "syncFail"; + const cb = () => { + throw new Error("test error"); + }; + registerLocator(locator, cb); + expect(() => { + try { + resolveLocator(locator); + } catch(e) { + if (e.message !== `The locator ${locator} is not registered with any plugin`) { + throw e; + } + } + }).toThrow("test error"); + }); + it("should throw if the returned value is not an xpath", () => { + const locator = "xpathErr"; + const cb = () => { + return 5; + }; + registerLocator(locator, cb); + expect(() => { + try { + resolveLocator(locator); + } catch(e) { + if (e.message !== `The locator ${locator} is not registered with any plugin`) { + throw e; + } + } + }).toThrow(`Locator ${locator} returned an invalid response`); + }); + it("should successfully resolve an async locator", () => { + registerLocator("asyncLocator", () => Promise.resolve("//button")); + expect(resolveLocator("asyncLocator")).resolves.toBe("//button"); + }); + it("should fail to resolve an async locator", () => { + registerLocator("asyncFail", () => Promise.reject(false)); + expect(resolveLocator("asyncFail")).rejects.toBeFalsy(); + }); + it("should pass options to the locator resolver", () => { + registerLocator("optionsLocator", (target, options) => (options.first)); + const option = "test"; + expect(resolveLocator("optionsLocator", "button", { first: option })).toEqual(option); + }); +}); diff --git a/packages/selenium-ide/src/__test__/plugin/manager.spec.js b/packages/selenium-ide/src/__test__/plugin/manager.spec.js index 164330e62..8d1a26bc9 100644 --- a/packages/selenium-ide/src/__test__/plugin/manager.spec.js +++ b/packages/selenium-ide/src/__test__/plugin/manager.spec.js @@ -17,6 +17,7 @@ import Manager from "../../plugin/manager"; import { canExecuteCommand } from "../../plugin/commandExecutor"; +import { canResolveLocator } from "../../plugin/locatorResolver"; describe("plugin manager", () => { it("should have a list of active plugins", () => { @@ -30,12 +31,16 @@ describe("plugin manager", () => { commands: [{ id: "aCommand", name: "do something" + }], + locators: [{ + id: "aLocator" }] }; expect(Manager.plugins.length).toBe(0); Manager.registerPlugin(plugin); expect(Manager.plugins.length).toBe(1); expect(canExecuteCommand(plugin.commands[0].id)).toBeTruthy(); + expect(canResolveLocator(plugin.locators[0].id)).toBeTruthy(); }); it("should register a plugin with no commands", () => { const plugin = { diff --git a/packages/selenium-ide/src/api/v1/index.js b/packages/selenium-ide/src/api/v1/index.js index ffedf654e..d5ca4b717 100644 --- a/packages/selenium-ide/src/api/v1/index.js +++ b/packages/selenium-ide/src/api/v1/index.js @@ -34,6 +34,7 @@ router.post("/register", (req, res) => { name: req.name, version: req.version, commands: req.commands, + locators: req.locators, dependencies: req.dependencies }; Manager.registerPlugin(plugin); diff --git a/packages/selenium-ide/src/neo/IO/SideeX/playback.js b/packages/selenium-ide/src/neo/IO/SideeX/playback.js index 8403f35fe..87790118f 100644 --- a/packages/selenium-ide/src/neo/IO/SideeX/playback.js +++ b/packages/selenium-ide/src/neo/IO/SideeX/playback.js @@ -20,7 +20,9 @@ import FatalError from "../../../errors/fatal"; import NoResponseError from "../../../errors/no-response"; import PlaybackState, { PlaybackStates } from "../../stores/view/PlaybackState"; import UiState from "../../stores/view/UiState"; +import { Commands, TargetTypes } from "../../models/Command"; import { canExecuteCommand, executeCommand } from "../../../plugin/commandExecutor"; +import { canResolveLocator, resolveLocator } from "../../../plugin/locatorResolver"; import ExtCommand, { isExtCommand } from "./ext-command"; import { xlateArgument } from "./formatCommand"; @@ -95,7 +97,7 @@ function executionLoop() { .then(doAjaxWait) .then(doDomWait) .then(doDelay) - .then(doCommand) + .then(prepDoCommand) .then(executionLoop); } } @@ -226,10 +228,34 @@ function doDomWait(res, domTime, domCount = 0) { }); } -function doCommand(res, implicitTime = Date.now(), implicitCount = 0) { +function prepDoCommand() { if (!PlaybackState.isPlaying || PlaybackState.paused) return; const { id, command, target, value } = PlaybackState.runningQueue[PlaybackState.currentPlayingIndex]; + let parseTarget = Promise.resolve(target); + if (command === "open") { + parseTarget = Promise.resolve(new URL(target, baseUrl).href); + } else if (Commands.list.get(command).type === TargetTypes.LOCATOR) { + const breakLocator = /\s*([^\s]*)\s*=\s*(.*[^\s])\s*/; + const results = target.match(breakLocator); + if (canResolveLocator(results[1])) { + parseTarget = resolveLocator(results[1], results[2], { + commandId: id, + runId: PlaybackState.runId, + testId: PlaybackState.currentRunningTest.id, + frameId: extCommand.getCurrentPlayingFrameId(), + tabId: extCommand.currentPlayingTabId, + windowId: extCommand.currentPlayingWindowId + }); + } + } + + return parseTarget.then((parsed) => ( + doCommand(id, command, { target, parsed }, value) + )); +} + +function doCommand(id, command, target, value, implicitTime = Date.now(), implicitCount = 0) { let p = new Promise(function(resolve, reject) { let count = 0; let interval = setInterval(function() { @@ -247,22 +273,21 @@ function doCommand(res, implicitTime = Date.now(), implicitCount = 0) { }, 500); }); - const parsedTarget = command === "open" ? new URL(target, baseUrl).href : target; return p.then(() => ( canExecuteCommand(command) ? - doPluginCommand(id, command, parsedTarget, value, implicitTime, implicitCount) : - doSeleniumCommand(id, command, parsedTarget, value, implicitTime, implicitCount) + doPluginCommand(id, command, target, value, implicitTime, implicitCount) : + doSeleniumCommand(id, command, target, value, implicitTime, implicitCount) )); } -function doSeleniumCommand(id, command, parsedTarget, value, implicitTime, implicitCount) { +function doSeleniumCommand(id, command, target, value, implicitTime, implicitCount) { return (command !== "type" - ? extCommand.sendMessage(command, xlateArgument(parsedTarget), xlateArgument(value), isWindowMethodCommand(command)) - : extCommand.doType(xlateArgument(parsedTarget), xlateArgument(value), isWindowMethodCommand(command))).then(function(result) { + ? extCommand.sendMessage(command, xlateArgument(target.parsed), xlateArgument(value), isWindowMethodCommand(command)) + : extCommand.doType(xlateArgument(target.parsed), xlateArgument(value), isWindowMethodCommand(command))).then(function(result) { if (result.result !== "success") { // implicit if (isElementNotFound(result.result)) { - return doImplicitWait(result.result, id, parsedTarget, implicitTime, implicitCount); + return doImplicitWait(result.result, id, command, target, value, implicitTime, implicitCount); } else { PlaybackState.setCommandState(id, /^verify/.test(command) ? PlaybackStates.Failed : PlaybackStates.Fatal, result.result); } @@ -273,7 +298,7 @@ function doSeleniumCommand(id, command, parsedTarget, value, implicitTime, impli } function doPluginCommand(id, command, target, value, implicitTime, implicitCount) { - return executeCommand(command, target, value, { + return executeCommand(command, target.parsed, value, { commandId: id, runId: PlaybackState.runId, testId: PlaybackState.currentRunningTest.id, @@ -284,7 +309,7 @@ function doPluginCommand(id, command, target, value, implicitTime, implicitCount PlaybackState.setCommandState(id, res.status ? res.status : PlaybackStates.Passed, res && res.message || undefined); }).catch(err => { if (isElementNotFound(err.message)) { - return doImplicitWait(err.message, id, target, implicitTime, implicitCount); + return doImplicitWait(err.message, id, command, target, value, implicitTime, implicitCount); } else { PlaybackState.setCommandState(id, (err instanceof FatalError || err instanceof NoResponseError) ? PlaybackStates.Fatal : PlaybackStates.Failed, err.message); } @@ -295,7 +320,7 @@ function isElementNotFound(error) { return error.match(/Element[\s\S]*?not found/); } -function doImplicitWait(error, commandId, target, implicitTime, implicitCount) { +function doImplicitWait(error, id, command, target, value, implicitTime, implicitCount) { if (isElementNotFound(error)) { if (implicitTime && (Date.now() - implicitTime > 30000)) { reportError("Implicit Wait timed out after 30000ms"); @@ -306,8 +331,8 @@ function doImplicitWait(error, commandId, target, implicitTime, implicitCount) { if (implicitCount == 1) { implicitTime = Date.now(); } - PlaybackState.setCommandState(commandId, PlaybackStates.Pending, `Trying to find ${target}...`); - return doCommand(false, implicitTime, implicitCount); + PlaybackState.setCommandState(id, PlaybackStates.Pending, `Trying to find ${target.target}...`); + return doCommand(id, command, target, value, implicitTime, implicitCount); } } } diff --git a/packages/selenium-ide/src/plugin/locatorResolver.js b/packages/selenium-ide/src/plugin/locatorResolver.js new file mode 100644 index 000000000..32f85b0c1 --- /dev/null +++ b/packages/selenium-ide/src/plugin/locatorResolver.js @@ -0,0 +1,64 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 +// +// http://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. + +const locators = {}; + +function verifyXpath(locator, response) { + if (typeof response !== "string") { + throw new Error(`Locator ${locator} returned an invalid response`); + } + return response; +} + +function isDefaultLocator(locator) { + return (locator === "id" || + locator === "name" || + locator === "link" || + locator === "css" || + locator === "xpath"); +} + +export function registerLocator(locator, func) { + if (typeof locator !== "string") { + throw new Error(`Expected to receive string instead received ${typeof locator}`); + } else if (isDefaultLocator(locator)) { + throw new Error("Overriding default locator strategies is disallowed"); + } else if (typeof func !== "function") { + throw new Error(`Expected to receive function instead received ${typeof func}`); + } else if (locators[locator]) { + throw new Error(`A locator named ${locator} already exists`); + } else { + locators[locator] = func; + } +} + +export function canResolveLocator(locator) { + return locators.hasOwnProperty(locator); +} + +export function resolveLocator(locator, target, options) { + if (!locators[locator]) { + throw new Error(`The locator ${locator} is not registered with any plugin`); + } else { + const response = locators[locator](target, options); + + if (response instanceof Promise) { + return response.then((r) => (verifyXpath(locator, r))); + } + return verifyXpath(locator, response); + } +} diff --git a/packages/selenium-ide/src/plugin/manager.js b/packages/selenium-ide/src/plugin/manager.js index 351838841..2d272e799 100644 --- a/packages/selenium-ide/src/plugin/manager.js +++ b/packages/selenium-ide/src/plugin/manager.js @@ -15,9 +15,10 @@ // specific language governing permissions and limitations // under the License. -import { RegisterConfigurationHook, RegisterSuiteHook, RegisterTestHook, RegisterEmitter } from "selianize"; +import { RegisterConfigurationHook, RegisterSuiteHook, RegisterTestHook, RegisterCommandEmitter, RegisterLocationEmitter } from "selianize"; import { Commands } from "../neo/models/Command"; import { registerCommand } from "./commandExecutor"; +import { registerLocator } from "./locatorResolver"; import { sendMessage } from "./communication"; const TIMEOUT = 5000; @@ -34,6 +35,15 @@ function RunCommand(id, command, target, value, options) { }); } +function ResolveLocator(id, locator, target, options) { + return sendMessage(id, { + action: "resolve", + locator, + target, + options + }).then(res => (res.message)); +} + class PluginManager { constructor() { this.plugins = []; @@ -56,7 +66,13 @@ class PluginManager { plugin.commands.forEach(({ id, name, type }) => { Commands.addCommand(id, { name, type }); registerCommand(id, RunCommand.bind(undefined, plugin.id, id)); - RegisterEmitter(id, this.emitCommand.bind(undefined, plugin, id)); + RegisterCommandEmitter(id, this.emitCommand.bind(undefined, plugin, id)); + }); + } + if (plugin.locators) { + plugin.locators.forEach(({ id }) => { + registerLocator(id, ResolveLocator.bind(undefined, plugin.id, id)); + RegisterLocationEmitter(id, this.emitLocation.bind(undefined, plugin, id)); }); } } else { @@ -138,7 +154,7 @@ class PluginManager { } emitCommand(plugin, command, target, value) { - // no need to check emission as it is be unreachable, in case a project can't emit + // no need to check emission as it will be unreachable, in case a project can't emit return sendMessage(plugin.id, { action: "emit", entity: "command", @@ -150,6 +166,18 @@ class PluginManager { }).then(res => res.message); } + emitLocation(plugin, locator, target) { + // no need to check emission as it will be unreachable, in case a project can't emit + return sendMessage(plugin.id, { + action: "emit", + entity: "location", + command: { + locator, + target + } + }).then(res => res.message); + } + emitMessage(message, keepAliveCB) { return Promise.all(this.plugins.map(plugin => { let didReachTimeout = false; diff --git a/packages/selianize/__tests__/index.spec.js b/packages/selianize/__tests__/index.spec.js index 897aaafee..211351719 100644 --- a/packages/selianize/__tests__/index.spec.js +++ b/packages/selianize/__tests__/index.spec.js @@ -17,7 +17,7 @@ import fs from "fs"; import path from "path"; -import Selianize, { ParseError, RegisterConfigurationHook, RegisterSuiteHook, RegisterTestHook, RegisterEmitter } from "../src"; +import Selianize, { ParseError, RegisterConfigurationHook, RegisterSuiteHook, RegisterTestHook, RegisterCommandEmitter, RegisterLocationEmitter } from "../src"; describe("Selenium code serializer", () => { it("should export the code to javascript", () => { @@ -66,9 +66,16 @@ describe("Selenium code serializer", () => { const project = JSON.parse(fs.readFileSync(path.join(__dirname, "test-files", "project-4-new-command.side"))); const hook = jest.fn(); hook.mockReturnValue(Promise.resolve("some new command code")); - RegisterEmitter("newCommand", hook); + RegisterCommandEmitter("newCommand", hook); return expect((await Selianize(project))[0].code).toMatch(/some new command codeawait/); }); + it("should register a new location emitter", async () => { + const project = JSON.parse(fs.readFileSync(path.join(__dirname, "test-files", "project-5-new-locator.side"))); + const hook = jest.fn(); + hook.mockReturnValue(Promise.resolve("some new location code")); + RegisterLocationEmitter("newLocator", hook); + return expect((await Selianize(project))[0].code).toMatch(/elementLocated\(some new location code\)/); + }); it("should fail to export a project with errors", () => { const project = JSON.parse(fs.readFileSync(path.join(__dirname, "test-files", "project-2.side"))); const failure = { diff --git a/packages/selianize/__tests__/test-files/project-5-new-locator.side b/packages/selianize/__tests__/test-files/project-5-new-locator.side new file mode 100644 index 000000000..7ee724fc1 --- /dev/null +++ b/packages/selianize/__tests__/test-files/project-5-new-locator.side @@ -0,0 +1 @@ +{"id":"aeb17f04-8943-4b60-80bd-6441808a3e00","name":"Untitled Project","url":"https://en.wikipedia.org","tests":[{"id":"c65aa6ef-5f39-4fc8-b847-9687a7a7e10d","name":"aa playback","commands":[{"id":"df5478bd-e466-4272-b156-535c734922fd","command":"open","target":"/wiki/Legislation","value":""},{"id":"1dd7399c-6293-4644-973b-91b6c0bce12e","command":"click","target":"newLocator=a:contains(\"enacted\")","value":""},{"id":"0cb0a7ab-87b9-4f82-9867-c8728c171003","command":"clickAt","target":"link=parliamentary systems","value":""}]},{"id":"50000c36-a80a-4f13-a190-afc0c14dc56f","name":"aab playback","commands":[{"id":"895e8ac3-9ff7-41c5-9c95-d9d823d3fe4f","command":"open","target":"/wiki/River_Chater","value":""},{"id":"3dab5127-2761-4591-a1d9-b96275c1678b","command":"clickAt","target":"link=River Welland","value":""},{"id":"93f1bef3-7ff0-43c0-841d-e5e809415a23","command":"clickAt","target":"link=floods of 1947","value":""},{"id":"73dbc4ce-9ecd-44ba-9464-7fd9818fba9d","command":"clickAt","target":"link=scapegoat","value":""}]},{"id":"9b837fa2-1966-41fa-a125-63894fa77854","name":"aab type","commands":[{"id":"f426e920-96c5-44d6-9fd4-f5ce92355204","command":"open","target":"/wiki/Main_Page","value":""},{"id":"a7658315-4a79-4e47-8cbf-c7cb4d0cbc11","command":"type","target":"id=searchInput","value":"testtest"}]}],"suites":[{"id":"6d1a9cd9-b8f5-4782-b11c-da87764cec79","name":"aaa suite","tests":["c65aa6ef-5f39-4fc8-b847-9687a7a7e10d"]}],"urls":["https://en.wikipedia.org"]} diff --git a/packages/selianize/src/index.js b/packages/selianize/src/index.js index 6c2478b9e..c1e650fd6 100644 --- a/packages/selianize/src/index.js +++ b/packages/selianize/src/index.js @@ -58,10 +58,14 @@ export function RegisterTestHook(hook) { TestCaseEmitter.registerHook(hook); } -export function RegisterEmitter(command, emitter) { +export function RegisterCommandEmitter(command, emitter) { CommandEmitter.registerEmitter(command, emitter); } +export function RegisterLocationEmitter(location, emitter) { + LocationEmitter.registerEmitter(location, emitter); +} + export function ParseError(error) { return error.suites.map(suite => ( (`## ${suite.name}\n`).concat(suite.tests.map(test => ( diff --git a/packages/selianize/src/location.js b/packages/selianize/src/location.js index e77254af3..bfcd31733 100644 --- a/packages/selianize/src/location.js +++ b/packages/selianize/src/location.js @@ -42,8 +42,20 @@ export function emit(location) { }); } +export function canEmit(locatorName) { + return !!(emitters[locatorName]); +} + +export function registerEmitter(locator, emitter) { + if (!canEmit(locator)) { + emitters[locator] = emitter; + } +} + export default { - emit + canEmit, + emit, + registerEmitter }; function emitId(selector) {