diff --git a/package.json b/package.json index c516baf89aa5..f591eadffcdd 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "@types/node-fetch": "^2.5.5", "@types/parse5": "^6.0.0", "@types/sass": "^1.16.0", + "@types/selenium-webdriver": "^3.0.17", "@types/semver": "^7.3.4", "@types/send": "^0.14.5", "@types/stylelint": "^9.10.1", diff --git a/rollup-globals.bzl b/rollup-globals.bzl index e56b94b6c287..fa22fa73e8e5 100644 --- a/rollup-globals.bzl +++ b/rollup-globals.bzl @@ -76,6 +76,7 @@ ROLLUP_GLOBALS = { "protractor": "protractor", "rxjs": "rxjs", "rxjs/operators": "rxjs.operators", + "selenium-webdriver": "selenium-webdriver", } # Converts a string from dash-case to lower camel case. diff --git a/src/cdk/config.bzl b/src/cdk/config.bzl index f3fe4dd25006..1623a570ac84 100644 --- a/src/cdk/config.bzl +++ b/src/cdk/config.bzl @@ -21,6 +21,7 @@ CDK_ENTRYPOINTS = [ "testing", "testing/protractor", "testing/testbed", + "testing/webdriver", ] # List of all entry-point targets of the Angular Material package. diff --git a/src/cdk/testing/BUILD.bazel b/src/cdk/testing/BUILD.bazel index 7683eaeaeae2..78dc2e5cd4b4 100644 --- a/src/cdk/testing/BUILD.bazel +++ b/src/cdk/testing/BUILD.bazel @@ -1,5 +1,6 @@ load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") load("//tools:defaults.bzl", "markdown_to_html", "ng_web_test_suite", "ts_library") +load("//src/cdk/testing/tests:webdriver-test.bzl", "webdriver_test") package(default_visibility = ["//visibility:public"]) @@ -41,8 +42,15 @@ ng_web_test_suite( ) e2e_test_suite( - name = "e2e_tests", + name = "protractor_e2e_tests", deps = [ "//src/cdk/testing/tests:e2e_test_sources", ], ) + +webdriver_test( + name = "webdriver_e2e_tests", + deps = [ + "//src/cdk/testing/tests:webdriver_test_sources", + ], +) diff --git a/src/cdk/testing/tests/BUILD.bazel b/src/cdk/testing/tests/BUILD.bazel index 73084408e73e..342ba6a1c861 100644 --- a/src/cdk/testing/tests/BUILD.bazel +++ b/src/cdk/testing/tests/BUILD.bazel @@ -9,7 +9,6 @@ ng_module( ["**/*.ts"], exclude = [ "**/*.spec.ts", - "**/*.spec.d.ts", "harnesses/**", ], ), @@ -43,7 +42,6 @@ ng_test_library( srcs = glob( [ "**/*.spec.ts", - "**/*.spec.d.ts", ], exclude = [ "cross-environment.spec.ts", @@ -65,10 +63,12 @@ ng_test_library( ng_e2e_test_library( name = "e2e_test_sources", - srcs = glob([ - "**/*.e2e.spec.ts", - "**/*.spec.d.ts", - ]), + srcs = glob( + [ + "**/*.e2e.spec.ts", + ], + exclude = ["webdriver.e2e.spec.ts"], + ), deps = [ ":cross_environment_specs", ":test_harnesses", @@ -77,3 +77,18 @@ ng_e2e_test_library( "//src/cdk/testing/protractor", ], ) + +ts_library( + name = "webdriver_test_sources", + testonly = True, + srcs = ["webdriver.e2e.spec.ts"], + deps = [ + ":cross_environment_specs", + ":test_harnesses", + "//src/cdk/testing", + "//src/cdk/testing/webdriver", + "@npm//@types/jasmine", + "@npm//@types/node", + "@npm//@types/selenium-webdriver", + ], +) diff --git a/src/cdk/testing/tests/kagekiri.spec.d.ts b/src/cdk/testing/tests/kagekiri.spec.d.ts deleted file mode 100644 index bfa43c5ae493..000000000000 --- a/src/cdk/testing/tests/kagekiri.spec.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -// Note: kagekiri is a dev dependency that is used only in our tests to test using a custom -// querySelector function. Do not use this in published code. -declare module 'kagekiri' { - export function querySelectorAll(selector: string, root: Element): NodeListOf; -} diff --git a/src/cdk/testing/tests/protractor.e2e.spec.ts b/src/cdk/testing/tests/protractor.e2e.spec.ts index 8e6de875eecf..395e54d5ec0f 100644 --- a/src/cdk/testing/tests/protractor.e2e.spec.ts +++ b/src/cdk/testing/tests/protractor.e2e.spec.ts @@ -7,10 +7,8 @@ import {MainComponentHarness} from './harnesses/main-component-harness'; // Kagekiri is available globally in the browser. We declare it here so we can use it in the // browser-side script passed to `by.js`. -// TODO(mmalerba): Replace with type-only import once TS 3.8 is available, see: -// https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#type-only-imports-exports declare const kagekiri: { - querySelectorAll: (selector: string, root: Element) => NodeListOf; + querySelectorAll: (selector: string, root: Element) => NodeListOf }; const piercingQueryFn = (selector: string, root: ElementFinder) => protractorElement.all(by.js( diff --git a/src/cdk/testing/tests/webdriver-test.bzl b/src/cdk/testing/tests/webdriver-test.bzl new file mode 100644 index 000000000000..1a61850f3d4b --- /dev/null +++ b/src/cdk/testing/tests/webdriver-test.bzl @@ -0,0 +1,34 @@ +load("//tools:defaults.bzl", "jasmine_node_test") +load("@io_bazel_rules_webtesting//web:web.bzl", "web_test") +load("//tools/server-test:index.bzl", "server_test") + +def webdriver_test(name, data = [], tags = [], **kwargs): + jasmine_node_test( + name = "%s_jasmine_test" % name, + data = data + [ + "@npm//@bazel/typescript", + ], + tags = tags + ["manual"], + **kwargs + ) + + web_test( + name = "%s_chromium_web_test" % name, + browser = "@npm//@angular/dev-infra-private/browsers/chromium:chromium", + tags = tags + ["manual"], + test = ":%s_jasmine_test" % name, + ) + + server_test( + name = "%s_chromium" % name, + server = "//src/e2e-app:devserver", + test = ":%s_chromium_web_test" % name, + tags = tags + ["e2e"], + ) + + native.test_suite( + name = name, + tests = [ + ":%s_chromium" % name, + ], + ) diff --git a/src/cdk/testing/tests/webdriver.e2e.spec.ts b/src/cdk/testing/tests/webdriver.e2e.spec.ts new file mode 100644 index 000000000000..c8eff7366a21 --- /dev/null +++ b/src/cdk/testing/tests/webdriver.e2e.spec.ts @@ -0,0 +1,136 @@ +import {HarnessLoader, parallel} from '@angular/cdk/testing'; +import {waitForAngularReady, WebDriverHarnessEnvironment} from '@angular/cdk/testing/webdriver'; +import * as webdriver from 'selenium-webdriver'; +import {crossEnvironmentSpecs} from './cross-environment.spec'; +import {MainComponentHarness} from './harnesses/main-component-harness'; + +// Tests are flaky on CI unless we increase the timeout. +jasmine.DEFAULT_TIMEOUT_INTERVAL = 10_000; // 10 seconds + +/** + * Metadata file generated by `rules_webtesting` for browser tests. + * The metadata provides configuration for launching the browser and + * necessary capabilities. See source for details: + * https://github.com/bazelbuild/rules_webtesting/blob/06023bb3/web/internal/metadata.bzl#L69-L82 + */ +interface WebTestMetadata { + capabilities: webdriver.Capabilities; +} + +if (process.env['WEB_TEST_METADATA'] === undefined) { + console.error(`Test running outside of a "web_test" target. No browser found.`); + process.exit(1); +} + +const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']!); +const webTestMetadata: WebTestMetadata = + require(runfiles.resolve(process.env['WEB_TEST_METADATA'])); +const port = process.env['TEST_SERVER_PORT']; + +// Kagekiri is available globally in the browser. We declare it here so we can use it in the +// browser-side script passed to `By.js`. +declare const kagekiri: { + querySelectorAll: (selector: string, root: Element) => NodeListOf +}; + +describe('WebDriverHarnessEnvironment', () => { + let wd: webdriver.WebDriver; + + async function getUrl(path: string) { + await wd.get(`http://localhost:${port}${path}`); + await waitForAngularReady(wd); + } + + async function piercingQueryFn(selector: string, root: () => webdriver.WebElement) { + return wd.findElements(webdriver.By.js( + (s: string, r: Element) => kagekiri.querySelectorAll(s, r), selector, root())); + } + + async function activeElement() { + return wd.switchTo().activeElement(); + } + + beforeAll(async () => { + wd = await new webdriver.Builder() + .usingServer(process.env.WEB_TEST_WEBDRIVER_SERVER!) + .withCapabilities(webTestMetadata.capabilities) + .build(); + }); + + afterAll(async () => { + await wd.quit(); + }); + + beforeEach(async () => { + await getUrl('/component-harness'); + }); + + describe('environment specific', () => { + describe('HarnessLoader', () => { + let loader: HarnessLoader; + + beforeEach(() => { + loader = WebDriverHarnessEnvironment.loader(wd); + }); + + it('should create HarnessLoader from WebDriverHarnessEnvironment', () => { + expect(loader).not.toBeNull(); + }); + }); + + describe('ComponentHarness', () => { + let harness: MainComponentHarness; + + beforeEach(async () => { + harness = await WebDriverHarnessEnvironment.loader(wd).getHarness(MainComponentHarness); + }); + + it('can get elements outside of host', async () => { + const globalEl = await harness.globalEl(); + expect(await globalEl.text()).toBe('I am a sibling!'); + }); + + it('should get correct text excluding certain selectors', async () => { + const results = await harness.subcomponentAndSpecialHarnesses(); + const subHarnessHost = await results[0].host(); + + expect(await subHarnessHost.text({exclude: 'h2'})).toBe('ProtractorTestBedOther'); + expect(await subHarnessHost.text({exclude: 'li'})).toBe('List of test tools'); + }); + + it('should be able to retrieve the WebElement from a WebDriverElement', async () => { + const element = WebDriverHarnessEnvironment.getNativeElement(await harness.host()); + expect(await element.getTagName()).toBe('test-main'); + }); + }); + + describe('shadow DOM interaction', () => { + it('should not pierce shadow boundary by default', async () => { + const harness = await WebDriverHarnessEnvironment.loader(wd) + .getHarness(MainComponentHarness); + expect(await harness.shadows()).toEqual([]); + }); + + it('should pierce shadow boundary when using piercing query', async () => { + const harness = await WebDriverHarnessEnvironment.loader(wd, {queryFn: piercingQueryFn}) + .getHarness(MainComponentHarness); + const shadows = await harness.shadows(); + expect(await parallel(() => { + return shadows.map(el => el.text()); + })).toEqual(['Shadow 1', 'Shadow 2']); + }); + + it('should allow querying across shadow boundary', async () => { + const harness = await WebDriverHarnessEnvironment.loader(wd, {queryFn: piercingQueryFn}) + .getHarness(MainComponentHarness); + expect(await (await harness.deepShadow()).text()).toBe('Shadow 2'); + }); + }); + }); + + describe('environment independent', () => crossEnvironmentSpecs( + () => WebDriverHarnessEnvironment.loader(wd), + () => WebDriverHarnessEnvironment.loader(wd).getHarness(MainComponentHarness), + async () => (await activeElement()).getAttribute('id'), + )); +}); diff --git a/src/cdk/testing/webdriver/BUILD.bazel b/src/cdk/testing/webdriver/BUILD.bazel new file mode 100644 index 000000000000..97bdc1a2be25 --- /dev/null +++ b/src/cdk/testing/webdriver/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "webdriver", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/cdk/testing/webdriver", + deps = [ + "//src/cdk/testing", + "@npm//@types/selenium-webdriver", + "@npm//selenium-webdriver", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) diff --git a/src/cdk/testing/webdriver/index.ts b/src/cdk/testing/webdriver/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk/testing/webdriver/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/cdk/testing/webdriver/public-api.ts b/src/cdk/testing/webdriver/public-api.ts new file mode 100644 index 000000000000..1196e2342379 --- /dev/null +++ b/src/cdk/testing/webdriver/public-api.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './webdriver-element'; +export * from './webdriver-harness-environment'; diff --git a/src/cdk/testing/webdriver/webdriver-element.ts b/src/cdk/testing/webdriver/webdriver-element.ts new file mode 100644 index 000000000000..4379921ffaed --- /dev/null +++ b/src/cdk/testing/webdriver/webdriver-element.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + _getTextWithExcludedElements, + ElementDimensions, + EventData, + ModifierKeys, + TestElement, + TestKey, + TextOptions +} from '@angular/cdk/testing'; +import * as webdriver from 'selenium-webdriver'; +import {getWebDriverModifierKeys, webDriverKeyMap} from './webdriver-keys'; + +/** A `TestElement` implementation for WebDriver. */ +export class WebDriverElement implements TestElement { + constructor( + readonly element: () => webdriver.WebElement, + private _stabilize: () => Promise) {} + + async blur(): Promise { + await this._executeScript(((element: HTMLElement) => element.blur()), this.element()); + await this._stabilize(); + } + + async clear(): Promise { + await this.element().clear(); + await this._stabilize(); + } + + async click(...args: [ModifierKeys?] | ['center', ModifierKeys?] | + [number, number, ModifierKeys?]): Promise { + await this._dispatchClickEventSequence(args, webdriver.Button.LEFT); + await this._stabilize(); + } + + async rightClick(...args: [ModifierKeys?] | ['center', ModifierKeys?] | + [number, number, ModifierKeys?]): Promise { + await this._dispatchClickEventSequence(args, webdriver.Button.RIGHT); + await this._stabilize(); + } + + async focus(): Promise { + await this._executeScript((element: HTMLElement) => element.focus(), this.element()); + await this._stabilize(); + } + + async getCssValue(property: string): Promise { + await this._stabilize(); + return this.element().getCssValue(property); + } + + async hover(): Promise { + await this._actions().mouseMove(this.element()).perform(); + await this._stabilize(); + } + + async mouseAway(): Promise { + await this._actions().mouseMove(this.element(), {x: -1, y: -1}).perform(); + await this._stabilize(); + } + + async sendKeys(...keys: (string | TestKey)[]): Promise; + async sendKeys(modifiers: ModifierKeys, ...keys: (string | TestKey)[]): Promise; + async sendKeys(...modifiersAndKeys: any[]): Promise { + const first = modifiersAndKeys[0]; + let modifiers: ModifierKeys; + let rest: (string | TestKey)[]; + if (typeof first !== 'string' && typeof first !== 'number') { + modifiers = first; + rest = modifiersAndKeys.slice(1); + } else { + modifiers = {}; + rest = modifiersAndKeys; + } + + const modifierKeys = getWebDriverModifierKeys(modifiers); + const keys = rest.map(k => typeof k === 'string' ? k.split('') : [webDriverKeyMap[k]]) + .reduce((arr, k) => arr.concat(k), []) + // webdriver.Key.chord doesn't work well with geckodriver (mozilla/geckodriver#1502), + // so avoid it if no modifier keys are required. + .map(k => modifierKeys.length > 0 ? webdriver.Key.chord(...modifierKeys, k) : k); + + await this.element().sendKeys(...keys); + await this._stabilize(); + } + + async text(options?: TextOptions): Promise { + await this._stabilize(); + if (options?.exclude) { + return this._executeScript(_getTextWithExcludedElements, this.element(), options.exclude); + } + return this.element().getText(); + } + + async getAttribute(name: string): Promise { + await this._stabilize(); + return this._executeScript( + (element: Element, attribute: string) => element.getAttribute(attribute), + this.element(), name); + } + + async hasClass(name: string): Promise { + await this._stabilize(); + const classes = (await this.getAttribute('class')) || ''; + return new Set(classes.split(/\s+/).filter(c => c)).has(name); + } + + async getDimensions(): Promise { + await this._stabilize(); + const {width, height} = await this.element().getSize(); + const {x: left, y: top} = await this.element().getLocation(); + return {width, height, left, top}; + } + + async getProperty(name: string): Promise { + await this._stabilize(); + return this._executeScript( + (element: Element, property: keyof Element) => element[property], + this.element(), name); + } + + async setInputValue(newValue: string): Promise { + await this._executeScript( + (element: HTMLInputElement, value: string) => element.value = value, + this.element(), newValue); + await this._stabilize(); + } + + async selectOptions(...optionIndexes: number[]): Promise { + await this._stabilize(); + const options = await this.element().findElements(webdriver.By.css('option')); + const indexes = new Set(optionIndexes); // Convert to a set to remove duplicates. + + if (options.length && indexes.size) { + // Reset the value so all the selected states are cleared. We can + // reuse the input-specific method since the logic is the same. + await this.setInputValue(''); + + for (let i = 0; i < options.length; i++) { + if (indexes.has(i)) { + // We have to hold the control key while clicking on options so that multiple can be + // selected in multi-selection mode. The key doesn't do anything for single selection. + await this._actions().keyDown(webdriver.Key.CONTROL).perform(); + await options[i].click(); + await this._actions().keyUp(webdriver.Key.CONTROL).perform(); + } + } + + await this._stabilize(); + } + } + + async matchesSelector(selector: string): Promise { + await this._stabilize(); + return this._executeScript((element: Element, s: string) => + (Element.prototype.matches || (Element.prototype as any).msMatchesSelector) + .call(element, s), + this.element(), selector); + } + + async isFocused(): Promise { + await this._stabilize(); + return webdriver.WebElement.equals( + this.element(), this.element().getDriver().switchTo().activeElement()); + } + + async dispatchEvent(name: string, data?: Record): Promise { + await this._executeScript(dispatchEvent, name, this.element(), data); + await this._stabilize(); + } + + /** Gets the webdriver action sequence. */ + private _actions() { + return this.element().getDriver().actions(); + } + + /** Executes a function in the browser. */ + private async _executeScript(script: Function, ...var_args: any[]): Promise { + return this.element().getDriver().executeScript(script, ...var_args); + } + + /** Dispatches all the events that are part of a click event sequence. */ + private async _dispatchClickEventSequence( + args: [ModifierKeys?] | ['center', ModifierKeys?] | [number, number, ModifierKeys?], + button: string) { + let modifiers: ModifierKeys = {}; + if (args.length && typeof args[args.length - 1] === 'object') { + modifiers = args.pop() as ModifierKeys; + } + const modifierKeys = getWebDriverModifierKeys(modifiers); + + // Omitting the offset argument to mouseMove results in clicking the center. + // This is the default behavior we want, so we use an empty array of offsetArgs if + // no args remain after popping the modifiers from the args passed to this function. + const offsetArgs = (args.length === 2 ? + [{x: args[0], y: args[1]}] : []) as [{x: number, y: number}]; + + let actions = this._actions().mouseMove(this.element(), ...offsetArgs); + + for (const modifierKey of modifierKeys) { + actions = actions.keyDown(modifierKey); + } + actions = actions.click(button); + for (const modifierKey of modifierKeys) { + actions = actions.keyUp(modifierKey); + } + + await actions.perform(); + } +} + +/** + * Dispatches an event with a particular name and data to an element. Note that this needs to be a + * pure function, because it gets stringified by WebDriver and is executed inside the browser. + */ +function dispatchEvent(name: string, element: Element, data?: Record) { + const event = document.createEvent('Event'); + event.initEvent(name); + // tslint:disable-next-line:ban Have to use `Object.assign` to preserve the original object. + Object.assign(event, data || {}); + element.dispatchEvent(event); +} diff --git a/src/cdk/testing/webdriver/webdriver-harness-environment.ts b/src/cdk/testing/webdriver/webdriver-harness-environment.ts new file mode 100644 index 000000000000..b250107fa597 --- /dev/null +++ b/src/cdk/testing/webdriver/webdriver-harness-environment.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {HarnessEnvironment, HarnessLoader, TestElement} from '@angular/cdk/testing'; +import * as webdriver from 'selenium-webdriver'; +import {WebDriverElement} from './webdriver-element'; + +/** + * An Angular framework stabilizer function that takes a callback and calls it when the application + * is stable, passing a boolean indicating if any work was done. + */ +declare interface FrameworkStabilizer { + (callback: (didWork: boolean) => void): void; +} + +declare global { + interface Window { + /** + * These hooks are exposed by Angular to register a callback for when the application is stable + * (no more pending tasks). + * + * For the implementation, see: https://github.com/ + * angular/angular/blob/master/packages/platform-browser/src/browser/testability.ts#L30-L49 + */ + frameworkStabilizers: FrameworkStabilizer[]; + } +} + +/** Options to configure the environment. */ +export interface WebDriverHarnessEnvironmentOptions { + /** The query function used to find DOM elements. */ + queryFn: (selector: string, root: () => webdriver.WebElement) => Promise; +} + +/** The default environment options. */ +const defaultEnvironmentOptions: WebDriverHarnessEnvironmentOptions = { + queryFn: async (selector: string, root: () => webdriver.WebElement) => + root().findElements(webdriver.By.css(selector)) +}; + +/** + * This function is meant to be executed in the browser. It taps into the hooks exposed by Angular + * and invokes the specified `callback` when the application is stable (no more pending tasks). + */ +function whenStable(callback: (didWork: boolean[]) => void): void { + Promise.all(window.frameworkStabilizers.map(stabilizer => new Promise(stabilizer))) + .then(callback); +} + +/** + * This function is meant to be executed in the browser. It checks whether the Angular framework has + * bootstrapped yet. + */ +function isBootstrapped() { + return !!window.frameworkStabilizers; +} + +/** Waits for angular to be ready after the page load. */ +export async function waitForAngularReady(wd: webdriver.WebDriver) { + await wd.wait(() => wd.executeScript(isBootstrapped)); + await wd.executeAsyncScript(whenStable); +} + +/** A `HarnessEnvironment` implementation for WebDriver. */ +export class WebDriverHarnessEnvironment extends HarnessEnvironment<() => webdriver.WebElement> { + /** The options for this environment. */ + private _options: WebDriverHarnessEnvironmentOptions; + + protected constructor( + rawRootElement: () => webdriver.WebElement, options?: WebDriverHarnessEnvironmentOptions) { + super(rawRootElement); + this._options = {...defaultEnvironmentOptions, ...options}; + } + + /** Gets the ElementFinder corresponding to the given TestElement. */ + static getNativeElement(el: TestElement): webdriver.WebElement { + if (el instanceof WebDriverElement) { + return el.element(); + } + throw Error('This TestElement was not created by the WebDriverHarnessEnvironment'); + } + + /** Creates a `HarnessLoader` rooted at the document root. */ + static loader(driver: webdriver.WebDriver, options?: WebDriverHarnessEnvironmentOptions): + HarnessLoader { + return new WebDriverHarnessEnvironment( + () => driver.findElement(webdriver.By.css('body')), options); + } + + async forceStabilize(): Promise { + await this.rawRootElement().getDriver().executeAsyncScript(whenStable); + } + + async waitForTasksOutsideAngular(): Promise { + // TODO: figure out how we can do this for the webdriver environment. + // https://github.com/angular/components/issues/17412 + } + + protected getDocumentRoot(): () => webdriver.WebElement { + return () => this.rawRootElement().getDriver().findElement(webdriver.By.css('body')); + } + + protected createTestElement(element: () => webdriver.WebElement): TestElement { + return new WebDriverElement(element, () => this.forceStabilize()); + } + + protected createEnvironment(element: () => webdriver.WebElement): + HarnessEnvironment<() => webdriver.WebElement> { + return new WebDriverHarnessEnvironment(element, this._options); + } + + // Note: This seems to be working, though we may need to re-evaluate if we encounter issues with + // stale element references. `() => Promise` seems like a more correct + // return type, though supporting it would require changes to the public harness API. + protected async getAllRawElements(selector: string): Promise<(() => webdriver.WebElement)[]> { + const els = await this._options.queryFn(selector, this.rawRootElement); + return els.map((x: webdriver.WebElement) => () => x); + } +} diff --git a/src/cdk/testing/webdriver/webdriver-keys.ts b/src/cdk/testing/webdriver/webdriver-keys.ts new file mode 100644 index 000000000000..b8a148deed4a --- /dev/null +++ b/src/cdk/testing/webdriver/webdriver-keys.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ModifierKeys, TestKey} from '@angular/cdk/testing'; +import * as webdriver from 'selenium-webdriver'; + +/** + * Maps the `TestKey` constants to WebDriver's `webdriver.Key` constants. + * See https://github.com/SeleniumHQ/selenium/blob/trunk/javascript/webdriver/key.js#L29 + */ +export const webDriverKeyMap = { + [TestKey.BACKSPACE]: webdriver.Key.BACK_SPACE, + [TestKey.TAB]: webdriver.Key.TAB, + [TestKey.ENTER]: webdriver.Key.ENTER, + [TestKey.SHIFT]: webdriver.Key.SHIFT, + [TestKey.CONTROL]: webdriver.Key.CONTROL, + [TestKey.ALT]: webdriver.Key.ALT, + [TestKey.ESCAPE]: webdriver.Key.ESCAPE, + [TestKey.PAGE_UP]: webdriver.Key.PAGE_UP, + [TestKey.PAGE_DOWN]: webdriver.Key.PAGE_DOWN, + [TestKey.END]: webdriver.Key.END, + [TestKey.HOME]: webdriver.Key.HOME, + [TestKey.LEFT_ARROW]: webdriver.Key.ARROW_LEFT, + [TestKey.UP_ARROW]: webdriver.Key.ARROW_UP, + [TestKey.RIGHT_ARROW]: webdriver.Key.ARROW_RIGHT, + [TestKey.DOWN_ARROW]: webdriver.Key.ARROW_DOWN, + [TestKey.INSERT]: webdriver.Key.INSERT, + [TestKey.DELETE]: webdriver.Key.DELETE, + [TestKey.F1]: webdriver.Key.F1, + [TestKey.F2]: webdriver.Key.F2, + [TestKey.F3]: webdriver.Key.F3, + [TestKey.F4]: webdriver.Key.F4, + [TestKey.F5]: webdriver.Key.F5, + [TestKey.F6]: webdriver.Key.F6, + [TestKey.F7]: webdriver.Key.F7, + [TestKey.F8]: webdriver.Key.F8, + [TestKey.F9]: webdriver.Key.F9, + [TestKey.F10]: webdriver.Key.F10, + [TestKey.F11]: webdriver.Key.F11, + [TestKey.F12]: webdriver.Key.F12, + [TestKey.META]: webdriver.Key.META +}; + +/** Gets a list of WebDriver `Key`s for the given `ModifierKeys`. */ +export function getWebDriverModifierKeys(modifiers: ModifierKeys): string[] { + const result: string[] = []; + if (modifiers.control) { + result.push(webdriver.Key.CONTROL); + } + if (modifiers.alt) { + result.push(webdriver.Key.ALT); + } + if (modifiers.shift) { + result.push(webdriver.Key.SHIFT); + } + if (modifiers.meta) { + result.push(webdriver.Key.META); + } + return result; +} diff --git a/src/cdk/tsconfig-tests.json b/src/cdk/tsconfig-tests.json index b645504ff910..0bc1deec6591 100644 --- a/src/cdk/tsconfig-tests.json +++ b/src/cdk/tsconfig-tests.json @@ -27,6 +27,7 @@ "exclude": [ "testing/protractor/**.ts", "testing/private/e2e/**.ts", + "testing/webdriver/**.ts", "**/schematics/**/*.ts", "**/*.e2e.spec.ts" ] diff --git a/tools/public_api_guard/cdk/testing/webdriver.d.ts b/tools/public_api_guard/cdk/testing/webdriver.d.ts new file mode 100644 index 000000000000..d5ad9e883c57 --- /dev/null +++ b/tools/public_api_guard/cdk/testing/webdriver.d.ts @@ -0,0 +1,50 @@ +export declare function waitForAngularReady(wd: webdriver.WebDriver): Promise; + +export declare class WebDriverElement implements TestElement { + readonly element: () => webdriver.WebElement; + constructor(element: () => webdriver.WebElement, _stabilize: () => Promise); + blur(): Promise; + clear(): Promise; + click(...args: [ModifierKeys?] | ['center', ModifierKeys?] | [ + number, + number, + ModifierKeys? + ]): Promise; + dispatchEvent(name: string, data?: Record): Promise; + focus(): Promise; + getAttribute(name: string): Promise; + getCssValue(property: string): Promise; + getDimensions(): Promise; + getProperty(name: string): Promise; + hasClass(name: string): Promise; + hover(): Promise; + isFocused(): Promise; + matchesSelector(selector: string): Promise; + mouseAway(): Promise; + rightClick(...args: [ModifierKeys?] | ['center', ModifierKeys?] | [ + number, + number, + ModifierKeys? + ]): Promise; + selectOptions(...optionIndexes: number[]): Promise; + sendKeys(...keys: (string | TestKey)[]): Promise; + sendKeys(modifiers: ModifierKeys, ...keys: (string | TestKey)[]): Promise; + setInputValue(newValue: string): Promise; + text(options?: TextOptions): Promise; +} + +export declare class WebDriverHarnessEnvironment extends HarnessEnvironment<() => webdriver.WebElement> { + protected constructor(rawRootElement: () => webdriver.WebElement, options?: WebDriverHarnessEnvironmentOptions); + protected createEnvironment(element: () => webdriver.WebElement): HarnessEnvironment<() => webdriver.WebElement>; + protected createTestElement(element: () => webdriver.WebElement): TestElement; + forceStabilize(): Promise; + protected getAllRawElements(selector: string): Promise<(() => webdriver.WebElement)[]>; + protected getDocumentRoot(): () => webdriver.WebElement; + waitForTasksOutsideAngular(): Promise; + static getNativeElement(el: TestElement): webdriver.WebElement; + static loader(driver: webdriver.WebDriver, options?: WebDriverHarnessEnvironmentOptions): HarnessLoader; +} + +export interface WebDriverHarnessEnvironmentOptions { + queryFn: (selector: string, root: () => webdriver.WebElement) => Promise; +} diff --git a/tools/public_api_guard/generate-guard-tests.bzl b/tools/public_api_guard/generate-guard-tests.bzl index f5d9f62dc28b..49d7725a516e 100644 --- a/tools/public_api_guard/generate-guard-tests.bzl +++ b/tools/public_api_guard/generate-guard-tests.bzl @@ -31,7 +31,7 @@ def generate_test_targets(golden_files): ], golden = "angular_material/tools/public_api_guard/%s" % golden_file, use_angular_tag_rules = False, - # Required for the `youtube-player` and `google-maps` packages. "i0" is - # generated by ngtsc for creating directive, module definitions. - allow_module_identifiers = ["YT", "google", "i0"], + # Required for the `youtube-player`, `google-maps`, and `cdk/testing/webdriver` + # packages. "i0" is generated by ngtsc for creating directive, module definitions. + allow_module_identifiers = ["YT", "google", "i0", "webdriver"], ) diff --git a/tools/server-test/BUILD.bazel b/tools/server-test/BUILD.bazel new file mode 100644 index 000000000000..4af9ea7fdd7b --- /dev/null +++ b/tools/server-test/BUILD.bazel @@ -0,0 +1,9 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "test_runner_lib", + srcs = ["test-runner.ts"], + deps = ["@npm//@types/node"], +) diff --git a/tools/server-test/index.bzl b/tools/server-test/index.bzl new file mode 100644 index 000000000000..b50509a13ff9 --- /dev/null +++ b/tools/server-test/index.bzl @@ -0,0 +1,18 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test") + +""" + Runs a given test together with the specified server. The server executable is expected + to support a `--port` command line flag. The chosen available port is then set as environment + variable so that the test environment can connect to the server. Use `TEST_SERVER_PORT`. +""" + +def server_test(server, test, **kwargs): + nodejs_test( + data = [server, test, "//tools/server-test:test_runner_lib"], + args = ["$(rootpath %s)" % server, "$(rootpath %s)" % test], + entry_point = "//tools/server-test:test-runner.ts", + # TODO(josephperrott): update dependency usages to no longer need bazel patch module resolver + # See: https://github.com/bazelbuild/rules_nodejs/wiki#--bazel_patch_module_resolver-now-defaults-to-false-2324 + templated_args = ["--bazel_patch_module_resolver"], + **kwargs + ) diff --git a/tools/server-test/test-runner.ts b/tools/server-test/test-runner.ts new file mode 100644 index 000000000000..b1566153db6d --- /dev/null +++ b/tools/server-test/test-runner.ts @@ -0,0 +1,101 @@ +/** + * A generic test runner for brining up a server on a random port, waiting for the server to bind + * to that port, and then running tests against it. + */ + +const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']!); +import * as child_process from 'child_process'; +import * as net from 'net'; + +/** Checks if the given port is free. */ +function isPortFree(port: number) { + return new Promise(resolve => { + const server = net.createServer(); + server.on('error', () => resolve(false)); + server.on('close', () => resolve(true)); + server.listen(port, () => server.close()); + }); +} + +/** Checks if the given port is bound. */ +function isPortBound(port: number) { + return new Promise(resolve => { + const client = new net.Socket(); + client.once('connect', () => resolve(true)); + client.once('error', () => resolve(false)); + client.connect(port); + }); +} + +/** Gets a random free port in the private port range. */ +async function getRandomFreePort() { + const minPrivatePort = 49152; + const maxPrivatePort = 65535; + let port: number; + do { + port = Math.floor(Math.random() * (maxPrivatePort - minPrivatePort + 1)) + minPrivatePort; + } while (!await isPortFree(port)); + return port; +} + +/** Returns a promise that resolves after the given number of ms. */ +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Returns a promise that resolves when the given port is bound, or rejects if it does not become + * bound within the given timeout duration. + */ +async function waitForPortBound(port: number, timeout: number): Promise { + const isBound = await isPortBound(port); + if (isBound) { + return true; + } + if (timeout <= 0) { + throw new Error('Timeout waiting for server to start'); + } + const wait = Math.min(timeout, 500); + return sleep(wait).then(() => waitForPortBound(port, timeout - wait)); +} + +/** Starts a server and runs a test against it. */ +async function runTest(serverPath: string, testPath: string) { + let server: child_process.ChildProcess | null = null; + return new Promise(async (resolve, reject) => { + const port = await getRandomFreePort(); + + // Expose the chosen test server port so that the test environment can + // connect to the server. + process.env['TEST_SERVER_PORT'] = `${port}`; + + // Start the server. + server = child_process.spawn(serverPath, ['--port', `${port}`], {stdio: 'inherit'}); + server.on('exit', exitCode => { + if (exitCode !== 0) { + reject(Error(`Server exited with error code: ${exitCode}`)); + } + server = null; + }); + + // Wait for the server to bind to the port, then run the tests. + await waitForPortBound(port, 10000); + + const test = child_process.spawnSync(testPath, {stdio: 'inherit'}); + if (test.status === 0) { + resolve(); + } else { + reject(Error(`Test failed`)); + } + }).finally(() => server?.kill()); +} + +if (require.main === module) { + const [serverRootpath, testRootpath] = process.argv.slice(2); + const serverBinPath = runfiles.resolveWorkspaceRelative(serverRootpath); + const testBinPath = runfiles.resolveWorkspaceRelative(testRootpath); + + runTest(serverBinPath, testBinPath) + .then(() => process.exit()) + .catch(() => process.exit(1)); +} diff --git a/yarn.lock b/yarn.lock index 5b744240d10a..a8e9f08ae0c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2122,7 +2122,7 @@ dependencies: "@types/node" "*" -"@types/selenium-webdriver@^3.0.0": +"@types/selenium-webdriver@^3.0.0", "@types/selenium-webdriver@^3.0.17": version "3.0.17" resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-3.0.17.tgz#50bea0c3c2acc31c959c5b1e747798b3b3d06d4b" integrity sha512-tGomyEuzSC1H28y2zlW6XPCaDaXFaD6soTdb4GNdmte2qfHtrKqhy0ZFs4r/1hpazCfEZqeTSRLvSasmEx89uw==