From 60359192c275baeabd22ef0a9fb5ceaf44047c5b Mon Sep 17 00:00:00 2001 From: toddtarsi Date: Mon, 18 Sep 2023 15:38:22 -0500 Subject: [PATCH] im just doin this --- package.json | 2 +- .../session/controllers/Recorder/index.ts | 2 +- packages/side-api/src/types/base.ts | 5 +- packages/side-executor-runtime/BUILD.bazel | 33 + packages/side-executor-runtime/jest.config.js | 16 + packages/side-executor-runtime/package.json | 37 + .../src/errors/assertion.ts | 26 + .../side-executor-runtime/src/errors/index.ts | 19 + .../src/errors/verification.ts | 26 + packages/side-executor-runtime/src/index.ts | 21 + .../src/preprocessors.ts | 190 ++ packages/side-executor-runtime/src/types.ts | 63 + packages/side-executor-runtime/src/utils.ts | 20 + .../side-executor-runtime/src/variables.ts | 49 + .../side-executor-runtime/src/webdriver.ts | 1842 +++++++++++++++++ packages/side-executor-runtime/tsconfig.json | 25 + packages/side-executor/BUILD.bazel | 33 + packages/side-executor/jest.config.js | 16 + packages/side-executor/package.json | 37 + packages/side-executor/src/callstack.ts | 56 + .../side-executor/src/errors/assertion.ts | 26 + packages/side-executor/src/errors/index.ts | 19 + .../side-executor/src/errors/verification.ts | 26 + packages/side-executor/src/executor.ts | 156 ++ packages/side-executor/src/index.ts | 20 + .../src/playback-tree/command-leveler.ts | 87 + .../src/playback-tree/command-node.ts | 290 +++ .../src/playback-tree/commands.ts | 183 ++ .../control-flow-syntax-error.ts | 24 + .../side-executor/src/playback-tree/index.ts | 257 +++ .../side-executor/src/playback-tree/state.ts | 38 + .../src/playback-tree/syntax-validation.ts | 139 ++ packages/side-executor/src/playback.ts | 319 +++ packages/side-executor/src/types.ts | 54 + packages/side-executor/src/utils.ts | 20 + packages/side-executor/src/variables.ts | 53 + packages/side-executor/tsconfig.json | 25 + 37 files changed, 4250 insertions(+), 4 deletions(-) create mode 100644 packages/side-executor-runtime/BUILD.bazel create mode 100644 packages/side-executor-runtime/jest.config.js create mode 100644 packages/side-executor-runtime/package.json create mode 100644 packages/side-executor-runtime/src/errors/assertion.ts create mode 100644 packages/side-executor-runtime/src/errors/index.ts create mode 100644 packages/side-executor-runtime/src/errors/verification.ts create mode 100644 packages/side-executor-runtime/src/index.ts create mode 100644 packages/side-executor-runtime/src/preprocessors.ts create mode 100644 packages/side-executor-runtime/src/types.ts create mode 100644 packages/side-executor-runtime/src/utils.ts create mode 100644 packages/side-executor-runtime/src/variables.ts create mode 100644 packages/side-executor-runtime/src/webdriver.ts create mode 100644 packages/side-executor-runtime/tsconfig.json create mode 100644 packages/side-executor/BUILD.bazel create mode 100644 packages/side-executor/jest.config.js create mode 100644 packages/side-executor/package.json create mode 100644 packages/side-executor/src/callstack.ts create mode 100644 packages/side-executor/src/errors/assertion.ts create mode 100644 packages/side-executor/src/errors/index.ts create mode 100644 packages/side-executor/src/errors/verification.ts create mode 100644 packages/side-executor/src/executor.ts create mode 100644 packages/side-executor/src/index.ts create mode 100644 packages/side-executor/src/playback-tree/command-leveler.ts create mode 100644 packages/side-executor/src/playback-tree/command-node.ts create mode 100644 packages/side-executor/src/playback-tree/commands.ts create mode 100644 packages/side-executor/src/playback-tree/control-flow-syntax-error.ts create mode 100644 packages/side-executor/src/playback-tree/index.ts create mode 100644 packages/side-executor/src/playback-tree/state.ts create mode 100644 packages/side-executor/src/playback-tree/syntax-validation.ts create mode 100644 packages/side-executor/src/playback.ts create mode 100644 packages/side-executor/src/types.ts create mode 100644 packages/side-executor/src/utils.ts create mode 100644 packages/side-executor/src/variables.ts create mode 100644 packages/side-executor/tsconfig.json diff --git a/package.json b/package.json index c237e1f98..263e5ae61 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint": "pnpm run lint:scripts", "lint:scripts": "eslint --ignore-pattern node_modules --ignore-pattern third-party --ignore-pattern dist --ignore-pattern build --ignore-pattern json --ext .ts,.tsx --ext .js packages/", "test:ide": "npm-run-bg -s 'http-server -p 8080 ./tests/static::Available on::8080' 'node ./packages/selenium-ide/scripts/ide-runner.js -t 15000 ./tests/examples/*.side'", - "test:side-runner": "npm-run-bg -s 'http-server -p 8080 ./tests/static::Available on::8080' 'node ./packages/side-runner/dist/bin.js -t 15000 ./tests/examples/*.side'", + "test:side-runner": "npm-run-bg -s 'http-server -p 8080 ./tests/static::Available on::8080' 'node ./packages/side-runner/dist/bin.js -c \"browserName=firefox\" -t 15000 ./tests/examples/*.side'", "test:side-runner:ci": "npm-run-bg -s 'http-server -p 8080 ./tests/static::Available on::8080' 'node ./packages/side-runner/dist/bin.js -c \"goog:chromeOptions.args=[headless,no-sandbox] browserName=chrome\" -t 15000 ./tests/examples/*.side'", "typecheck": "tsc --noEmit --composite false", "watch": "run-p watch:ts watch:webpack", diff --git a/packages/selenium-ide/src/main/session/controllers/Recorder/index.ts b/packages/selenium-ide/src/main/session/controllers/Recorder/index.ts index 3cb613d70..7be08134a 100644 --- a/packages/selenium-ide/src/main/session/controllers/Recorder/index.ts +++ b/packages/selenium-ide/src/main/session/controllers/Recorder/index.ts @@ -71,7 +71,7 @@ export default class RecorderController extends BaseController { if (session.state.status !== 'recording') { return null } - const commands = [] + const commands: CommandShape[] = [] if ( getLastActiveWindowHandleId(session) != cmd.winHandleId ) { diff --git a/packages/side-api/src/types/base.ts b/packages/side-api/src/types/base.ts index fc7f774d4..4e4fba1b3 100644 --- a/packages/side-api/src/types/base.ts +++ b/packages/side-api/src/types/base.ts @@ -1,4 +1,4 @@ -import { ProjectShape } from '@seleniumhq/side-model' +import { CommandShape, ProjectShape } from '@seleniumhq/side-model' import { Chrome } from '@seleniumhq/browser-info' import { Browser } from '@seleniumhq/get-driver' import { StateShape } from '../models/state' @@ -78,7 +78,8 @@ export type EventListenerParams> = export type LocatorFields = 'target' | 'value' -export interface RecordNewCommandInput { +export interface RecordNewCommandInput + extends Omit { command: string target: string | [string, string][] value: string | [string, string][] diff --git a/packages/side-executor-runtime/BUILD.bazel b/packages/side-executor-runtime/BUILD.bazel new file mode 100644 index 000000000..90a94c954 --- /dev/null +++ b/packages/side-executor-runtime/BUILD.bazel @@ -0,0 +1,33 @@ +load("@npm//@babel/cli:index.bzl", "babel") +load("@npm//jest-cli:index.bzl", "jest_test") + +babel( + name = "build", + args = [ + '--root-mode upward -d dist src --extensions ".js,.jsx,.ts,.tsx" --source-maps true' + ] +) + +filegroup( + name = "test_lib", + srcs = glob([ + "**/*.js" + ]), +) + +jest_test( + name = "test", + args = [ + "--no-cache", + "--no-watchman", + "--ci", + "--colors", + "--config", + "babel.config.js", + "--updateSnapshot" + ], + data = [ + ":test_lib", + "//:babel.config.js" + ] +) diff --git a/packages/side-executor-runtime/jest.config.js b/packages/side-executor-runtime/jest.config.js new file mode 100644 index 000000000..660f3fac0 --- /dev/null +++ b/packages/side-executor-runtime/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + testEnvironment: 'jsdom', + testEnvironmentOptions: { + url: 'http://localhost/index.html', + }, + moduleNameMapper: { + '^.+\\.(css|scss)$': 'identity-obj-proxy', + }, + setupFilesAfterEnv: ['./scripts/jest/test.config.js'], + testMatch: ['**/packages/**/__test?(s)__/**/*.spec.[jt]s?(x)'], + testPathIgnorePatterns: ['/node_modules/'], + transform: { + '^.+\\.jsx?$': 'babel-jest', + '^.+\\.tsx?$': 'ts-jest', + }, +} diff --git a/packages/side-executor-runtime/package.json b/packages/side-executor-runtime/package.json new file mode 100644 index 000000000..860df38a9 --- /dev/null +++ b/packages/side-executor-runtime/package.json @@ -0,0 +1,37 @@ +{ + "name": "@seleniumhq/side-executor-runtime", + "version": "4.0.0-alpha.1", + "private": false, + "description": "Selenium IDE playback and execution", + "author": "Tomer ", + "homepage": "http://github.com/SeleniumHQ/selenium-ide", + "license": "Apache-2.0", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist tsconfig.tsbuildinfo node_modules", + "watch": "tsc --watch" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/SeleniumHQ/selenium-ide.git" + }, + "bugs": { + "url": "https://github.com/SeleniumHQ/selenium-ide/issues" + }, + "devDependencies": { + "@seleniumhq/side-testkit": "^4.0.0-alpha.1", + "@seleniumhq/webdriver-testkit": "^4.0.0-alpha.1" + }, + "dependencies": { + "@seleniumhq/side-commons": "^4.0.0-alpha.1", + "@seleniumhq/side-model": "^4.0.0-alpha.4", + "@types/selenium-webdriver": "^4.1.15", + "selenium-webdriver": "^4.11.1" + }, + "gitHead": "507c7c802f34196e6ee4800bf5c0b36553d41369" +} diff --git a/packages/side-executor-runtime/src/errors/assertion.ts b/packages/side-executor-runtime/src/errors/assertion.ts new file mode 100644 index 000000000..b06b9834a --- /dev/null +++ b/packages/side-executor-runtime/src/errors/assertion.ts @@ -0,0 +1,26 @@ +// 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. + +export default class AssertionError extends Error { + constructor(...argv: string[]) { + super(argv.join(' ')) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AssertionError) + } + } +} diff --git a/packages/side-executor-runtime/src/errors/index.ts b/packages/side-executor-runtime/src/errors/index.ts new file mode 100644 index 000000000..65637e7a3 --- /dev/null +++ b/packages/side-executor-runtime/src/errors/index.ts @@ -0,0 +1,19 @@ +// 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. + +export { default as AssertionError } from './assertion' +export { default as VerificationError } from './verification' diff --git a/packages/side-executor-runtime/src/errors/verification.ts b/packages/side-executor-runtime/src/errors/verification.ts new file mode 100644 index 000000000..0459b7291 --- /dev/null +++ b/packages/side-executor-runtime/src/errors/verification.ts @@ -0,0 +1,26 @@ +// 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. + +export default class VerificationError extends Error { + constructor(...argv: string[]) { + super(argv.join(' ')) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, VerificationError) + } + } +} diff --git a/packages/side-executor-runtime/src/index.ts b/packages/side-executor-runtime/src/index.ts new file mode 100644 index 000000000..2ca394bab --- /dev/null +++ b/packages/side-executor-runtime/src/index.ts @@ -0,0 +1,21 @@ +// 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. + +export { default as Variables } from './variables' +export { default as WebDriverExecutor } from './webdriver' + +export * from './types' diff --git a/packages/side-executor-runtime/src/preprocessors.ts b/packages/side-executor-runtime/src/preprocessors.ts new file mode 100644 index 000000000..e885e1706 --- /dev/null +++ b/packages/side-executor-runtime/src/preprocessors.ts @@ -0,0 +1,190 @@ +// 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 { Fn } from '@seleniumhq/side-commons' +import Variables from './variables' + +const nbsp = String.fromCharCode(160) + +// this function is meant to be composed on the prototype of the executor +// refer to preprocessor.spec.js for an example on how to do so +// this will define this to be in scope allowing the executor function to +// have this in scope as well as grant the preprocessor access to the variables +export function composePreprocessors(...args: any[]) { + const func = args[args.length - 1] + const params = args.slice(0, args.length - 1) + if (params.length === 0) { + return func + } else if (params.length === 1) { + return function preprocess(target: any) { + // @ts-expect-error + return func.call(this, runPreprocessor(params[0], target, this.variables)) + } + } else if (params.length === 2) { + return function preprocess(target: any, value: any) { + return func.call( + // @ts-expect-error + this, + // @ts-expect-error + runPreprocessor(params[0], target, this.variables), + // @ts-expect-error + runPreprocessor(params[1], value, this.variables) + ) + } + } else { + return function preprocess(target: any, value: any, options: any) { + if (!options) { + return func.call( + // @ts-expect-error + this, + // @ts-expect-error + runPreprocessor(params[0], target, this.variables), + // @ts-expect-error + runPreprocessor(params[1], value, this.variables) + ) + } + return func.call( + // @ts-expect-error + this, + // @ts-expect-error + runPreprocessor(params[0], target, this.variables), + // @ts-expect-error + runPreprocessor(params[1], value, this.variables), + // @ts-expect-error + preprocessObject(params[2], options, this.variables) + ) + } + } +} + +function runPreprocessor(preprocessor: Fn, value: any, ...args: any[]) { + if (typeof preprocessor === 'function') { + return preprocessor(value, ...args) + } + return value +} + +function preprocessObject( + preprocessors: Record, + obj: Record, + ...args: any[] +) { + const result = { ...obj } + + Object.keys(preprocessors).forEach((prop) => { + if (result[prop]) { + result[prop] = runPreprocessor(preprocessors[prop], result[prop], ...args) + } + }) + + return result +} + +export type Interpolator = (value: string, variables: Variables) => string + +export function preprocessArray(interpolator: Interpolator) { + return function preprocessArray(items: string[], variables: Variables) { + return items.map((item) => [interpolator(item[0], variables), item[1]]) + } +} + +export function interpolateString(value: string, variables: Variables) { + value = value.replace(/^\s+/, '') + value = value.replace(/\s+$/, '') + let r2 + let parts = [] + if (/\$\{/.exec(value)) { + const regexp = /\$\{(.*?)\}/g + let lastIndex = 0 + while ((r2 = regexp.exec(value))) { + if (variables.has(r2[1])) { + if (r2.index - lastIndex > 0) { + parts.push(string(value.substring(lastIndex, r2.index))) + } + parts.push(variables.get(r2[1])) + lastIndex = regexp.lastIndex + } else if (r2[1] == 'nbsp') { + if (r2.index - lastIndex > 0) { + parts.push( + variables.get(string(value.substring(lastIndex, r2.index))) + ) + } + parts.push(nbsp) + lastIndex = regexp.lastIndex + } + } + if (lastIndex < value.length) { + parts.push(string(value.substring(lastIndex, value.length))) + } + return parts.map(String).join('') + } else { + return string(value) + } +} + +export function interpolateScript(script: string, variables: Variables) { + let value = script.replace(/^\s+/, '').replace(/\s+$/, '') + let r2 + let parts = [] + const variablesUsed: Record = {} + const argv: any[] = [] + let argl = 0 // length of arguments + if (/\$\{/.exec(value)) { + const regexp = /\$\{(.*?)\}/g + let lastIndex = 0 + while ((r2 = regexp.exec(value))) { + const variableName = r2[1] + if (variables.has(variableName)) { + if (r2.index - lastIndex > 0) { + parts.push(string(value.substring(lastIndex, r2.index))) + } + if ( + !Object.prototype.hasOwnProperty.call(variablesUsed, variableName) + ) { + variablesUsed[variableName] = argl + argv.push(variables.get(variableName)) + argl++ + } + parts.push(`arguments[${variablesUsed[variableName]}]`) + lastIndex = regexp.lastIndex + } + } + if (lastIndex < value.length) { + parts.push(string(value.substring(lastIndex, value.length))) + } + return { + script: parts.join(''), + argv, + } + } else { + return { + script: string(value), + argv, + } + } +} + +function string(value: string | null) { + if (value != null) { + value = value.replace(/\\\\/g, '\\') + value = value.replace(/\\r/g, '\r') + value = value.replace(/\\n/g, '\n') + return value + } else { + return '' + } +} diff --git a/packages/side-executor-runtime/src/types.ts b/packages/side-executor-runtime/src/types.ts new file mode 100644 index 000000000..8f90b0705 --- /dev/null +++ b/packages/side-executor-runtime/src/types.ts @@ -0,0 +1,63 @@ +import { CommandShape, ProjectShape } from '@seleniumhq/side-model' +import { CommandType } from '@seleniumhq/side-model/src/Commands' +import WebDriverExecutor, { WebDriverExecutorHooks } from './webdriver' + +export { Capabilities } from 'selenium-webdriver' + +/** + * Modified command shape with additional execute function + */ +export interface CustomCommandShape extends CommandType { + execute: (command: CommandShape, driver: WebDriverExecutor) => Promise +} + +export interface CommandHookInput { + command: CommandShape +} + +export interface StoreWindowHandleHookInput { + windowHandle: string + windowHandleName: string +} + +export interface WindowAppearedHookInput { + command: CommandShape + windowHandleName: CommandShape['windowHandleName'] + windowHandle: string +} + +export interface WindowSwitchedHookInput { + windowHandle: string +} + +export type PluginHookInput = { + logger: Console + project: ProjectShape +} & Record + +export interface PluginHooks extends WebDriverExecutorHooks { + onBeforePlayAll?: (input: PluginHookInput) => Promise + onAfterPlayAll?: (input: PluginHookInput) => Promise + onMessage?: (...args: any[]) => void +} + +export interface FormatShape { + opts?: { + fileExtension?: string + commandPrefixPadding?: string + terminatingKeyword?: string + commentPrefix?: string + } +} + +/** + * A plugin is a javascript module that can be loaded into the Side Runner. + * It can be used to extend the functionality of the Side Runner by adding new + * commands, formats, or hooks. + */ + +export interface PluginRuntimeShape { + commands?: Record + formats?: FormatShape[] + hooks?: PluginHooks +} diff --git a/packages/side-executor-runtime/src/utils.ts b/packages/side-executor-runtime/src/utils.ts new file mode 100644 index 000000000..4a14351b5 --- /dev/null +++ b/packages/side-executor-runtime/src/utils.ts @@ -0,0 +1,20 @@ +// 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. + +export function absolutifyUrl(targetUrl: string, baseUrl: string): string { + return new URL(targetUrl, baseUrl).href +} diff --git a/packages/side-executor-runtime/src/variables.ts b/packages/side-executor-runtime/src/variables.ts new file mode 100644 index 000000000..9080a3da2 --- /dev/null +++ b/packages/side-executor-runtime/src/variables.ts @@ -0,0 +1,49 @@ +// 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. + +export default class Variables { + constructor() { + this.storedVars = new Map() + } + storedVars: Map + + get(key: string) { + if (key.startsWith('env:')) { + return process.env[key.slice(4)] + } + return this.storedVars.get(key) + } + + set(key: string, value: any) { + this.storedVars.set(key, value) + } + + has(key: string) { + if (key.startsWith('env:')) { + return true + } + return this.storedVars.has(key) + } + + delete(key: string) { + if (this.storedVars.has(key)) this.storedVars.delete(key) + } + + clear() { + this.storedVars.clear() + } +} diff --git a/packages/side-executor-runtime/src/webdriver.ts b/packages/side-executor-runtime/src/webdriver.ts new file mode 100644 index 000000000..9f5874f87 --- /dev/null +++ b/packages/side-executor-runtime/src/webdriver.ts @@ -0,0 +1,1842 @@ +// 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 webdriver, { + Capabilities, + WebDriver, + WebElement as WebElementShape, +} from 'selenium-webdriver' +import { absolutifyUrl } from './utils' +import { + composePreprocessors, + preprocessArray, + interpolateString, + interpolateScript, +} from './preprocessors' +import { AssertionError, VerificationError } from './errors' +import Variables from './variables' +import { Fn } from '@seleniumhq/side-commons' +import { CommandShape } from '@seleniumhq/side-model' +import { PluginRuntimeShape } from './types' + +const { By, Condition, until, Key, WebElementCondition } = webdriver + +export type ExpandedCapabilities = Partial & { + browserName: string + 'goog:chromeOptions'?: Record +} +const DEFAULT_CAPABILITIES: ExpandedCapabilities = { + browserName: 'chrome', +} + +const state = Symbol('state') + +/** + * This is a polyfill type to allow for unsupported electron + * driver methods to override with their own custom implementations + */ +export interface WindowAPI { + setWindowSize: ( + executor: WebDriverExecutor, + width: number, + height: number + ) => Promise +} + +export interface WebDriverExecutorConstructorArgs { + capabilities?: ExpandedCapabilities + customCommands?: PluginRuntimeShape['commands'] + disableCodeExportCompat?: boolean + driver?: WebDriver + hooks?: WebDriverExecutorHooks + implicitWait?: number + server?: string + windowAPI?: WindowAPI +} + +export interface WebDriverExecutorInitOptions { + baseUrl: string + logger: Console + variables: Variables +} + +export interface WebDriverExecutorCondEvalResult { + value: boolean +} + +export interface BeforePlayHookInput { + driver: WebDriverExecutor +} + +export interface CommandHookInput { + command: CommandShape +} + +export interface StoreWindowHandleHookInput { + windowHandle: string + windowHandleName: string +} + +export interface WindowAppearedHookInput { + command: CommandShape + windowHandleName: CommandShape['windowHandleName'] + windowHandle?: string | Error +} + +export interface WindowSwitchedHookInput { + windowHandle?: string | Error +} + +export type GeneralHook = (input: T) => Promise | void + +export interface WebDriverExecutorHooks { + onBeforePlay?: GeneralHook + onAfterCommand?: GeneralHook + onBeforeCommand?: GeneralHook + onStoreWindowHandle?: GeneralHook + onWindowAppeared?: GeneralHook + onWindowSwitched?: GeneralHook +} + +export type HookKeys = keyof WebDriverExecutorHooks + +export interface ElementEditableScriptResult { + enabled: boolean + readonly: boolean +} + +export interface ScriptShape { + script: string + argv: any[] +} + +const defaultWindowAPI: WindowAPI = { + setWindowSize: async (executor: WebDriverExecutor, width, height) => { + await executor.driver.manage().window().setRect({ width, height }) + }, +} + +export default class WebDriverExecutor { + constructor({ + customCommands = {}, + disableCodeExportCompat = false, + driver, + capabilities, + server, + hooks = {}, + implicitWait, + windowAPI = defaultWindowAPI, + }: WebDriverExecutorConstructorArgs) { + if (driver) { + this.driver = driver + } else { + this.capabilities = capabilities || DEFAULT_CAPABILITIES + this.server = server + } + this.disableCodeExportCompat = disableCodeExportCompat + this.initialized = false + this.implicitWait = implicitWait || 5 * 1000 + this.hooks = hooks + this.waitForNewWindow = this.waitForNewWindow.bind(this) + this.customCommands = customCommands + this.windowAPI = windowAPI + } + baseUrl?: string + // @ts-expect-error + variables: Variables + cancellable?: { cancel: () => void } + capabilities?: ExpandedCapabilities + customCommands: Required['commands'] + disableCodeExportCompat: boolean + // @ts-expect-error + driver: WebDriver + server?: string + windowAPI: WindowAPI + windowHandle?: string + hooks: WebDriverExecutorHooks + implicitWait: number + initialized: boolean + logger?: Console; + [state]?: any + + async init({ baseUrl, logger, variables }: WebDriverExecutorInitOptions) { + this.baseUrl = baseUrl + this.logger = logger + this.variables = variables + this[state] = {} + + if (!this.driver) { + const { browserName, ...capabilities } = this + .capabilities as ExpandedCapabilities + this.logger.info('Building driver for ' + browserName) + this.driver = await new webdriver.Builder() + .withCapabilities(capabilities) + .usingServer(this.server as string) + .forBrowser(browserName) + .build() + this.logger.info('Driver has been built for ' + browserName) + } + this.initialized = true + } + + async cancel() { + if (this.cancellable) { + await this.cancellable.cancel() + } + } + + async cleanup() { + if (this.initialized) { + await this.driver.quit() + // @ts-expect-error + this.driver = undefined + this.initialized = false + } + } + + isAlive() { + // webdriver will throw for us, but not necessarily for all commands + // TODO: check if there are commands that will succeed if we always return true + return true + } + + name(command: string) { + if (!command) { + return 'skip' + } + + const upperCase = command.charAt(0).toUpperCase() + command.slice(1) + const func = 'do' + upperCase + // @ts-expect-error The functions can be overridden by custom commands and stuff + if (!this[func]) { + if (this.customCommands[command]) { + return command + } + throw new Error(`Unknown command ${command}`) + } + return func + } + + async executeHook( + hook: T, + ...args: Parameters> + ) { + type HookContents = WebDriverExecutorHooks[T] + type HookParameters = Parameters> + const fn = this.hooks[hook] as HookContents + if (!fn) return + // @ts-expect-error it's okay, this shape is fine + await fn.apply(this, args as HookParameters) + } + + async beforeCommand(commandObject: CommandShape) { + if (commandObject.opensWindow) { + this[state].openedWindows = await this.driver.getAllWindowHandles() + } + await this.executeHook('onBeforeCommand', { command: commandObject }) + } + + async afterCommand(commandObject: CommandShape) { + this.cancellable = undefined + if (commandObject.opensWindow) { + const handle = await this.waitForNewWindow(commandObject.windowTimeout) + this.variables.set(commandObject.windowHandleName as string, handle) + + await this.executeHook('onWindowAppeared', { + command: commandObject, + windowHandleName: commandObject.windowHandleName, + windowHandle: handle, + }) + } + + await this.executeHook('onAfterCommand', { command: commandObject }) + } + + async waitForNewWindow(timeout: number = 2000) { + const finder = new Promise((resolve) => { + const start = Date.now() + const findHandle = async () => { + if (Date.now() - start > timeout) { + resolve(undefined) + return + } + + const currentHandles = await this.driver.getAllWindowHandles() + const newHandle = currentHandles.find( + (handle) => !this[state].openedWindows.includes(handle) + ) + if (newHandle) { + resolve(newHandle) + return + } + // cant find, wait next time. + setTimeout(findHandle, 200) + } + + findHandle() + }) + + return this.driver.wait(finder, timeout) + } + + registerCommand(commandName: string, fn: Fn) { + // @ts-expect-error + this['do' + commandName.charAt(0).toUpperCase() + commandName.slice(1)] = fn + } + + // Commands go after this line + async skip() { + return Promise.resolve() + } + + // window commands + async doOpen(url: string) { + await this.driver.get(absolutifyUrl(url, this.baseUrl as string)) + } + + async doSetWindowSize(widthXheight: string) { + const [width, height] = widthXheight.split('x').map((v) => parseInt(v)) + await this.windowAPI.setWindowSize(this, width, height) + } + + async doSelectWindow(handleLocator: string) { + const prefix = 'handle=' + if (handleLocator.startsWith(prefix)) { + const handle = handleLocator.substr(prefix.length) + await this.driver.switchTo().window(handle) + await this.executeHook('onWindowSwitched', { + windowHandle: handle, + }) + } else { + throw new Error( + 'Invalid window handle given (e.g. handle=${handleVariable})' + ) + } + } + + async doClose() { + await this.driver.close() + } + + async doSelectFrame(locator: string) { + // It's possible that for the browser and webdriver to index frames differently. + // We can ask the browser for the URL of the underlying original index and use that in + // webdriver to ensure we get the proper match. + + const targetLocator = this.driver.switchTo() + if (locator === 'relative=top') { + await targetLocator.defaultContent() + } else if (locator === 'relative=parent') { + await targetLocator.parentFrame() + } else if (locator.startsWith('index=')) { + const frameIndex = locator.substring('index='.length) + const frameTargets = frameIndex.split('\\') + for (let frameTarget of frameTargets) { + if (frameTarget === '..') await targetLocator.parentFrame() + else { + if (this.disableCodeExportCompat) { + const frameIndex = locator.substring('index='.length) + // Delay for a second. Check too fast, and browser will think this iframe location is 'about:blank' + await new Promise((f) => setTimeout(f, 1000)) + const frameUrl = await this.driver.executeScript( + "return window.frames['" + frameIndex + "'].location.href" + ) + const windowFrames = await this.driver.findElements( + By.css('iframe') + ) + let matchIndex = 0 + for (let frame of windowFrames) { + let localFrameUrl = await frame.getAttribute('src') + if (localFrameUrl === frameUrl) { + break + } + matchIndex++ + } + this.driver.switchTo().frame(matchIndex) + } else { + await targetLocator.frame(Number(frameTarget)) + } + } + } + } else { + const element = await this.waitForElement(locator) + await targetLocator.frame(element) + } + } + + async doSubmit() { + throw new Error( + '"submit" is not a supported command in Selenium WebDriver. Please re-record the step.' + ) + } + + // mouse commands + + async doAddSelection( + locator: string, + optionLocator: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const option = await element.findElement(parseOptionLocator(optionLocator)) + const selections = (await this.driver.executeScript( + 'return arguments[0].selectedOptions', + element + )) as WebElementShape[] + if (!(await findElement(selections, option))) { + await option.click() + } + } + + async doRemoveSelection( + locator: string, + optionLocator: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + + if (!(await element.getAttribute('multiple'))) { + throw new Error('Given element is not a multiple select type element') + } + + const option = await element.findElement(parseOptionLocator(optionLocator)) + const selections = (await this.driver.executeScript( + 'return arguments[0].selectedOptions', + element + )) as WebElementShape[] + if (await findElement(selections, option)) { + await option.click() + } + } + + async doCheck( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + if (!(await element.isSelected())) { + await element.click() + } + } + + async doUncheck( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + if (await element.isSelected()) { + await element.click() + } + } + + async doClick(locator: string, _: string) { + const element = await this.waitForElementVisible(locator, this.implicitWait) + await element.click() + } + + async doClickAt( + locator: string, + coordString: string, + commandObject: Partial = {} + ) { + const coords = parseCoordString(coordString) + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element, ...coords }) + .click() + .perform() + } + + async doDoubleClick( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver.actions({ bridge: true }).doubleClick(element).perform() + } + + async doDoubleClickAt( + locator: string, + coordString: string, + commandObject: Partial = {} + ) { + const coords = parseCoordString(coordString) + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element, ...coords }) + .doubleClick() + .perform() + } + + async doDragAndDropToObject( + dragLocator: string, + dropLocator: string, + commandObject: Partial = {} + ) { + const drag = await this.waitForElement( + dragLocator, + commandObject.targetFallback + ) + const drop = await this.waitForElement( + dropLocator, + commandObject.valueFallback + ) + await this.driver + .actions({ bridge: true }) + .dragAndDrop(drag, drop) + .perform() + } + + async doMouseDown( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element }) + .press() + .perform() + } + + async doMouseDownAt( + locator: string, + coordString: string, + commandObject: Partial = {} + ) { + const coords = parseCoordString(coordString) + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element, ...coords }) + .press() + .perform() + } + + async doMouseMoveAt( + locator: string, + coordString: string, + commandObject: Partial = {} + ) { + const coords = parseCoordString(coordString) + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element, ...coords }) + .perform() + } + + async doMouseOut( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const [rect, vp]: [DOMRect, { height: number; width: number }] = + await this.driver.executeScript( + 'return [arguments[0].getBoundingClientRect(), {height: window.innerHeight, width: window.innerWidth}];', + element + ) + + // try top + if (rect.top > 0) { + const y = -(rect.height / 2 + 1) + return await this.driver + .actions({ bridge: true }) + .move({ origin: element, y }) + .perform() + } + // try right + else if (vp.width > rect.right) { + const x = rect.right / 2 + 1 + return await this.driver + .actions({ bridge: true }) + .move({ origin: element, x }) + .perform() + } + // try bottom + else if (vp.height > rect.bottom) { + const y = rect.height / 2 + 1 + return await this.driver + .actions({ bridge: true }) + .move({ origin: element, y }) + .perform() + } + // try left + else if (rect.left > 0) { + const x = -rect.right / 2 + return await this.driver + .actions({ bridge: true }) + .move({ origin: element, x }) + .perform() + } + + throw new Error( + 'Unable to perform mouse out as the element takes up the entire viewport' + ) + } + + async doMouseOver( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element }) + .perform() + } + + async doMouseUp( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element }) + .release() + .perform() + } + + async doMouseUpAt( + locator: string, + coordString: string, + commandObject: Partial = {} + ) { + const coords = parseCoordString(coordString) + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver + .actions({ bridge: true }) + .move({ origin: element, ...coords }) + .release() + .perform() + } + + async doSelect( + locator: string, + optionLocator: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const option = await element.findElement(parseOptionLocator(optionLocator)) + await option.click() + } + + // keyboard commands + + async doEditContent( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await this.driver.executeScript( + "if(arguments[0].contentEditable === 'true') {arguments[0].innerText = arguments[1]} else {throw new Error('Element is not content editable')}", + element, + value + ) + } + + async doType( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await element.clear() + await element.sendKeys(value) + } + + async doSendKeys( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + await element.sendKeys(...value) + } + + // wait commands + + async doWaitForElementEditable(locator: string, timeout: string) { + const element = await this.driver.findElement(parseLocator(locator)) + await this.driver.wait( + this.isElementEditable(element), + parseInt(timeout), + 'Timed out waiting for element to be editable' + ) + } + + async doWaitForElementNotEditable(locator: string, timeout: string) { + const element = await this.driver.findElement(parseLocator(locator)) + await this.driver.wait( + this.isElementEditable(element), + parseInt(timeout), + 'Timed out waiting for element to not be editable' + ) + } + + async doWaitForElementPresent( + locator: string, + timeout: string, + commandObj: Partial = {} + ) { + await this.driver.wait( + this.elementIsLocated(locator, commandObj.targetFallback), + parseInt(timeout) + ) + } + + async doWaitForElementNotPresent(locator: string, timeout: string) { + const parsedLocator = parseLocator(locator) + const elements = await this.driver.findElements(parsedLocator) + if (elements.length !== 0) { + const noElementPresentCondition = new Condition( + 'for element to not be present', + async () => { + const elements = await this.driver.findElements(parsedLocator) + return elements.length === 0 + } + ) + await this.driver.wait( + noElementPresentCondition, + Number(timeout) + ) + } + } + + async doWaitForElementVisible( + locator: string, + timeout: string, + commandObj: Partial = {} + ) { + await this.waitForElementVisible( + locator, + parseInt(timeout), + commandObj.targetFallback + ) + } + + async doWaitForElementNotVisible(locator: string, timeout: string) { + const parsedLocator = parseLocator(locator) + const elements = await this.driver.findElements(parsedLocator) + + if (elements.length > 0) { + await this.driver.wait( + until.elementIsNotVisible(elements[0]), + parseInt(timeout) + ) + } + } + + async doWaitForText( + locator: string, + text: string, + commandObj: Partial = {} + ) { + await this.waitForText(locator, text, commandObj.targetFallback) + } + + // script commands + + async doRunScript(script: ScriptShape) { + await this.driver.executeScript(script.script, ...script.argv) + } + + async doExecuteScript(script: ScriptShape, optionalVariable?: string) { + const result = await this.driver.executeScript( + script.script, + ...script.argv + ) + if (optionalVariable) { + this.variables.set(optionalVariable, result) + } + } + + async doExecuteAsyncScript(script: ScriptShape, optionalVariable?: string) { + const result = await this.driver.executeAsyncScript( + `var callback = arguments[arguments.length - 1];${script.script}.then(callback).catch(callback);`, + ...script.argv + ) + if (optionalVariable) { + this.variables.set(optionalVariable, result) + } + } + + // alert commands + + async doAcceptAlert() { + await this.driver.switchTo().alert().accept() + } + + async doAcceptConfirmation() { + await this.driver.switchTo().alert().accept() + } + + async doAnswerPrompt(optAnswer?: string) { + const alert = await this.driver.switchTo().alert() + if (optAnswer) { + await alert.sendKeys(optAnswer) + } + await alert.accept() + } + + async doDismissConfirmation() { + await this.driver.switchTo().alert().dismiss() + } + + async doDismissPrompt() { + await this.driver.switchTo().alert().dismiss() + } + + // store commands + + async doStore(string: string, variable: string) { + this.variables.set(variable, string) + return Promise.resolve() + } + + async doStoreAttribute(attributeLocator: string, variable: string) { + const attributePos = attributeLocator.lastIndexOf('@') + const elementLocator = attributeLocator.slice(0, attributePos) + const attributeName = attributeLocator.slice(attributePos + 1) + + const element = await this.waitForElement(elementLocator) + const value = await element.getAttribute(attributeName) + this.variables.set(variable, value) + } + + async doStoreElementCount(locator: string, variable: string) { + const elements = await this.driver.findElements(parseLocator(locator)) + this.variables.set(variable, elements.length) + } + + async doStoreJson(json: string, variable: string) { + this.variables.set(variable, JSON.parse(json)) + return Promise.resolve() + } + + async doStoreText( + locator: string, + variable: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const text = await element.getText() + this.variables.set(variable, text) + } + + async doStoreTitle(variable: string) { + const title = await this.driver.getTitle() + this.variables.set(variable, title) + } + + async doStoreValue( + locator: string, + variable: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const value = await element.getAttribute('value') + this.variables.set(variable, value) + } + + async doStoreWindowHandle(variable: string) { + const handle = await this.driver.getWindowHandle() + this.variables.set(variable, handle) + await this.executeHook('onStoreWindowHandle', { + windowHandle: handle, + windowHandleName: variable, + }) + } + + // assertions + + async doAssert(variableName: string, value: string) { + const variable = `${this.variables.get(variableName)}` + if (variable != value) { + throw new AssertionError( + "Actual value '" + variable + "' did not match '" + value + "'" + ) + } + } + + async doAssertAlert(expectedText: string) { + const alert = await this.driver.switchTo().alert() + const actualText = await alert.getText() + if (actualText !== expectedText) { + throw new AssertionError( + "Actual alert text '" + + actualText + + "' did not match '" + + expectedText + + "'" + ) + } + } + + async doAssertConfirmation(expectedText: string) { + const alert = await this.driver.switchTo().alert() + const actualText = await alert.getText() + if (actualText !== expectedText) { + throw new AssertionError( + "Actual confirm text '" + + actualText + + "' did not match '" + + expectedText + + "'" + ) + } + } + + async doAssertEditable( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + if (!(await this.isElementEditable(element))) { + throw new AssertionError('Element is not editable') + } + } + + async doAssertNotEditable( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + if (await this.isElementEditable(element)) { + throw new AssertionError('Element is editable') + } + } + + async doAssertPrompt(expectedText: string) { + const alert = await this.driver.switchTo().alert() + const actualText = await alert.getText() + if (actualText !== expectedText) { + throw new AssertionError( + "Actual prompt text '" + + actualText + + "' did not match '" + + expectedText + + "'" + ) + } + } + + async doAssertTitle(title: string) { + const actualTitle = await this.driver.getTitle() + if (title != actualTitle) { + throw new AssertionError( + "Actual value '" + actualTitle + "' did not match '" + title + "'" + ) + } + } + + async doAssertElementPresent( + locator: string, + _: string, + commandObject: Partial = {} + ) { + await this.waitForElement(locator, commandObject.targetFallback) + } + + async doAssertElementNotPresent(locator: string) { + const elements = await this.driver.findElements(parseLocator(locator)) + if (elements.length) { + throw new AssertionError('Unexpected element was found in page') + } + } + + async doAssertText( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const text = await element.getText() + if (text !== value) { + throw new AssertionError( + "Actual value '" + text + "' did not match '" + value + "'" + ) + } + } + + async doAssertNotText( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const text = await element.getText() + if (text === value) { + throw new AssertionError( + "Actual value '" + text + "' did match '" + value + "'" + ) + } + } + + async doAssertValue( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const elementValue = await element.getAttribute('value') + if (elementValue !== value) { + throw new AssertionError( + "Actual value '" + elementValue + "' did not match '" + value + "'" + ) + } + } + + // not generally implemented + async doAssertNotValue( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const elementValue = await element.getAttribute('value') + if (elementValue === value) { + throw new AssertionError( + "Actual value '" + elementValue + "' did match '" + value + "'" + ) + } + } + + async doAssertChecked( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + if (!(await element.isSelected())) { + throw new AssertionError('Element is not checked, expected to be checked') + } + } + + async doAssertNotChecked( + locator: string, + _: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + if (await element.isSelected()) { + throw new AssertionError('Element is checked, expected to be unchecked') + } + } + + async doAssertSelectedValue( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const elementValue = await element.getAttribute('value') + if (elementValue !== value) { + throw new AssertionError( + "Actual value '" + elementValue + "' did not match '" + value + "'" + ) + } + } + + async doAssertNotSelectedValue( + locator: string, + value: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const elementValue = await element.getAttribute('value') + if (elementValue === value) { + throw new AssertionError( + "Actual value '" + elementValue + "' did match '" + value + "'" + ) + } + } + + async doAssertSelectedLabel( + locator: string, + label: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject.targetFallback + ) + const selectedValue = await element.getAttribute('value') + const selectedOption = await element.findElement( + By.xpath(`option[@value="${selectedValue}"]`) + ) + const selectedOptionLabel = await selectedOption.getText() + if (selectedOptionLabel !== label) { + throw new AssertionError( + "Actual value '" + + selectedOptionLabel + + "' did not match '" + + label + + "'" + ) + } + } + + async doAssertNotSelectedLabel( + locator: string, + label: string, + commandObject: Partial = {} + ) { + const element = await this.waitForElement( + locator, + commandObject?.targetFallback + ) + const selectedValue = await element.getAttribute('value') + const selectedOption = await element.findElement( + By.xpath(`option[@value="${selectedValue}"]`) + ) + const selectedOptionLabel = await selectedOption.getText() + if (selectedOptionLabel === label) { + throw new AssertionError( + "Actual value '" + selectedOptionLabel + "' not match '" + label + "'" + ) + } + } + + // other commands + + async doDebugger() { + throw new Error('`debugger` is not supported in this run mode') + } + + async doEcho(string: string) { + if (this.logger) { + this.logger.info(`echo: ${string}`) + } + } + + async doPause(time: number) { + await this.driver.sleep(time) + } + + async doRun() { + throw new Error('`run` is not supported in this run mode') + } + + async doSetSpeed() { + throw new Error('`set speed` is not supported in this run mode') + } + + async evaluateConditional( + script: ScriptShape + ): Promise { + const result = await this.driver.executeScript( + interpolateScript(`return (${script.script})`, this.variables).script, + ...script.argv + ) + return { + value: !!result, + } + } + + async elementIsLocated( + locator: string, + fallback: [string, string][] = [] + ): Promise { + const elementLocator = parseLocator(locator) + try { + return await this.driver.findElement(elementLocator) + } catch (e) { + for (let i = 0; i < fallback.length; i++) { + try { + let loc = parseLocator(fallback[i][0]) + return await this.driver.findElement(loc) + } catch (e) { + // try the next one + } + } + } + return null + } + + async waitForElement( + locator: string, + fallback: [string, string][] = [] + ): Promise { + const locatorCondition = new WebElementCondition( + 'for element to be located', + async () => { + const el = await this.elementIsLocated(locator, fallback) + return el + } + ) + return await this.driver.wait( + locatorCondition, + this.implicitWait + ) + } + + async isElementEditable(element: WebElementShape) { + const { enabled, readonly } = + await this.driver.executeScript( + 'return { enabled: !arguments[0].disabled, readonly: arguments[0].readOnly };', + element + ) + return enabled && !readonly + } + + async waitForElementVisible( + locator: string, + timeout: number, + fallback: [string, string][] = [] + ) { + const visibleCondition = new WebElementCondition( + 'for element to be visible', + async () => { + const el = await this.elementIsLocated(locator, fallback) + if (!el) return null + if (!(await el.isDisplayed())) return null + return el + } + ) + return await this.driver.wait(visibleCondition, timeout) + } + + async waitForText( + locator: string, + text: string, + fallback: [string, string][] = [] + ) { + const timeout = this.implicitWait + const textCondition = new Condition( + 'for text to be present in element', + async () => { + const el = await this.elementIsLocated(locator, fallback) + if (!el) return null + const elText = await el.getText() + return elText === text + } + ) + await this.driver.wait(textCondition, timeout) + } +} + +WebDriverExecutor.prototype.doOpen = composePreprocessors( + interpolateString, + WebDriverExecutor.prototype.doOpen +) + +WebDriverExecutor.prototype.doSetWindowSize = composePreprocessors( + interpolateString, + WebDriverExecutor.prototype.doSetWindowSize +) + +WebDriverExecutor.prototype.doSelectWindow = composePreprocessors( + interpolateString, + WebDriverExecutor.prototype.doSelectWindow +) + +WebDriverExecutor.prototype.doSelectFrame = composePreprocessors( + interpolateString, + WebDriverExecutor.prototype.doSelectFrame +) + +WebDriverExecutor.prototype.doAnswerPrompt = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAnswerPrompt +) + +WebDriverExecutor.prototype.doAddSelection = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + valueFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doAddSelection +) + +WebDriverExecutor.prototype.doRemoveSelection = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + valueFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doRemoveSelection +) + +WebDriverExecutor.prototype.doCheck = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doCheck +) + +WebDriverExecutor.prototype.doUncheck = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doUncheck +) + +WebDriverExecutor.prototype.doClick = composePreprocessors( + interpolateString, + interpolateString, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doClick +) + +WebDriverExecutor.prototype.doClickAt = composePreprocessors( + interpolateString, + interpolateString, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doClickAt +) + +WebDriverExecutor.prototype.doDoubleClick = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doDoubleClick +) + +WebDriverExecutor.prototype.doDoubleClickAt = composePreprocessors( + interpolateString, + interpolateString, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doDoubleClickAt +) + +WebDriverExecutor.prototype.doDragAndDropToObject = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + valueFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doDragAndDropToObject +) + +WebDriverExecutor.prototype.doMouseDown = composePreprocessors( + interpolateString, + null, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseDown +) + +WebDriverExecutor.prototype.doMouseDownAt = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseDownAt +) + +WebDriverExecutor.prototype.doMouseMoveAt = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseMoveAt +) + +WebDriverExecutor.prototype.doMouseOut = composePreprocessors( + interpolateString, + null, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseOut +) + +WebDriverExecutor.prototype.doMouseOver = composePreprocessors( + interpolateString, + null, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseOver +) + +WebDriverExecutor.prototype.doMouseUp = composePreprocessors( + interpolateString, + null, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseUp +) + +WebDriverExecutor.prototype.doMouseUpAt = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doMouseUpAt +) + +WebDriverExecutor.prototype.doSelect = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + valueFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doSelect +) + +WebDriverExecutor.prototype.doEditContent = composePreprocessors( + interpolateString, + interpolateString, + { + targetFallback: preprocessArray(interpolateString), + }, + WebDriverExecutor.prototype.doEditContent +) + +WebDriverExecutor.prototype.doType = composePreprocessors( + interpolateString, + interpolateString, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doType +) + +WebDriverExecutor.prototype.doSendKeys = composePreprocessors( + interpolateString, + preprocessKeys, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doSendKeys +) + +WebDriverExecutor.prototype.doRunScript = composePreprocessors( + interpolateScript, + WebDriverExecutor.prototype.doRunScript +) + +WebDriverExecutor.prototype.doExecuteScript = composePreprocessors( + interpolateScript, + null, + WebDriverExecutor.prototype.doExecuteScript +) + +WebDriverExecutor.prototype.doExecuteAsyncScript = composePreprocessors( + interpolateScript, + null, + WebDriverExecutor.prototype.doExecuteAsyncScript +) + +WebDriverExecutor.prototype.doStore = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doStore +) + +WebDriverExecutor.prototype.doStoreAttribute = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doStoreAttribute +) + +WebDriverExecutor.prototype.doStoreElementCount = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doStoreElementCount +) + +WebDriverExecutor.prototype.doStoreJson = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doStoreJson +) + +WebDriverExecutor.prototype.doStoreText = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doStoreText +) + +WebDriverExecutor.prototype.doStoreValue = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doStoreValue +) + +WebDriverExecutor.prototype.doAssert = composePreprocessors( + null, + interpolateString, + WebDriverExecutor.prototype.doAssert +) + +WebDriverExecutor.prototype.doAssertAlert = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAssertAlert +) + +WebDriverExecutor.prototype.doAssertConfirmation = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAssertConfirmation +) + +WebDriverExecutor.prototype.doAssertEditable = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doAssertEditable +) + +WebDriverExecutor.prototype.doAssertElementPresent = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAssertElementPresent +) + +WebDriverExecutor.prototype.doAssertElementNotPresent = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAssertElementNotPresent +) + +WebDriverExecutor.prototype.doAssertNotEditable = composePreprocessors( + interpolateString, + null, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doAssertNotEditable +) + +WebDriverExecutor.prototype.doAssertPrompt = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAssertPrompt +) + +WebDriverExecutor.prototype.doAssertSelectedLabel = composePreprocessors( + interpolateString, + interpolateString, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doAssertSelectedLabel +) + +WebDriverExecutor.prototype.doAssertText = composePreprocessors( + interpolateString, + interpolateString, + WebDriverExecutor.prototype.doAssertText +) + +WebDriverExecutor.prototype.doAssertTitle = composePreprocessors( + interpolateString, + null, + WebDriverExecutor.prototype.doAssertTitle +) + +WebDriverExecutor.prototype.doAssertValue = composePreprocessors( + interpolateString, + interpolateString, + { targetFallback: preprocessArray(interpolateString) }, + WebDriverExecutor.prototype.doAssertValue +) + +WebDriverExecutor.prototype.doEcho = composePreprocessors( + interpolateString, + WebDriverExecutor.prototype.doEcho +) + +const waitCommands: (keyof WebDriverExecutor)[] = [ + 'doWaitForElementEditable', + 'doWaitForElementNotEditable', + 'doWaitForElementPresent', + 'doWaitForElementNotPresent', + 'doWaitForElementVisible', + 'doWaitForElementNotVisible', + 'doWaitForText', +] + +waitCommands.forEach((cmd) => { + // @ts-expect-error - Whatever who cares + WebDriverExecutor.prototype[cmd] = composePreprocessors( + interpolateString, + interpolateString, + WebDriverExecutor.prototype[cmd] + ) +}) + +function createVerifyCommands(Executor: WebDriverExecutor) { + // @ts-expect-error + Object.getOwnPropertyNames(Executor.prototype) + .filter((command) => /^doAssert/.test(command)) + .forEach((assertion) => { + const verify = assertion.replace('doAssert', 'doVerify') + // @ts-expect-error + Executor.prototype[verify] = { + // creating the function inside an object since function declared in an + // object are named after the property, thus creating dyanmic named funcs + // also in order to be able to call(this) the function has to be normal + // declaration rather than arrow, as arrow will be bound to + // createVerifyCommands context which is undefined + [verify]: async function (...args: any[]) { + try { + // @ts-expect-error + return await Executor.prototype[assertion].call(this, ...args) + } catch (err) { + if (err instanceof AssertionError) { + throw new VerificationError(err.message) + } + throw err + } + }, + }[verify] + }) +} + +// @ts-expect-error +createVerifyCommands(WebDriverExecutor) + +function parseLocator(locator: string) { + if (/^\/\//.test(locator)) { + return By.xpath(locator) + } + const fragments = locator.split('=') + const type = fragments.shift() as keyof typeof LOCATORS + const selector = fragments.join('=') + if (LOCATORS[type] && selector) { + return LOCATORS[type](selector) + } else { + throw new Error(type ? `Unknown locator ${type}` : "Locator can't be empty") + } +} + +function parseOptionLocator(locator: string) { + const fragments = locator.split('=') + const type = fragments.shift() as keyof typeof OPTIONS_LOCATORS + const selector = fragments.join('=') + if (OPTIONS_LOCATORS[type] && selector) { + return OPTIONS_LOCATORS[type](selector) + } else if (!selector) { + // no selector strategy given, assuming label + return OPTIONS_LOCATORS['label'](type) + } else { + throw new Error( + type ? `Unknown selection locator ${type}` : "Locator can't be empty" + ) + } +} + +function parseCoordString(coord: string) { + const [x, y] = coord.split(',').map((n) => parseInt(n)) + return { + x, + y, + } +} + +function preprocessKeys(_str: string, variables: Variables) { + const str = interpolateString(_str, variables) + let keys = [] + let match = str.match(/\$\{\w+\}/g) + if (!match) { + keys.push(str) + } else { + let i = 0 + while (i < str.length) { + let currentKey = match.shift() as string, + currentKeyIndex = str.indexOf(currentKey, i) + if (currentKeyIndex > i) { + // push the string before the current key + keys.push(str.substr(i, currentKeyIndex - i)) + i = currentKeyIndex + } + if (currentKey) { + if (/^\$\{KEY_\w+\}/.test(currentKey)) { + // is a key + let keyName = ( + currentKey.match(/\$\{KEY_(\w+)\}/) as [string, string] + )[1] + // @ts-expect-error + let key = Key[keyName] + if (key) { + keys.push(key) + } else { + throw new Error(`Unrecognised key ${keyName}`) + } + } else { + // not a key, and not a stored variable, push it as-is + keys.push(currentKey) + } + i += currentKey.length + } else if (i < str.length) { + // push the rest of the string + keys.push(str.substr(i, str.length)) + i = str.length + } + } + } + return keys +} + +const LOCATORS = { + id: By.id, + name: By.name, + link: By.linkText, + linkText: By.linkText, + partialLinkText: By.partialLinkText, + css: By.css, + xpath: By.xpath, +} + +const nbsp = String.fromCharCode(160) +const OPTIONS_LOCATORS = { + id: (id: string) => By.css(`*[id="${id}"]`), + value: (value: string) => By.css(`*[value="${value}"]`), + label: (label: string) => { + const labels = label.match(/^[\w|-]+(?=:)/) + if (labels?.length) { + const [type, ...labelParts] = label.split(':') + const labelBody = labelParts.join(':') + switch (type) { + case 'mostly-equals': + return By.xpath( + `//option[normalize-space(translate(., '${nbsp}', ' ')) = '${labelBody}']` + ) + } + } + return By.xpath(`//option[. = '${label}']`) + }, + index: (index: string) => By.css(`*:nth-child(${index})`), +} + +async function findElement( + elements: WebElementShape[], + element: WebElementShape +) { + const id = await element.getId() + for (let i = 0; i < elements.length; i++) { + if ((await elements[i].getId()) === id) { + return true + } + } + return false +} diff --git a/packages/side-executor-runtime/tsconfig.json b/packages/side-executor-runtime/tsconfig.json new file mode 100644 index 000000000..d47e32566 --- /dev/null +++ b/packages/side-executor-runtime/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./src", + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["**/__mocks__"], + "references": [ + { + "path": "../side-commons" + }, + { + "path": "../side-model" + }, + { + "path": "../side-testkit" + }, + { + "path": "../webdriver-testkit" + } + ], + "types": ["node", "jest"] +} diff --git a/packages/side-executor/BUILD.bazel b/packages/side-executor/BUILD.bazel new file mode 100644 index 000000000..90a94c954 --- /dev/null +++ b/packages/side-executor/BUILD.bazel @@ -0,0 +1,33 @@ +load("@npm//@babel/cli:index.bzl", "babel") +load("@npm//jest-cli:index.bzl", "jest_test") + +babel( + name = "build", + args = [ + '--root-mode upward -d dist src --extensions ".js,.jsx,.ts,.tsx" --source-maps true' + ] +) + +filegroup( + name = "test_lib", + srcs = glob([ + "**/*.js" + ]), +) + +jest_test( + name = "test", + args = [ + "--no-cache", + "--no-watchman", + "--ci", + "--colors", + "--config", + "babel.config.js", + "--updateSnapshot" + ], + data = [ + ":test_lib", + "//:babel.config.js" + ] +) diff --git a/packages/side-executor/jest.config.js b/packages/side-executor/jest.config.js new file mode 100644 index 000000000..660f3fac0 --- /dev/null +++ b/packages/side-executor/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + testEnvironment: 'jsdom', + testEnvironmentOptions: { + url: 'http://localhost/index.html', + }, + moduleNameMapper: { + '^.+\\.(css|scss)$': 'identity-obj-proxy', + }, + setupFilesAfterEnv: ['./scripts/jest/test.config.js'], + testMatch: ['**/packages/**/__test?(s)__/**/*.spec.[jt]s?(x)'], + testPathIgnorePatterns: ['/node_modules/'], + transform: { + '^.+\\.jsx?$': 'babel-jest', + '^.+\\.tsx?$': 'ts-jest', + }, +} diff --git a/packages/side-executor/package.json b/packages/side-executor/package.json new file mode 100644 index 000000000..eaf70bdb9 --- /dev/null +++ b/packages/side-executor/package.json @@ -0,0 +1,37 @@ +{ + "name": "@seleniumhq/side-executor", + "version": "4.0.0-alpha.1", + "private": false, + "description": "Selenium IDE playback and execution", + "author": "Tomer ", + "homepage": "http://github.com/SeleniumHQ/selenium-ide", + "license": "Apache-2.0", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist tsconfig.tsbuildinfo node_modules", + "watch": "tsc --watch" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/SeleniumHQ/selenium-ide.git" + }, + "bugs": { + "url": "https://github.com/SeleniumHQ/selenium-ide/issues" + }, + "devDependencies": { + "@seleniumhq/side-testkit": "^4.0.0-alpha.1", + "@seleniumhq/webdriver-testkit": "^4.0.0-alpha.1" + }, + "dependencies": { + "@seleniumhq/side-commons": "^4.0.0-alpha.1", + "@seleniumhq/side-model": "^4.0.0-alpha.4", + "@types/selenium-webdriver": "^4.1.15", + "selenium-webdriver": "^4.11.1" + }, + "gitHead": "507c7c802f34196e6ee4800bf5c0b36553d41369" +} diff --git a/packages/side-executor/src/callstack.ts b/packages/side-executor/src/callstack.ts new file mode 100644 index 000000000..f0b340c19 --- /dev/null +++ b/packages/side-executor/src/callstack.ts @@ -0,0 +1,56 @@ +// 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 { CommandShape, TestShape } from '@seleniumhq/side-model' +import { PlaybackTree } from './playback-tree' +import { CommandNode } from './playback-tree/command-node' + +const stack = Symbol('stack') + +export interface Caller { + position: CommandNode['next'] + tree: PlaybackTree + commands: CommandShape[] +} + +export interface CallShape { + callee: TestShape + caller?: Caller +} + +export default class Callstack { + constructor() { + this[stack] = [] + } + [stack]: CallShape[] + get length() { + return this[stack].length + } + + call(procedure: CallShape) { + this[stack].push(procedure) + } + + unwind(): CallShape { + if (!this.length) throw new Error('Call stack is empty') + return this[stack].pop() as CallShape + } + + top() { + return this[stack][this[stack].length - 1] + } +} diff --git a/packages/side-executor/src/errors/assertion.ts b/packages/side-executor/src/errors/assertion.ts new file mode 100644 index 000000000..b06b9834a --- /dev/null +++ b/packages/side-executor/src/errors/assertion.ts @@ -0,0 +1,26 @@ +// 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. + +export default class AssertionError extends Error { + constructor(...argv: string[]) { + super(argv.join(' ')) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AssertionError) + } + } +} diff --git a/packages/side-executor/src/errors/index.ts b/packages/side-executor/src/errors/index.ts new file mode 100644 index 000000000..65637e7a3 --- /dev/null +++ b/packages/side-executor/src/errors/index.ts @@ -0,0 +1,19 @@ +// 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. + +export { default as AssertionError } from './assertion' +export { default as VerificationError } from './verification' diff --git a/packages/side-executor/src/errors/verification.ts b/packages/side-executor/src/errors/verification.ts new file mode 100644 index 000000000..0459b7291 --- /dev/null +++ b/packages/side-executor/src/errors/verification.ts @@ -0,0 +1,26 @@ +// 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. + +export default class VerificationError extends Error { + constructor(...argv: string[]) { + super(argv.join(' ')) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, VerificationError) + } + } +} diff --git a/packages/side-executor/src/executor.ts b/packages/side-executor/src/executor.ts new file mode 100644 index 000000000..d91689af3 --- /dev/null +++ b/packages/side-executor/src/executor.ts @@ -0,0 +1,156 @@ +// 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 { Capabilities } from 'selenium-webdriver' +import { CommandShape } from '@seleniumhq/side-model' +import { Fn } from '@seleniumhq/side-commons' + +export type ExpandedCapabilities = Partial & { + browserName: string + 'goog:chromeOptions'?: Record +} +const DEFAULT_CAPABILITIES: ExpandedCapabilities = { + browserName: 'chrome', +} + +const state = Symbol('state') + +export interface BaseExecutorConstructorArgs { + capabilities?: ExpandedCapabilities + hooks?: BaseExecutorHooks + implicitWait?: number + server?: string +} + +export interface BaseExecutorInitOptions { + baseUrl: string + logger: Console +} + +export interface BeforePlayHookInput { + driver: BaseExecutor +} + +export interface CommandHookInput { + command: CommandShape +} + +export type GeneralHook = (input: T) => Promise | void + +export interface BaseExecutorHooks { + onBeforePlay?: GeneralHook + onAfterCommand?: GeneralHook + onBeforeCommand?: GeneralHook + onWindowAppeared?: GeneralHook<{ + command: CommandShape + windowHandleName: string + }> +} + +export type HookKeys = keyof BaseExecutorHooks + +export interface ElementEditableScriptResult { + enabled: boolean + readonly: boolean +} + +export interface ScriptShape { + script: string + argv: any[] +} + +export default class BaseExecutor { + constructor({ + capabilities, + server, + hooks = {}, + implicitWait, + }: BaseExecutorConstructorArgs) { + this.capabilities = capabilities || DEFAULT_CAPABILITIES + this.server = server + this.implicitWait = implicitWait || 5 * 1000 + this.hooks = hooks + } + baseUrl?: string + cancellable?: { cancel: () => void } + capabilities?: ExpandedCapabilities + commands: Record = {} + server?: string + windowHandle?: string + hooks: BaseExecutorHooks + implicitWait: number + logger?: Console; + [state]?: any + + async init({ baseUrl, logger }: BaseExecutorInitOptions) { + this.baseUrl = baseUrl + this.logger = logger + this[state] = {} + } + + async cancel() { + if (this.cancellable) { + await this.cancellable.cancel() + } + } + + async cleanup() {} + + name(command: string) { + if (!command) { + return 'skip' + } + + if (this.commands[command]) { + return command + } + throw new Error(`Unknown command ${command}`) + } + + async executeHook( + hook: T, + ...args: Parameters> + ) { + type HookContents = BaseExecutorHooks[T] + type HookParameters = Parameters> + const fn = this.hooks[hook] as HookContents + if (!fn) return + // @ts-expect-error it's okay, this shape is fine + await fn.apply(this, args as HookParameters) + } + + async beforeCommand(commandObject: CommandShape) { + await this.executeHook('onBeforeCommand', { command: commandObject }) + } + + async afterCommand(commandObject: CommandShape) { + this.cancellable = undefined + if (commandObject.opensWindow) { + await this.executeHook('onWindowAppeared', { + command: commandObject, + windowHandleName: commandObject.windowHandleName!, + }) + } + + await this.executeHook('onAfterCommand', { command: commandObject }) + } + + // Commands go after this line + async skip() { + return Promise.resolve() + } +} diff --git a/packages/side-executor/src/index.ts b/packages/side-executor/src/index.ts new file mode 100644 index 000000000..e4dc8b12f --- /dev/null +++ b/packages/side-executor/src/index.ts @@ -0,0 +1,20 @@ +// 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. + +export { default as BaseExecutor } from './executor' + +export * from './types' diff --git a/packages/side-executor/src/playback-tree/command-leveler.ts b/packages/side-executor/src/playback-tree/command-leveler.ts new file mode 100644 index 000000000..485146e45 --- /dev/null +++ b/packages/side-executor/src/playback-tree/command-leveler.ts @@ -0,0 +1,87 @@ +// 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 { ControlFlowCommandNames } from './commands' +import { CommandShape } from '@seleniumhq/side-model' + +export function deriveCommandLevels(commandStack: CommandShape[]): number[] { + let level = 0 + let levels: number[] = [] + commandStack.forEach(function (command) { + const result = levelCommand(command, level, levels) + level = result.level + levels = result.levels + }) + return levels +} + +export type LevelCommand = ( + command: CommandShape, + level: number, + levels: number[] +) => { + level: number + levels: number[] +} + +const levelDefault: LevelCommand = (_command, level, _levels) => { + let levels = [..._levels] + levels.push(level) + return { level, levels } +} + +const levelBranchOpen: LevelCommand = (_command, level, _levels) => { + let levels = [..._levels] + levels.push(level) + level++ + return { level, levels } +} + +const levelBranchEnd: LevelCommand = (_command, level, _levels) => { + let levels = [..._levels] + level-- + levels.push(level) + return { level, levels } +} + +const levelElse: LevelCommand = (_command, level, _levels) => { + let levels = [..._levels] + level-- + levels.push(level) + level++ + return { level, levels } +} + +const levelCommand: LevelCommand = (command, level, levels) => { + if (!command.skip && commandLevelers[command.command]) { + return commandLevelers[command.command](command, level, levels) + } + return levelDefault(command, level, levels) +} + +const commandLevelers = { + [ControlFlowCommandNames.do]: levelBranchOpen, + [ControlFlowCommandNames.else]: levelElse, + [ControlFlowCommandNames.elseIf]: levelElse, + [ControlFlowCommandNames.end]: levelBranchEnd, + [ControlFlowCommandNames.forEach]: levelBranchOpen, + [ControlFlowCommandNames.if]: levelBranchOpen, + [ControlFlowCommandNames.repeatIf]: levelBranchEnd, + [ControlFlowCommandNames.times]: levelBranchOpen, + [ControlFlowCommandNames.try]: levelBranchOpen, + [ControlFlowCommandNames.while]: levelBranchOpen, +} diff --git a/packages/side-executor/src/playback-tree/command-node.ts b/packages/side-executor/src/playback-tree/command-node.ts new file mode 100644 index 000000000..4674d0150 --- /dev/null +++ b/packages/side-executor/src/playback-tree/command-node.ts @@ -0,0 +1,290 @@ +// 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 { Fn } from '@seleniumhq/side-commons' +import { CommandShape } from '@seleniumhq/side-model' +import { WebDriverExecutor } from '..' +import { interpolateScript, interpolateString } from '../preprocessors' +import { CommandNodeOptions } from '../types' +import Variables from '../variables' +import { WebDriverExecutorCondEvalResult } from '../webdriver' +import { ControlFlowCommandChecks } from './commands' + +export interface CommandExecutorOptions { + executorOverride?: Fn +} + +export interface CommandExecutionResult { + next?: CommandNode + skipped?: boolean + value?: any +} + +export class CommandNode { + constructor( + command: CommandShape, + { emitControlFlowChange }: CommandNodeOptions = {} + ) { + this.command = command + this.next = undefined + this.left = undefined + this.right = undefined + this.index = 0 + this.level = 0 + this.timesVisited = 0 + this.emitControlFlowChange = emitControlFlowChange + ? emitControlFlowChange + : () => {} + } + command: CommandShape + emitControlFlowChange: Fn + next?: CommandNode + transientError?: string + left?: CommandNode + right?: CommandNode + index: number + level: number + timesVisited: number + + /* I'm not sure what this does yet, so I'm putting it on a shelf atm + isExtCommand(executor: CommandNode): boolean { + return !!( + typeof executor.isExtCommand === 'function' && + executor.isExtCommand(this.command.command) + ) + } + */ + + isControlFlow(): boolean { + return !!(this.left || this.right) + } + + isTerminal(): boolean { + return ( + ControlFlowCommandChecks.isTerminal(this.command) || + this.command.command === '' + ) + } + + shouldSkip(): boolean { + return Boolean(this.command.skip || this.command.command.startsWith('//')) + } + + async execute( + commandExecutor: WebDriverExecutor, + args?: CommandExecutorOptions + ) { + if (this._isRetryLimit()) { + throw new Error( + 'Max retry limit exceeded. To override it, specify a new limit in the value input field.' + ) + } + if (this.shouldSkip()) { + return this._executionResult({ skipped: true }) + } + await commandExecutor.beforeCommand(this.command) + const result = await this._executeCommand(commandExecutor, args) + await commandExecutor.afterCommand(this.command) + return this._executionResult(result) + } + + async _executeCommand( + commandExecutor: WebDriverExecutor, + { executorOverride }: CommandExecutorOptions = {} + ) { + if (executorOverride) { + return await executorOverride(this.command.target, this.command.value) + } else if (this.isControlFlow()) { + return this._evaluate(commandExecutor) + } else if (this.isTerminal()) { + return + } else { + const { command } = this + const { target, value } = command + const commandName = command.command + const customCommand = commandExecutor.customCommands[commandName] + const existingCommandName = commandExecutor.name(commandName) + // @ts-expect-error webdriver is too kludged by here + if (!customCommand && !commandExecutor[existingCommandName]) { + throw new Error(`Missing expected command type ${commandName}`) + } + const executor = customCommand + ? () => customCommand.execute(command, commandExecutor) + : // @ts-expect-error webdriver is too kludged by here + () => commandExecutor[existingCommandName](target, value, commandName) + const ignoreRetry = + commandName === 'pause' || commandName.startsWith('wait') + if (ignoreRetry) { + return executor() + } + return this.retryCommand( + executor, + Date.now() + commandExecutor.implicitWait + ) + } + } + + async pauseTimeout(timeout?: number): Promise { + return new Promise((resolve) => setTimeout(resolve, timeout)) + } + + async retryCommand( + execute: () => Promise, + timeout: number + ): Promise { + const timeLimit = timeout - Date.now() + const expirationTimer = setTimeout(() => { + throw new Error( + `Operation timed out running command ${this.command.command}:${this.command.target}:${this.command.value}` + ) + }, timeLimit) + try { + const result = await execute() + clearTimeout(expirationTimer) + return result + } catch (e) { + clearTimeout(expirationTimer) + this.handleTransientError(e, timeout) + await this.pauseTimeout() + return this.retryCommand(execute, timeout) + } + } + + _executionResult(result: CommandExecutionResult = {}) { + this._incrementTimesVisited() + return { + next: this.isControlFlow() ? result.next : this.next, + skipped: result.skipped, + } + } + + handleTransientError(e: unknown, timeout: number) { + const { command, target, value } = this.command + const thisCommand = `${command}-${target}-${value}` + const thisErrorMessage = e instanceof Error ? e.message : '' + const thisTransientError = `${thisCommand}-${thisErrorMessage}` + const lastTransientError = this.transientError + const isNewErrorMessage = lastTransientError !== thisTransientError + const notRetrying = Date.now() > timeout + if (isNewErrorMessage) { + this.transientError = thisTransientError + console.warn( + 'Unexpected error occured during command:', + thisCommand, + notRetrying ? '' : 'retrying...' + ) + if (thisErrorMessage) { + console.error(thisErrorMessage) + } + } + + if (notRetrying) { + console.error('Command failure:', thisCommand) + throw e + } + } + + evaluateForEach(variables: Variables): boolean | string { + let collection = variables.get( + interpolateScript(this.command.target as string, variables).script + ) + if (!collection) return 'Invalid variable provided.' + variables.set( + interpolateScript(this.command.value as string, variables).script, + collection[this.timesVisited] + ) + const result = this.timesVisited < collection.length + if (result) + this.emitControlFlowChange({ + commandId: this.command.id, + type: CommandType.LOOP, + index: this.timesVisited, + iterator: collection[this.timesVisited], + collection, + }) + // Reset timesVisited if loop ends, needed to support forEach recursion. + // It's set to -1 since the incrementer will pick it up. Setting it to + // 0 when called on a subsequent interation. + else this.timesVisited = -1 + return result + } + + _evaluate(commandExecutor: WebDriverExecutor) { + if (ControlFlowCommandChecks.isTimes(this.command)) { + const number = Math.floor( + +interpolateString(`${this.command.target}`, commandExecutor.variables) + ) + if (isNaN(number)) { + return Promise.reject(new Error('Invalid number provided as a target.')) + } + return this._evaluationResult({ value: this.timesVisited < number }) + } + let expression = interpolateScript( + this.command.target as string, + commandExecutor.variables + ) + if (ControlFlowCommandChecks.isForEach(this.command)) { + const result = this.evaluateForEach(commandExecutor.variables) + if (!result) { + this.emitControlFlowChange({ + commandId: this.command.id, + type: CommandType.LOOP, + end: true, + }) + } + return this._evaluationResult({ + value: Boolean(result), + }) + } + return commandExecutor.evaluateConditional(expression).then((result) => { + return this._evaluationResult(result) + }) + } + + _evaluationResult(result: WebDriverExecutorCondEvalResult) { + if (result.value) { + return { + next: this.right, + } + } else { + return { + next: this.left, + } + } + } + + _incrementTimesVisited() { + if (ControlFlowCommandChecks.isLoop(this.command)) this.timesVisited++ + } + + _isRetryLimit() { + if (ControlFlowCommandChecks.isLoop(this.command)) { + let limit = 1000 + let value = Math.floor(+(this.command.value as string)) + if (this.command.value && !isNaN(value)) { + limit = value + } + return this.timesVisited >= limit + } + return false + } +} + +export const CommandType = { + LOOP: 'loop', + CONDITIONAL: 'conditional', +} as const diff --git a/packages/side-executor/src/playback-tree/commands.ts b/packages/side-executor/src/playback-tree/commands.ts new file mode 100644 index 000000000..c71af3b4f --- /dev/null +++ b/packages/side-executor/src/playback-tree/commands.ts @@ -0,0 +1,183 @@ +// 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 { CommandShape } from '@seleniumhq/side-model' + +export const ControlFlowCommandNames = { + do: 'do', + else: 'else', + elseIf: 'elseIf', + end: 'end', + forEach: 'forEach', + if: 'if', + repeatIf: 'repeatIf', + times: 'times', + try: 'try', + while: 'while', +} + +function commandNamesEqual(command: CommandShape, target: string) { + if (command) { + return command.command === target + } else { + return false + } +} + +function isCommandEnabled(command: CommandShape) { + return command && !command.skip +} + +function isBlockOpen(command: CommandShape) { + return ( + isCommandEnabled(command) && + (isIf(command) || isLoop(command) || isTry(command)) + ) +} + +function isConditional(command: CommandShape) { + if (!isCommandEnabled(command)) return false + + switch (command.command) { + case ControlFlowCommandNames.elseIf: + case ControlFlowCommandNames.if: + case ControlFlowCommandNames.repeatIf: + case ControlFlowCommandNames.times: + case ControlFlowCommandNames.while: + return true + default: + return false + } +} + +function isControlFlow(command: CommandShape) { + if (!isCommandEnabled(command)) return false + + switch (command.command) { + case ControlFlowCommandNames.if: + case ControlFlowCommandNames.elseIf: + case ControlFlowCommandNames.else: + case ControlFlowCommandNames.end: + case ControlFlowCommandNames.do: + case ControlFlowCommandNames.repeatIf: + case ControlFlowCommandNames.times: + case ControlFlowCommandNames.try: + case ControlFlowCommandNames.while: + return true + default: + return false + } +} + +function isDo(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.do) + ) +} + +function isElse(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.else) + ) +} + +function isElseIf(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.elseIf) + ) +} + +function isElseOrElseIf(command: CommandShape) { + return isCommandEnabled(command) && (isElseIf(command) || isElse(command)) +} + +function isEnd(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.end) + ) +} + +function isIf(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.if) + ) +} + +function isIfBlock(command: CommandShape) { + return isCommandEnabled(command) && (isIf(command) || isElseOrElseIf(command)) +} + +function isLoop(command: CommandShape) { + if (!isCommandEnabled(command)) return false + + return ( + commandNamesEqual(command, ControlFlowCommandNames.while) || + commandNamesEqual(command, ControlFlowCommandNames.times) || + commandNamesEqual(command, ControlFlowCommandNames.repeatIf) || + commandNamesEqual(command, ControlFlowCommandNames.forEach) + ) +} + +function isTerminal(command: CommandShape) { + return ( + isCommandEnabled(command) && + (isElse(command) || isDo(command) || isEnd(command)) + ) +} + +function isTimes(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.times) + ) +} + +function isTry(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.try) + ) +} + +function isForEach(command: CommandShape) { + return ( + isCommandEnabled(command) && + commandNamesEqual(command, ControlFlowCommandNames.forEach) + ) +} + +export const ControlFlowCommandChecks = { + isIfBlock, + isConditional, + isDo, + isElse, + isElseOrElseIf, + isEnd, + isIf, + isLoop, + isBlockOpen, + isTerminal, + isControlFlow, + isTimes, + isForEach, + isTry, +} diff --git a/packages/side-executor/src/playback-tree/control-flow-syntax-error.ts b/packages/side-executor/src/playback-tree/control-flow-syntax-error.ts new file mode 100644 index 000000000..33018dde6 --- /dev/null +++ b/packages/side-executor/src/playback-tree/control-flow-syntax-error.ts @@ -0,0 +1,24 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// dControlFlowCommandChecks.istributed with thControlFlowCommandChecks.is work for additional information +// regarding copyright ownership. The SFC licenses thControlFlowCommandChecks.is file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use thControlFlowCommandChecks.is 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 dControlFlowCommandChecks.istributed under the License ControlFlowCommandChecks.is dControlFlowCommandChecks.istributed on an +// "AS ControlFlowCommandChecks.is" BASControlFlowCommandChecks.is, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permControlFlowCommandChecks.issions and limitations +// under the License. + +export class ControlFlowSyntaxError extends SyntaxError { + constructor(message: string, index?: number) { + super(message) + this.index = index + } + index?: number +} diff --git a/packages/side-executor/src/playback-tree/index.ts b/packages/side-executor/src/playback-tree/index.ts new file mode 100644 index 000000000..65cf7eb4d --- /dev/null +++ b/packages/side-executor/src/playback-tree/index.ts @@ -0,0 +1,257 @@ +// 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 { CommandNode } from './command-node' +import { State } from './state' +import { validateControlFlowSyntax, repeatIfError } from './syntax-validation' +import { deriveCommandLevels } from './command-leveler' +import { ControlFlowCommandNames, ControlFlowCommandChecks } from './commands' +import { CommandShape } from '@seleniumhq/side-model' +import { CommandNodeOptions } from '../types' +import { Fn } from '@seleniumhq/side-commons' +export { createPlaybackTree } // public API +export { createCommandNodesFromCommandStack } // for testing + +export interface PlaybackTree { + startingCommandNode: CommandNode + nodes: CommandNode[] + containsControlFlow: boolean +} + +function createPlaybackTree( + commandStack: CommandShape[], + { isValidationDisabled, emitControlFlowChange }: CommandNodeOptions = {} +): PlaybackTree { + let nodes = createCommandNodesFromCommandStack( + commandStack, + isValidationDisabled, + emitControlFlowChange + ) + return { + startingCommandNode: nodes[0], + nodes, + containsControlFlow: containsControlFlow(nodes), + } +} + +function containsControlFlow(nodes: CommandNode[]) { + return !!nodes.filter((node) => node.isControlFlow()).length +} + +function createCommandNodesFromCommandStack( + commandStack: CommandShape[], + isValidationDisabled: boolean = false, + emitControlFlowChange?: Fn +) { + if (!isValidationDisabled) validateControlFlowSyntax(commandStack) + let levels = deriveCommandLevels(commandStack) + let nodes = createCommandNodes(commandStack, levels, emitControlFlowChange) + return connectCommandNodes(nodes) +} + +function createCommandNodes( + commandStack: CommandShape[], + levels: number[], + emitControlFlowChange?: Fn +): CommandNode[] { + let commandNodes: CommandNode[] = [] + commandStack.forEach(function (command, index) { + let node = new CommandNode(command, { emitControlFlowChange }) + node.index = index + node.level = levels[index] + commandNodes.push(node) + }) + return commandNodes +} + +function connectCommandNodes(_commandNodeStack: CommandNode[]): CommandNode[] { + let commandNodeStack = [..._commandNodeStack] + let state = new State() + commandNodeStack.forEach(function (commandNode) { + let nextCommandNode = commandNodeStack[commandNode.index + 1] + connectCommandNode({ + commandNode, + nextCommandNode, + commandNodeStack, + state, + }) + }) + return commandNodeStack +} + +export type ConnectFn = ( + commandNode: CommandNode, + _nextCommandNode: CommandNode, + stack: CommandNode[], + state?: any +) => void + +const connectDefault: ConnectFn = ( + commandNode, + _nextCommandNode, + stack, + state +) => { + let nextCommandNode + if ( + ControlFlowCommandChecks.isIf(state.top()) && + ControlFlowCommandChecks.isElseOrElseIf(_nextCommandNode.command) + ) { + nextCommandNode = findNextNodeBy( + stack, + commandNode.index, + state.top().level, + ControlFlowCommandNames.end + ) + } else if ( + ControlFlowCommandChecks.isLoop(state.top()) && + ControlFlowCommandChecks.isEnd(_nextCommandNode.command) + ) { + nextCommandNode = stack[state.top().index] + } else { + nextCommandNode = _nextCommandNode + } + connectNext(commandNode, nextCommandNode as CommandNode) +} + +const trackBranchOpen: ConnectFn = ( + commandNode, + nextCommandNode, + _stack, + state +) => { + state.push({ + command: commandNode.command.command, + level: commandNode.level, + index: commandNode.index, + }) + if (ControlFlowCommandChecks.isDo(commandNode.command)) + connectNext(commandNode, nextCommandNode) +} + +const trackBranchClose: ConnectFn = ( + commandNode, + nextCommandNode, + stack, + state +) => { + state.pop() + const top = state.top() + let nextCommandNodeOverride + if ( + top && + ControlFlowCommandChecks.isLoop(top) && + nextCommandNode && + ControlFlowCommandChecks.isEnd(nextCommandNode.command) + ) + nextCommandNodeOverride = stack[top.index] + connectNext( + commandNode, + nextCommandNodeOverride ? nextCommandNodeOverride : nextCommandNode + ) +} + +const connectConditionalForBranchOpen: ConnectFn = ( + commandNode, + nextCommandNode, + stack, + state +) => { + trackBranchOpen(commandNode, nextCommandNode, stack, state) + connectConditional(commandNode, nextCommandNode, stack) +} + +const connectConditional: ConnectFn = (commandNode, nextCommandNode, stack) => { + let left = findNextNodeBy(stack, commandNode.index, commandNode.level) + let right = nextCommandNode + commandNode.right = right + commandNode.left = left +} + +function connectNext(commandNode: CommandNode, nextCommandNode: CommandNode) { + commandNode.next = nextCommandNode +} + +const connectDo: ConnectFn = (commandNode, nextCommandNode, stack, state) => { + const top = state.top() + if (!top) repeatIfError() + commandNode.right = stack[top.index] + commandNode.left = nextCommandNode + state.pop() +} + +function findNextNodeBy( + stack: CommandNode[], + index: number, + level: number, + commandName?: string +) { + for (let i = index + 1; i < stack.length + 1; i++) { + if (commandName) { + if ( + stack[i].level === level && + stack[i].command.command === commandName + ) { + return stack[i] + } + } else { + if (stack[i].level === level) { + return stack[i] + } + } + } + return undefined +} + +function connectCommandNode({ + commandNode, + nextCommandNode, + commandNodeStack, + state, +}: { + commandNode: CommandNode + nextCommandNode: CommandNode + commandNodeStack: CommandNode[] + state: any +}) { + if ( + commandNode.command.skip || + !commandNodeConnectors[commandNode.command.command] + ) { + connectDefault(commandNode, nextCommandNode, commandNodeStack, state) + } else { + commandNodeConnectors[commandNode.command.command]( + commandNode, + nextCommandNode, + commandNodeStack, + state + ) + } +} + +const commandNodeConnectors = { + [ControlFlowCommandNames.do]: trackBranchOpen, + [ControlFlowCommandNames.else]: connectNext, + [ControlFlowCommandNames.elseIf]: connectConditional, + [ControlFlowCommandNames.end]: trackBranchClose, + [ControlFlowCommandNames.forEach]: connectConditionalForBranchOpen, + [ControlFlowCommandNames.if]: connectConditionalForBranchOpen, + [ControlFlowCommandNames.repeatIf]: connectDo, + [ControlFlowCommandNames.times]: connectConditionalForBranchOpen, + [ControlFlowCommandNames.try]: connectConditionalForBranchOpen, + [ControlFlowCommandNames.while]: connectConditionalForBranchOpen, +} diff --git a/packages/side-executor/src/playback-tree/state.ts b/packages/side-executor/src/playback-tree/state.ts new file mode 100644 index 000000000..96573a3cc --- /dev/null +++ b/packages/side-executor/src/playback-tree/state.ts @@ -0,0 +1,38 @@ +// 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. + +export class State { + constructor() { + this._state = [] + } + _state: any[] + empty() { + return this._state.length === 0 + } + + push(obj: any) { + this._state.push(obj) + } + + pop() { + this._state.pop() + } + + top() { + return this._state[this._state.length - 1] + } +} diff --git a/packages/side-executor/src/playback-tree/syntax-validation.ts b/packages/side-executor/src/playback-tree/syntax-validation.ts new file mode 100644 index 000000000..607a56e3f --- /dev/null +++ b/packages/side-executor/src/playback-tree/syntax-validation.ts @@ -0,0 +1,139 @@ +// 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 { ControlFlowCommandNames, ControlFlowCommandChecks } from './commands' +import { State } from './state' +import { ControlFlowSyntaxError } from './control-flow-syntax-error' +import { CommandShape } from '@seleniumhq/side-model' + +export function validateControlFlowSyntax(commandStack: CommandShape[]) { + let state = new State() + commandStack.forEach(function (command, commandIndex) { + validateCommand(command, commandIndex, state) + }) + if (!state.empty()) { + throw new ControlFlowSyntaxError( + 'Incomplete block at ' + state.top().command, + state.top().index + ) + } else { + return true + } +} + +function validateCommand( + command: CommandShape, + commandIndex: number, + state: State +) { + if (!command.skip && commandValidators[command.command]) { + return commandValidators[command.command]( + command.command, + commandIndex, + state + ) + } +} + +const commandValidators = { + [ControlFlowCommandNames.do]: trackControlFlowBranchOpen, + [ControlFlowCommandNames.else]: validateElse, + [ControlFlowCommandNames.elseIf]: validateElseIf, + [ControlFlowCommandNames.end]: validateEnd, + [ControlFlowCommandNames.forEach]: trackControlFlowBranchOpen, + [ControlFlowCommandNames.if]: trackControlFlowBranchOpen, + [ControlFlowCommandNames.repeatIf]: validateRepeatIf, + [ControlFlowCommandNames.times]: trackControlFlowBranchOpen, + [ControlFlowCommandNames.try]: trackControlFlowBranchOpen, + [ControlFlowCommandNames.while]: trackControlFlowBranchOpen, +} + +function trackControlFlowBranchOpen( + commandName: string, + commandIndex: number, + state: State +) { + state.push({ command: commandName, index: commandIndex }) +} + +function validateElse(commandName: string, commandIndex: number, state: State) { + if (!ControlFlowCommandChecks.isIfBlock(state.top())) { + throw new ControlFlowSyntaxError( + 'An else used outside of an if block', + commandIndex + ) + } + if (ControlFlowCommandChecks.isElse(state.top())) { + throw new ControlFlowSyntaxError( + 'Too many else commands used', + commandIndex + ) + } + state.push({ command: commandName, index: commandIndex }) +} + +function validateElseIf( + commandName: string, + commandIndex: number, + state: State +) { + if (!ControlFlowCommandChecks.isIfBlock(state.top())) { + throw new ControlFlowSyntaxError( + 'An else if used outside of an if block', + commandIndex + ) + } + if (ControlFlowCommandChecks.isElse(state.top())) { + throw new ControlFlowSyntaxError( + 'Incorrect command order of else if / else', + commandIndex + ) + } + state.push({ command: commandName, index: commandIndex }) +} + +function validateEnd(commandName: string, commandIndex: number, state: State) { + if (ControlFlowCommandChecks.isBlockOpen(state.top())) { + state.pop() + } else if (ControlFlowCommandChecks.isElseOrElseIf(state.top())) { + state.pop() + validateEnd(commandName, commandIndex, state) + } else { + throw new ControlFlowSyntaxError( + 'Use of end without an opening keyword', + commandIndex + ) + } +} + +function validateRepeatIf( + _commandName: string, + commandIndex: number, + state: State +) { + if (!ControlFlowCommandChecks.isDo(state.top())) { + repeatIfError(commandIndex) + } + state.pop() +} + +export function repeatIfError(commandIndex?: number) { + throw new ControlFlowSyntaxError( + 'A repeat if used without a do block', + commandIndex + ) +} diff --git a/packages/side-executor/src/playback.ts b/packages/side-executor/src/playback.ts new file mode 100644 index 000000000..053f15e55 --- /dev/null +++ b/packages/side-executor/src/playback.ts @@ -0,0 +1,319 @@ +// 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 EventEmitter from 'events' +import { events } from '@seleniumhq/side-commons' +import { createPlaybackTree, PlaybackTree } from './playback-tree' +import Callstack, { Caller } from './callstack' +import BaseExecutor from './executor' +import { CommandShape, TestShape } from '@seleniumhq/side-model' +import { CommandNode, CommandType } from './playback-tree/command-node' + +const EE = 'event-emitter' +const state = 'state' + +export type GetTestByName = (name: string) => TestShape + +export interface PlaybackConstructorArgs { + baseUrl: string + executor: EXECUTOR + getTestByName: GetTestByName + logger: Console + options?: Partial +} +export interface PlaybackConstructorOptions { + pauseOnExceptions: boolean + ignoreBreakpoints: boolean + delay: number +} + +export interface ExtendedEventEmitter extends EventEmitter { + emitCommandStateChange: ( + state: PlaybackEventShapes['COMMAND_STATE_CHANGED'] + ) => void + emitControlFlowChange: ( + state: PlaybackEventShapes['CONTROL_FLOW_CHANGED'] + ) => void +} + +export type RunningPromise = Promise +export type VaguePromise = (v?: unknown) => void +export type VaguePromiseWrapper = { + res: VaguePromise + rej: VaguePromise +} + +export interface PlayOptions { + pauseImmediately?: boolean + startingCommandIndex?: number +} + +export type Play = ( + test: TestShape, + opts: PlayOptions +) => Promise<() => RunningPromise | undefined> + +export type PlayTo = VaguePromiseWrapper & { command: string } + +export default class BasePlayback { + constructor({ + baseUrl, + executor, + getTestByName, + logger, + options = {}, + }: PlaybackConstructorArgs) { + this.baseUrl = baseUrl + this.executor = executor + this.getTestByName = getTestByName + this.logger = logger + this.options = Object.assign( + { + pauseOnExceptions: false, + ignoreBreakpoints: false, + delay: 0, + }, + options + ) + this[state] = { + aborting: false, + initialized: false, + isPlaying: false, + pausing: false, + stopping: false, + } + // @ts-expect-error + this[EE] = new EventEmitter() + this[EE].emitCommandStateChange = (payload) => { + this[state].lastSentCommandState = payload + this[EE].emit(PlaybackEvents.COMMAND_STATE_CHANGED, payload) + } + this[EE].emitControlFlowChange = (payload) => { + this[EE].emit(PlaybackEvents.CONTROL_FLOW_CHANGED, payload) + } + events.mergeEventEmitter(this, this[EE]) + } + baseUrl: string + executor: BaseExecutor + getTestByName: GetTestByName + logger: Console + options: PlaybackConstructorOptions; + [state]: { + aborting: boolean + exitCondition?: keyof typeof PlaybackStatesPriorities + initialized: boolean + isPlaying: boolean + lastSentCommandState?: PlaybackEventShapes['COMMAND_STATE_CHANGED'] + pausing: boolean + pausingResolve?: (v?: unknown) => void + playPromise?: RunningPromise + playTo?: PlayTo + resumeResolve?: VaguePromise + steps?: number + stepPromise?: VaguePromiseWrapper + stopping: boolean + }; + [EE]: ExtendedEventEmitter + initialized?: boolean + commands?: CommandShape[] + playbackTree?: PlaybackTree + currentExecutingNode?: CommandNode + + async init() { + await this.executor.init({ + baseUrl: this.baseUrl, + logger: this.logger, + }) + this[state].initialized = true + } + + play: Play = async (test, { pauseImmediately, startingCommandIndex = 0 }) => { + if (!this[state].initialized) await this.init() + if (this[state].playPromise) { + throw new Error('Playback already in progress') + } + this[EE].emit(PlaybackEvents.PLAYBACK_STATE_CHANGED, { + state: PlaybackStates.PLAYING, + }) + this[state].isPlaying = true + this[state].playPromise = this.playTo( + test, + test.commands.length - 1, + startingCommandIndex + ) + if (pauseImmediately) { + await this.pause({ graceful: true }) + } + return () => this[state].playPromise + } + + async playTo(test: TestShape, stopIndex: number, startIndex: number) { + if (!test.commands[stopIndex]) { + throw new Error('Command not found in test') + } else if (startIndex && !test.commands[startIndex]) { + throw new Error('Command to start from not found in test') + } + } + + async playFrom(test: TestShape, commandToStart: CommandShape) { + const index = test.commands.indexOf(commandToStart) + if (index === -1) { + throw new Error('Command not found in test') + } + } + + async playSingleCommand(command: CommandShape) { + if (!this[state].initialized) await this.init() + this.playbackTree = createPlaybackTree([command], { + emitControlFlowChange: this[EE].emitControlFlowChange.bind(this), + }) + this.currentExecutingNode = this.playbackTree.startingCommandNode + const callstack = new Callstack() + callstack.call({ + callee: { + id: '1', + name: `Single command ${command.command} ${command.target} ${command.value}`, + commands: [command], + }, + }) + } + + async pause({ graceful } = { graceful: false }) { + this[state].pausing = true + if (!graceful) { + await this.executor.cancel() + } + this[state].isPlaying = false + await new Promise((res) => { + this[state].pausingResolve = res + }) + } + + async resume() { + this[state].steps = undefined + } + + async stop() { + this[state].stopping = true + + if (this[state].resumeResolve) { + const r = this[state].resumeResolve as VaguePromise + this[state].resumeResolve = undefined + r() + } else { + await this.executor.cancel() + } + + // play will throw but the user will catch it with this.play() + // this.stop() should resolve once play finishes + await (this[state].playPromise as RunningPromise).catch(() => {}) + } + + async abort() { + this[state].aborting = true + + if (this[state].resumeResolve) { + const r = this[state].resumeResolve as VaguePromise + this[state].resumeResolve = undefined + r() + } + // we kill regardless of wether the test is paused to keep the + // behavior consistent + + // @ts-expect-error + await this.executor.kill() + + // play will throw but the user will catch it with this.play() + // this.abort() should resolve once play finishes + await (this[state].playPromise as RunningPromise).catch(() => {}) + } + + async cleanup() { + this[EE].removeAllListeners() + await this.executor.cleanup() + } +} + +export interface PlaybackEventShapes { + COMMAND_STATE_CHANGED: { + id: string + callstackIndex?: number + command: CommandShape + state: typeof CommandStates[keyof typeof CommandStates] + message?: string + error?: Error + } + PLAYBACK_STATE_CHANGED: { + state: typeof PlaybackStates[keyof typeof PlaybackStates] + } + CALL_STACK_CHANGED: { + change: typeof CallstackChange[keyof typeof CallstackChange] + callee: TestShape + caller: Caller + } + CONTROL_FLOW_CHANGED: { + commandId: string + type: typeof CommandType[] + end: boolean + } +} + +export const PlaybackEvents = { + COMMAND_STATE_CHANGED: 'command-state-changed', + PLAYBACK_STATE_CHANGED: 'playback-state-changed', + CALL_STACK_CHANGED: 'call-stack-changed', + CONTROL_FLOW_CHANGED: 'control-flow-changed', +} as const + +export const PlaybackStates = { + PREPARATION: 'prep', + PLAYING: 'playing', + FINISHED: 'finished', + FAILED: 'failed', + ERRORED: 'errored', + PAUSED: 'paused', + BREAKPOINT: 'breakpoint', + STOPPED: 'stopped', + ABORTED: 'aborted', +} as const + +export type PlaybackState = typeof PlaybackStates[keyof typeof PlaybackStates] + +const PlaybackStatesPriorities = { + [PlaybackStates.FINISHED]: 0, + [PlaybackStates.FAILED]: 1, + [PlaybackStates.ERRORED]: 2, + [PlaybackStates.STOPPED]: 3, + [PlaybackStates.ABORTED]: 4, +} as const + +export const CommandStates = { + EXECUTING: 'executing', + PENDING: 'pending', + SKIPPED: 'skipped', + PASSED: 'passed', + UNDETERMINED: 'undetermined', + FAILED: 'failed', + ERRORED: 'errored', +} as const + +export type CommandState = typeof CommandStates[keyof typeof CommandStates] + +export const CallstackChange = { + CALLED: 'called', + UNWINDED: 'unwinded', +} as const diff --git a/packages/side-executor/src/types.ts b/packages/side-executor/src/types.ts new file mode 100644 index 000000000..bd0212680 --- /dev/null +++ b/packages/side-executor/src/types.ts @@ -0,0 +1,54 @@ +import { CommandShape, ProjectShape } from '@seleniumhq/side-model' +import BaseExecutor from './executor' + +export { Capabilities } from 'selenium-webdriver' + +/** + * Modified command shape with additional execute function + */ +export interface CommandHookInput { + command: CommandShape +} + +export interface StoreWindowHandleHookInput { + windowHandle: string + windowHandleName: string +} + +export interface WindowAppearedHookInput { + command: CommandShape + windowHandleName: CommandShape['windowHandleName'] + windowHandle: string +} + +export interface WindowSwitchedHookInput { + windowHandle: string +} + +export type PluginHookInput = { + logger: Console + project: ProjectShape +} & Record + +export type GeneralHook = ( + this: EXECUTOR, + input: INPUT +) => Promise | void + +export interface ExecutorPluginHooks { + onBeforePlayAll?: GeneralHook + onAfterPlayAll?: GeneralHook + onMessage?: GeneralHook + onStoreWindowHandle?: GeneralHook + onWindowAppeared?: GeneralHook + onWindowSwitched?: GeneralHook +} + +export interface FormatShape { + opts?: { + fileExtension?: string + commandPrefixPadding?: string + terminatingKeyword?: string + commentPrefix?: string + } +} diff --git a/packages/side-executor/src/utils.ts b/packages/side-executor/src/utils.ts new file mode 100644 index 000000000..4a14351b5 --- /dev/null +++ b/packages/side-executor/src/utils.ts @@ -0,0 +1,20 @@ +// 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. + +export function absolutifyUrl(targetUrl: string, baseUrl: string): string { + return new URL(targetUrl, baseUrl).href +} diff --git a/packages/side-executor/src/variables.ts b/packages/side-executor/src/variables.ts new file mode 100644 index 000000000..b8c510d90 --- /dev/null +++ b/packages/side-executor/src/variables.ts @@ -0,0 +1,53 @@ +// 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. + +export type BaseVariableResolver = (key: string) => TYPE + +export default class BaseVariables { + constructor(resolveMethod: BaseVariableResolver) { + this.resolveMethod = resolveMethod + } + resolveMethod: BaseVariableResolver + storedVars: Map = new Map() + + get(key: string) { + if (this.storedVars.has(key)) { + return this.storedVars.get(key) + } + const value = this.resolveMethod(key) + if (value) { + this.storedVars.set(key, value) + } + return value + } + + set(key: string, value: TYPE) { + return this.storedVars.set(key, value) + } + + has(key: string) { + return this.storedVars.has(key) + } + + delete(key: string) { + if (this.storedVars.has(key)) this.storedVars.delete(key) + } + + clear() { + this.storedVars.clear() + } +} diff --git a/packages/side-executor/tsconfig.json b/packages/side-executor/tsconfig.json new file mode 100644 index 000000000..d47e32566 --- /dev/null +++ b/packages/side-executor/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./src", + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["**/__mocks__"], + "references": [ + { + "path": "../side-commons" + }, + { + "path": "../side-model" + }, + { + "path": "../side-testkit" + }, + { + "path": "../webdriver-testkit" + } + ], + "types": ["node", "jest"] +}