Skip to content

Commit 40ae1b1

Browse files
authored
feat(cdk/testing): Allow custom querySelectorAll method (#18178)
* feat(cdk/testing): Allow custom `querySelectorAll` method This allows the user to drop in a shadow-piercing version of `querySelectorAll` to handle components that use `ViewEncapsulation.ShadowDom` * fix lint and tests * exclude shadow dom tests on browsers that don't support it * WIP: trying to fix import paths for gulp * stop using ng-content * WIP: attempt to get protractor working * make protractor tests work * address comments * address feedback * fix lint and api check * change lint to allow long urls * exclude kagekiri declarations from release * expand type of queryFn * remove use of `+` selector
1 parent 5460fab commit 40ae1b1

23 files changed

+217
-31
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
"husky": "^1.3.1",
116116
"inquirer": "^6.2.0",
117117
"jasmine-core": "^3.5.0",
118+
"kagekiri": "^1.0.18",
118119
"karma": "^4.4.1",
119120
"karma-browserstack-launcher": "^1.3.0",
120121
"karma-chrome-launcher": "^2.2.0",

rollup-globals.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ ROLLUP_GLOBALS = {
6969
"@material/top-app-bar": "mdc.topAppBar",
7070

7171
# Third-party libraries.
72+
"kagekiri": "kagekiri",
7273
"moment": "moment",
7374
"protractor": "protractor",
7475
"rxjs": "rxjs",

src/cdk/testing/BUILD.bazel

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ filegroup(
2626

2727
ng_web_test_suite(
2828
name = "unit_tests",
29-
deps = ["//src/cdk/testing/tests:unit_test_sources"],
29+
# We need to load Kagekiri statically since it is not a named AMD module and needs to
30+
# be manually configured through "require.js" which is used by "karma_web_test_suite".
31+
static_files = [
32+
"@npm//kagekiri",
33+
],
34+
deps = [
35+
":require-config.js",
36+
"//src/cdk/testing/tests:unit_test_sources",
37+
],
3038
)
3139

3240
e2e_test_suite(

src/cdk/testing/protractor/protractor-harness-environment.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,34 @@
77
*/
88

99
import {HarnessEnvironment, HarnessLoader, TestElement} from '@angular/cdk/testing';
10-
import {by, element as protractorElement, ElementFinder} from 'protractor';
10+
import {by, element as protractorElement, ElementArrayFinder, ElementFinder} from 'protractor';
1111
import {ProtractorElement} from './protractor-element';
1212

13+
/** Options to configure the environment. */
14+
export interface ProtractorHarnessEnvironmentOptions {
15+
/** The query function used to find DOM elements. */
16+
queryFn: (selector: string, root: ElementFinder) => ElementArrayFinder;
17+
}
18+
19+
/** The default environment options. */
20+
const defaultEnvironmentOptions: ProtractorHarnessEnvironmentOptions = {
21+
queryFn: (selector: string, root: ElementFinder) => root.all(by.css(selector))
22+
};
23+
1324
/** A `HarnessEnvironment` implementation for Protractor. */
1425
export class ProtractorHarnessEnvironment extends HarnessEnvironment<ElementFinder> {
15-
protected constructor(rawRootElement: ElementFinder) {
26+
/** The options for this environment. */
27+
private _options: ProtractorHarnessEnvironmentOptions;
28+
29+
protected constructor(
30+
rawRootElement: ElementFinder, options?: ProtractorHarnessEnvironmentOptions) {
1631
super(rawRootElement);
32+
this._options = {...defaultEnvironmentOptions, ...options};
1733
}
1834

1935
/** Creates a `HarnessLoader` rooted at the document root. */
20-
static loader(): HarnessLoader {
21-
return new ProtractorHarnessEnvironment(protractorElement(by.css('body')));
36+
static loader(options?: ProtractorHarnessEnvironmentOptions): HarnessLoader {
37+
return new ProtractorHarnessEnvironment(protractorElement(by.css('body')), options);
2238
}
2339

2440
async forceStabilize(): Promise<void> {}
@@ -37,15 +53,15 @@ export class ProtractorHarnessEnvironment extends HarnessEnvironment<ElementFind
3753
}
3854

3955
protected createEnvironment(element: ElementFinder): HarnessEnvironment<ElementFinder> {
40-
return new ProtractorHarnessEnvironment(element);
56+
return new ProtractorHarnessEnvironment(element, this._options);
4157
}
4258

4359
protected async getAllRawElements(selector: string): Promise<ElementFinder[]> {
44-
const elementFinderArray = this.rawRootElement.all(by.css(selector));
45-
const length = await elementFinderArray.count();
60+
const elementArrayFinder = this._options.queryFn(selector, this.rawRootElement);
61+
const length = await elementArrayFinder.count();
4662
const elements: ElementFinder[] = [];
4763
for (let i = 0; i < length; i++) {
48-
elements.push(elementFinderArray.get(i));
64+
elements.push(elementArrayFinder.get(i));
4965
}
5066
return elements;
5167
}

src/cdk/testing/require-config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Require.js is being used by the karma bazel rules and needs to be configured to properly
2+
// load AMD modules which are not explicitly named in their output bundle.
3+
require.config({
4+
paths: {
5+
'kagekiri': '/base/npm/node_modules/kagekiri/dist/kagekiri.umd.min',
6+
}
7+
});

src/cdk/testing/testbed/testbed-harness-environment.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,49 @@ import {takeWhile} from 'rxjs/operators';
1919
import {TaskState, TaskStateZoneInterceptor} from './task-state-zone-interceptor';
2020
import {UnitTestElement} from './unit-test-element';
2121

22+
/** Options to configure the environment. */
23+
export interface TestbedHarnessEnvironmentOptions {
24+
/** The query function used to find DOM elements. */
25+
queryFn: (selector: string, root: Element) => Iterable<Element> | ArrayLike<Element>;
26+
}
27+
28+
/** The default environment options. */
29+
const defaultEnvironmentOptions: TestbedHarnessEnvironmentOptions = {
30+
queryFn: (selector: string, root: Element) => root.querySelectorAll(selector)
31+
};
2232

2333
/** A `HarnessEnvironment` implementation for Angular's Testbed. */
2434
export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
35+
/** Whether the environment has been destroyed. */
2536
private _destroyed = false;
2637

2738
/** Observable that emits whenever the test task state changes. */
2839
private _taskState: Observable<TaskState>;
2940

30-
protected constructor(rawRootElement: Element, private _fixture: ComponentFixture<unknown>) {
41+
/** The options for this environment. */
42+
private _options: TestbedHarnessEnvironmentOptions;
43+
44+
protected constructor(rawRootElement: Element, private _fixture: ComponentFixture<unknown>,
45+
options?: TestbedHarnessEnvironmentOptions) {
3146
super(rawRootElement);
47+
this._options = {...defaultEnvironmentOptions, ...options};
3248
this._taskState = TaskStateZoneInterceptor.setup();
3349
_fixture.componentRef.onDestroy(() => this._destroyed = true);
3450
}
3551

3652
/** Creates a `HarnessLoader` rooted at the given fixture's root element. */
37-
static loader(fixture: ComponentFixture<unknown>): HarnessLoader {
38-
return new TestbedHarnessEnvironment(fixture.nativeElement, fixture);
53+
static loader(fixture: ComponentFixture<unknown>, options?: TestbedHarnessEnvironmentOptions):
54+
HarnessLoader {
55+
return new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
3956
}
4057

4158
/**
4259
* Creates a `HarnessLoader` at the document root. This can be used if harnesses are
4360
* located outside of a fixture (e.g. overlays appended to the document body).
4461
*/
45-
static documentRootLoader(fixture: ComponentFixture<unknown>): HarnessLoader {
46-
return new TestbedHarnessEnvironment(document.body, fixture);
62+
static documentRootLoader(fixture: ComponentFixture<unknown>,
63+
options?: TestbedHarnessEnvironmentOptions): HarnessLoader {
64+
return new TestbedHarnessEnvironment(document.body, fixture, options);
4765
}
4866

4967
/**
@@ -53,8 +71,9 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
5371
* of the fixture.
5472
*/
5573
static async harnessForFixture<T extends ComponentHarness>(
56-
fixture: ComponentFixture<unknown>, harnessType: ComponentHarnessConstructor<T>): Promise<T> {
57-
const environment = new TestbedHarnessEnvironment(fixture.nativeElement, fixture);
74+
fixture: ComponentFixture<unknown>, harnessType: ComponentHarnessConstructor<T>,
75+
options?: TestbedHarnessEnvironmentOptions): Promise<T> {
76+
const environment = new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
5877
await environment.forceStabilize();
5978
return environment.createComponentHarness(harnessType, fixture.nativeElement);
6079
}
@@ -95,11 +114,11 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
95114
}
96115

97116
protected createEnvironment(element: Element): HarnessEnvironment<Element> {
98-
return new TestbedHarnessEnvironment(element, this._fixture);
117+
return new TestbedHarnessEnvironment(element, this._fixture, this._options);
99118
}
100119

101120
protected async getAllRawElements(selector: string): Promise<Element[]> {
102121
await this.forceStabilize();
103-
return Array.from(this.rawRootElement.querySelectorAll(selector));
122+
return Array.from(this._options.queryFn(selector, this.rawRootElement));
104123
}
105124
}

src/cdk/testing/tests/BUILD.bazel

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ ng_module(
99
["**/*.ts"],
1010
exclude = [
1111
"**/*.spec.ts",
12+
"**/*.spec.d.ts",
1213
"harnesses/**",
1314
],
1415
),
1516
assets = glob(["**/*.html"]),
1617
deps = [
1718
"//src/cdk/keycodes",
19+
"//src/cdk/platform",
1820
"@npm//@angular/forms",
1921
],
2022
)
@@ -30,22 +32,30 @@ ts_library(
3032
ng_test_library(
3133
name = "unit_test_sources",
3234
srcs = glob(
33-
["**/*.spec.ts"],
35+
[
36+
"**/*.spec.ts",
37+
"**/*.spec.d.ts",
38+
],
3439
exclude = ["**/*.e2e.spec.ts"],
3540
),
3641
deps = [
3742
":test_components",
3843
":test_harnesses",
44+
"//src/cdk/platform",
3945
"//src/cdk/testing",
4046
"//src/cdk/testing/private",
4147
"//src/cdk/testing/testbed",
4248
"@npm//@angular/platform-browser",
49+
"@npm//kagekiri",
4350
],
4451
)
4552

4653
ng_e2e_test_library(
4754
name = "e2e_test_sources",
48-
srcs = glob(["**/*.e2e.spec.ts"]),
55+
srcs = glob([
56+
"**/*.e2e.spec.ts",
57+
"**/*.spec.d.ts",
58+
]),
4959
deps = [
5060
":test_harnesses",
5161
"//src/cdk/testing",

src/cdk/testing/tests/harnesses/main-component-harness.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export class MainComponentHarness extends ComponentHarness {
8181
this.locatorForAll(SubComponentHarness, SubComponentSpecialHarness);
8282
readonly missingElementsAndHarnesses =
8383
this.locatorFor('.not-found', SubComponentHarness.with({title: /not found/}));
84+
readonly shadows = this.locatorForAll('.in-the-shadows');
85+
readonly deepShadow = this.locatorFor(
86+
'test-shadow-boundary test-sub-shadow-boundary > .in-the-shadows');
8487

8588
private _testTools = this.locatorFor(SubComponentHarness);
8689

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// Note: kagekiri is a dev dependency that is used only in our tests to test using a custom
10+
// querySelector function. Do not use this in published code.
11+
declare module 'kagekiri' {
12+
export function querySelectorAll(selector: string, root: Element): NodeListOf<Element>;
13+
}

src/cdk/testing/tests/protractor.e2e.spec.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@ import {
55
TestElement
66
} from '@angular/cdk/testing';
77
import {ProtractorHarnessEnvironment} from '@angular/cdk/testing/protractor';
8-
import {browser} from 'protractor';
8+
import {browser, by, element as protractorElement, ElementFinder} from 'protractor';
99
import {MainComponentHarness} from './harnesses/main-component-harness';
1010
import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness';
1111

12+
// Kagekiri is available globally in the browser. We declare it here so we can use it in the
13+
// browser-side script passed to `by.js`.
14+
// TODO(mmalerba): Replace with type-only import once TS 3.8 is available, see:
15+
// https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#type-only-imports-exports
16+
declare const kagekiri: {
17+
querySelectorAll: (selector: string, root: Element) => NodeListOf<Element>;
18+
};
19+
20+
const piercingQueryFn = (selector: string, root: ElementFinder) => protractorElement.all(by.js(
21+
(s: string, r: Element) => kagekiri.querySelectorAll(s, r), selector, root.getWebElement()));
22+
1223
describe('ProtractorHarnessEnvironment', () => {
1324
beforeEach(async () => {
1425
await browser.get('/component-harness');
@@ -460,6 +471,27 @@ describe('ProtractorHarnessEnvironment', () => {
460471
}
461472
});
462473
});
474+
475+
describe('shadow DOM interaction', () => {
476+
it('should not pierce shadow boundary by default', async () => {
477+
const harness = await ProtractorHarnessEnvironment.loader()
478+
.getHarness(MainComponentHarness);
479+
expect(await harness.shadows()).toEqual([]);
480+
});
481+
482+
it('should pierce shadow boundary when using piercing query', async () => {
483+
const harness = await ProtractorHarnessEnvironment.loader({queryFn: piercingQueryFn})
484+
.getHarness(MainComponentHarness);
485+
const shadows = await harness.shadows();
486+
expect(await Promise.all(shadows.map(el => el.text()))).toEqual(['Shadow 1', 'Shadow 2']);
487+
});
488+
489+
it('should allow querying across shadow boundary', async () => {
490+
const harness = await ProtractorHarnessEnvironment.loader({queryFn: piercingQueryFn})
491+
.getHarness(MainComponentHarness);
492+
expect(await (await harness.deepShadow()).text()).toBe('Shadow 2');
493+
});
494+
});
463495
});
464496

465497
async function checkIsElement(result: ComponentHarness | TestElement, selector?: string) {

src/cdk/testing/tests/test-components-module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import {CommonModule} from '@angular/common';
1010
import {NgModule} from '@angular/core';
1111
import {FormsModule} from '@angular/forms';
1212
import {TestMainComponent} from './test-main-component';
13+
import {TestShadowBoundary, TestSubShadowBoundary} from './test-shadow-boundary';
1314
import {TestSubComponent} from './test-sub-component';
1415

1516
@NgModule({
1617
imports: [CommonModule, FormsModule],
17-
declarations: [TestMainComponent, TestSubComponent],
18-
exports: [TestMainComponent, TestSubComponent]
18+
declarations: [TestMainComponent, TestSubComponent, TestShadowBoundary, TestSubShadowBoundary],
19+
exports: [TestMainComponent, TestSubComponent, TestShadowBoundary, TestSubShadowBoundary]
1920
})
2021
export class TestComponentsModule {}

src/cdk/testing/tests/test-main-component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
3232
</button>
3333
<span id="task-state-result" #taskStateResult></span>
3434
</div>
35-
35+
<test-shadow-boundary *ngIf="_shadowDomSupported"></test-shadow-boundary>

src/cdk/testing/tests/test-main-component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {ENTER} from '@angular/cdk/keycodes';
10+
import {_supportsShadowDom} from '@angular/cdk/platform';
1011
import {
1112
ChangeDetectionStrategy,
1213
ChangeDetectorRef,
@@ -42,6 +43,7 @@ export class TestMainComponent implements OnDestroy {
4243
specialKey = '';
4344
relativeX = 0;
4445
relativeY = 0;
46+
_shadowDomSupported = _supportsShadowDom();
4547

4648
@ViewChild('clickTestElement') clickTestElement: ElementRef<HTMLElement>;
4749
@ViewChild('taskStateResult') taskStateResultElement: ElementRef<HTMLElement>;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
10+
11+
@Component({
12+
selector: 'test-shadow-boundary',
13+
template: `
14+
<div class="in-the-shadows">Shadow 1</div>
15+
<test-sub-shadow-boundary></test-sub-shadow-boundary>
16+
`,
17+
changeDetection: ChangeDetectionStrategy.OnPush,
18+
// tslint:disable-next-line:validate-decorators
19+
encapsulation: ViewEncapsulation.ShadowDom,
20+
})
21+
export class TestShadowBoundary {}
22+
23+
@Component({
24+
selector: 'test-sub-shadow-boundary',
25+
template: '<div class="in-the-shadows">Shadow 2</div>',
26+
changeDetection: ChangeDetectionStrategy.OnPush,
27+
// tslint:disable-next-line:validate-decorators
28+
encapsulation: ViewEncapsulation.ShadowDom,
29+
})
30+
export class TestSubShadowBoundary {}

0 commit comments

Comments
 (0)