UNPKG

33.3 kBJavaScriptView Raw
1import { getNoKeysSpecifiedError, TestKey, _getTextWithExcludedElements, handleAutoChangeDetectionStatus, stopHandlingAutoChangeDetectionStatus, HarnessEnvironment } from '@angular/cdk/testing';
2import { flush } from '@angular/core/testing';
3import { takeWhile } from 'rxjs/operators';
4import { BehaviorSubject } from 'rxjs';
5import * as keyCodes from '@angular/cdk/keycodes';
6import { PERIOD } from '@angular/cdk/keycodes';
7
8/** Unique symbol that is used to patch a property to a proxy zone. */
9const stateObservableSymbol = Symbol('ProxyZone_PATCHED#stateObservable');
10/**
11 * Interceptor that can be set up in a `ProxyZone` instance. The interceptor
12 * will keep track of the task state and emit whenever the state changes.
13 *
14 * This serves as a workaround for https://github.com/angular/angular/issues/32896.
15 */
16class TaskStateZoneInterceptor {
17 constructor(_lastState) {
18 this._lastState = _lastState;
19 /** Subject that can be used to emit a new state change. */
20 this._stateSubject = new BehaviorSubject(this._lastState ? this._getTaskStateFromInternalZoneState(this._lastState) : { stable: true });
21 /** Public observable that emits whenever the task state changes. */
22 this.state = this._stateSubject;
23 }
24 /** This will be called whenever the task state changes in the intercepted zone. */
25 onHasTask(delegate, current, target, hasTaskState) {
26 if (current === target) {
27 this._stateSubject.next(this._getTaskStateFromInternalZoneState(hasTaskState));
28 }
29 }
30 /** Gets the task state from the internal ZoneJS task state. */
31 _getTaskStateFromInternalZoneState(state) {
32 return { stable: !state.macroTask && !state.microTask };
33 }
34 /**
35 * Sets up the custom task state Zone interceptor in the `ProxyZone`. Throws if
36 * no `ProxyZone` could be found.
37 * @returns an observable that emits whenever the task state changes.
38 */
39 static setup() {
40 if (Zone === undefined) {
41 throw Error('Could not find ZoneJS. For test harnesses running in TestBed, ' +
42 'ZoneJS needs to be installed.');
43 }
44 // tslint:disable-next-line:variable-name
45 const ProxyZoneSpec = Zone['ProxyZoneSpec'];
46 // If there is no "ProxyZoneSpec" installed, we throw an error and recommend
47 // setting up the proxy zone by pulling in the testing bundle.
48 if (!ProxyZoneSpec) {
49 throw Error('ProxyZoneSpec is needed for the test harnesses but could not be found. ' +
50 'Please make sure that your environment includes zone.js/dist/zone-testing.js');
51 }
52 // Ensure that there is a proxy zone instance set up, and get
53 // a reference to the instance if present.
54 const zoneSpec = ProxyZoneSpec.assertPresent();
55 // If there already is a delegate registered in the proxy zone, and it
56 // is type of the custom task state interceptor, we just use that state
57 // observable. This allows us to only intercept Zone once per test
58 // (similar to how `fakeAsync` or `async` work).
59 if (zoneSpec[stateObservableSymbol]) {
60 return zoneSpec[stateObservableSymbol];
61 }
62 // Since we intercept on environment creation and the fixture has been
63 // created before, we might have missed tasks scheduled before. Fortunately
64 // the proxy zone keeps track of the previous task state, so we can just pass
65 // this as initial state to the task zone interceptor.
66 const interceptor = new TaskStateZoneInterceptor(zoneSpec.lastTaskState);
67 const zoneSpecOnHasTask = zoneSpec.onHasTask.bind(zoneSpec);
68 // We setup the task state interceptor in the `ProxyZone`. Note that we cannot register
69 // the interceptor as a new proxy zone delegate because it would mean that other zone
70 // delegates (e.g. `FakeAsyncTestZone` or `AsyncTestZone`) can accidentally overwrite/disable
71 // our interceptor. Since we just intend to monitor the task state of the proxy zone, it is
72 // sufficient to just patch the proxy zone. This also avoids that we interfere with the task
73 // queue scheduling logic.
74 zoneSpec.onHasTask = function (...args) {
75 zoneSpecOnHasTask(...args);
76 interceptor.onHasTask(...args);
77 };
78 return (zoneSpec[stateObservableSymbol] = interceptor.state);
79 }
80}
81
82/** Used to generate unique IDs for events. */
83let uniqueIds = 0;
84/**
85 * Creates a browser MouseEvent with the specified options.
86 * @docs-private
87 */
88function createMouseEvent(type, clientX = 0, clientY = 0, offsetX = 1, offsetY = 1, button = 0, modifiers = {}) {
89 // Note: We cannot determine the position of the mouse event based on the screen
90 // because the dimensions and position of the browser window are not available
91 // To provide reasonable `screenX` and `screenY` coordinates, we simply use the
92 // client coordinates as if the browser is opened in fullscreen.
93 const screenX = clientX;
94 const screenY = clientY;
95 const event = new MouseEvent(type, {
96 bubbles: true,
97 cancelable: true,
98 composed: true,
99 view: window,
100 detail: 0,
101 relatedTarget: null,
102 screenX,
103 screenY,
104 clientX,
105 clientY,
106 ctrlKey: modifiers.control,
107 altKey: modifiers.alt,
108 shiftKey: modifiers.shift,
109 metaKey: modifiers.meta,
110 button: button,
111 buttons: 1,
112 });
113 // The `MouseEvent` constructor doesn't allow us to pass these properties into the constructor.
114 // Override them to `1`, because they're used for fake screen reader event detection.
115 if (offsetX != null) {
116 defineReadonlyEventProperty(event, 'offsetX', offsetX);
117 }
118 if (offsetY != null) {
119 defineReadonlyEventProperty(event, 'offsetY', offsetY);
120 }
121 return event;
122}
123/**
124 * Creates a browser `PointerEvent` with the specified options. Pointer events
125 * by default will appear as if they are the primary pointer of their type.
126 * https://www.w3.org/TR/pointerevents2/#dom-pointerevent-isprimary.
127 *
128 * For example, if pointer events for a multi-touch interaction are created, the non-primary
129 * pointer touches would need to be represented by non-primary pointer events.
130 *
131 * @docs-private
132 */
133function createPointerEvent(type, clientX = 0, clientY = 0, offsetX, offsetY, options = { isPrimary: true }) {
134 const event = new PointerEvent(type, {
135 bubbles: true,
136 cancelable: true,
137 composed: true,
138 view: window,
139 clientX,
140 clientY,
141 ...options,
142 });
143 if (offsetX != null) {
144 defineReadonlyEventProperty(event, 'offsetX', offsetX);
145 }
146 if (offsetY != null) {
147 defineReadonlyEventProperty(event, 'offsetY', offsetY);
148 }
149 return event;
150}
151/**
152 * Creates a browser TouchEvent with the specified pointer coordinates.
153 * @docs-private
154 */
155function createTouchEvent(type, pageX = 0, pageY = 0, clientX = 0, clientY = 0) {
156 // We cannot use the `TouchEvent` or `Touch` because Firefox and Safari lack support.
157 // TODO: Switch to the constructor API when it is available for Firefox and Safari.
158 const event = document.createEvent('UIEvent');
159 const touchDetails = { pageX, pageY, clientX, clientY, identifier: uniqueIds++ };
160 // TS3.6 removes the initUIEvent method and suggests porting to "new UIEvent()".
161 event.initUIEvent(type, true, true, window, 0);
162 // Most of the browsers don't have a "initTouchEvent" method that can be used to define
163 // the touch details.
164 defineReadonlyEventProperty(event, 'touches', [touchDetails]);
165 defineReadonlyEventProperty(event, 'targetTouches', [touchDetails]);
166 defineReadonlyEventProperty(event, 'changedTouches', [touchDetails]);
167 return event;
168}
169/**
170 * Creates a keyboard event with the specified key and modifiers.
171 * @docs-private
172 */
173function createKeyboardEvent(type, keyCode = 0, key = '', modifiers = {}) {
174 return new KeyboardEvent(type, {
175 bubbles: true,
176 cancelable: true,
177 composed: true,
178 view: window,
179 keyCode: keyCode,
180 key: key,
181 shiftKey: modifiers.shift,
182 metaKey: modifiers.meta,
183 altKey: modifiers.alt,
184 ctrlKey: modifiers.control,
185 });
186}
187/**
188 * Creates a fake event object with any desired event type.
189 * @docs-private
190 */
191function createFakeEvent(type, bubbles = false, cancelable = true, composed = true) {
192 return new Event(type, { bubbles, cancelable, composed });
193}
194/**
195 * Defines a readonly property on the given event object. Readonly properties on an event object
196 * are always set as configurable as that matches default readonly properties for DOM event objects.
197 */
198function defineReadonlyEventProperty(event, propertyName, value) {
199 Object.defineProperty(event, propertyName, { get: () => value, configurable: true });
200}
201
202/**
203 * Utility to dispatch any event on a Node.
204 * @docs-private
205 */
206function dispatchEvent(node, event) {
207 node.dispatchEvent(event);
208 return event;
209}
210/**
211 * Shorthand to dispatch a fake event on a specified node.
212 * @docs-private
213 */
214function dispatchFakeEvent(node, type, bubbles) {
215 return dispatchEvent(node, createFakeEvent(type, bubbles));
216}
217/**
218 * Shorthand to dispatch a keyboard event with a specified key code and
219 * optional modifiers.
220 * @docs-private
221 */
222function dispatchKeyboardEvent(node, type, keyCode, key, modifiers) {
223 return dispatchEvent(node, createKeyboardEvent(type, keyCode, key, modifiers));
224}
225/**
226 * Shorthand to dispatch a mouse event on the specified coordinates.
227 * @docs-private
228 */
229function dispatchMouseEvent(node, type, clientX = 0, clientY = 0, offsetX, offsetY, button, modifiers) {
230 return dispatchEvent(node, createMouseEvent(type, clientX, clientY, offsetX, offsetY, button, modifiers));
231}
232/**
233 * Shorthand to dispatch a pointer event on the specified coordinates.
234 * @docs-private
235 */
236function dispatchPointerEvent(node, type, clientX = 0, clientY = 0, offsetX, offsetY, options) {
237 return dispatchEvent(node, createPointerEvent(type, clientX, clientY, offsetX, offsetY, options));
238}
239/**
240 * Shorthand to dispatch a touch event on the specified coordinates.
241 * @docs-private
242 */
243function dispatchTouchEvent(node, type, pageX = 0, pageY = 0, clientX = 0, clientY = 0) {
244 return dispatchEvent(node, createTouchEvent(type, pageX, pageY, clientX, clientY));
245}
246
247function triggerFocusChange(element, event) {
248 let eventFired = false;
249 const handler = () => (eventFired = true);
250 element.addEventListener(event, handler);
251 element[event]();
252 element.removeEventListener(event, handler);
253 if (!eventFired) {
254 dispatchFakeEvent(element, event);
255 }
256}
257/**
258 * Patches an elements focus and blur methods to emit events consistently and predictably.
259 * This is necessary, because some browsers can call the focus handlers asynchronously,
260 * while others won't fire them at all if the browser window is not focused.
261 * @docs-private
262 */
263// TODO: Check if this element focus patching is still needed for local testing,
264// where browser is not necessarily focused.
265function patchElementFocus(element) {
266 element.focus = () => dispatchFakeEvent(element, 'focus');
267 element.blur = () => dispatchFakeEvent(element, 'blur');
268}
269/** @docs-private */
270function triggerFocus(element) {
271 triggerFocusChange(element, 'focus');
272}
273/** @docs-private */
274function triggerBlur(element) {
275 triggerFocusChange(element, 'blur');
276}
277
278/** Input types for which the value can be entered incrementally. */
279const incrementalInputTypes = new Set([
280 'text',
281 'email',
282 'hidden',
283 'password',
284 'search',
285 'tel',
286 'url',
287]);
288/**
289 * Checks whether the given Element is a text input element.
290 * @docs-private
291 */
292function isTextInput(element) {
293 const nodeName = element.nodeName.toLowerCase();
294 return nodeName === 'input' || nodeName === 'textarea';
295}
296function typeInElement(element, ...modifiersAndKeys) {
297 const first = modifiersAndKeys[0];
298 let modifiers;
299 let rest;
300 if (first !== undefined &&
301 typeof first !== 'string' &&
302 first.keyCode === undefined &&
303 first.key === undefined) {
304 modifiers = first;
305 rest = modifiersAndKeys.slice(1);
306 }
307 else {
308 modifiers = {};
309 rest = modifiersAndKeys;
310 }
311 const isInput = isTextInput(element);
312 const inputType = element.getAttribute('type') || 'text';
313 const keys = rest
314 .map(k => typeof k === 'string'
315 ? k.split('').map(c => ({ keyCode: c.toUpperCase().charCodeAt(0), key: c }))
316 : [k])
317 .reduce((arr, k) => arr.concat(k), []);
318 // Throw an error if no keys have been specified. Calling this function with no
319 // keys should not result in a focus event being dispatched unexpectedly.
320 if (keys.length === 0) {
321 throw getNoKeysSpecifiedError();
322 }
323 // We simulate the user typing in a value by incrementally assigning the value below. The problem
324 // is that for some input types, the browser won't allow for an invalid value to be set via the
325 // `value` property which will always be the case when going character-by-character. If we detect
326 // such an input, we have to set the value all at once or listeners to the `input` event (e.g.
327 // the `ReactiveFormsModule` uses such an approach) won't receive the correct value.
328 const enterValueIncrementally = inputType === 'number'
329 ? // The value can be set character by character in number inputs if it doesn't have any decimals.
330 keys.every(key => key.key !== '.' && key.key !== '-' && key.keyCode !== PERIOD)
331 : incrementalInputTypes.has(inputType);
332 triggerFocus(element);
333 // When we aren't entering the value incrementally, assign it all at once ahead
334 // of time so that any listeners to the key events below will have access to it.
335 if (!enterValueIncrementally) {
336 element.value = keys.reduce((value, key) => value + (key.key || ''), '');
337 }
338 for (const key of keys) {
339 dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);
340 dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers);
341 if (isInput && key.key && key.key.length === 1) {
342 if (enterValueIncrementally) {
343 element.value += key.key;
344 dispatchFakeEvent(element, 'input');
345 }
346 }
347 dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers);
348 }
349 // Since we weren't dispatching `input` events while sending the keys, we have to do it now.
350 if (!enterValueIncrementally) {
351 dispatchFakeEvent(element, 'input');
352 }
353}
354/**
355 * Clears the text in an input or textarea element.
356 * @docs-private
357 */
358function clearElement(element) {
359 triggerFocus(element);
360 element.value = '';
361 dispatchFakeEvent(element, 'input');
362}
363
364// These are private APIs that are used both by the public APIs inside of this package, as well
365
366/** Maps `TestKey` constants to the `keyCode` and `key` values used by native browser events. */
367const keyMap = {
368 [TestKey.BACKSPACE]: { keyCode: keyCodes.BACKSPACE, key: 'Backspace' },
369 [TestKey.TAB]: { keyCode: keyCodes.TAB, key: 'Tab' },
370 [TestKey.ENTER]: { keyCode: keyCodes.ENTER, key: 'Enter' },
371 [TestKey.SHIFT]: { keyCode: keyCodes.SHIFT, key: 'Shift' },
372 [TestKey.CONTROL]: { keyCode: keyCodes.CONTROL, key: 'Control' },
373 [TestKey.ALT]: { keyCode: keyCodes.ALT, key: 'Alt' },
374 [TestKey.ESCAPE]: { keyCode: keyCodes.ESCAPE, key: 'Escape' },
375 [TestKey.PAGE_UP]: { keyCode: keyCodes.PAGE_UP, key: 'PageUp' },
376 [TestKey.PAGE_DOWN]: { keyCode: keyCodes.PAGE_DOWN, key: 'PageDown' },
377 [TestKey.END]: { keyCode: keyCodes.END, key: 'End' },
378 [TestKey.HOME]: { keyCode: keyCodes.HOME, key: 'Home' },
379 [TestKey.LEFT_ARROW]: { keyCode: keyCodes.LEFT_ARROW, key: 'ArrowLeft' },
380 [TestKey.UP_ARROW]: { keyCode: keyCodes.UP_ARROW, key: 'ArrowUp' },
381 [TestKey.RIGHT_ARROW]: { keyCode: keyCodes.RIGHT_ARROW, key: 'ArrowRight' },
382 [TestKey.DOWN_ARROW]: { keyCode: keyCodes.DOWN_ARROW, key: 'ArrowDown' },
383 [TestKey.INSERT]: { keyCode: keyCodes.INSERT, key: 'Insert' },
384 [TestKey.DELETE]: { keyCode: keyCodes.DELETE, key: 'Delete' },
385 [TestKey.F1]: { keyCode: keyCodes.F1, key: 'F1' },
386 [TestKey.F2]: { keyCode: keyCodes.F2, key: 'F2' },
387 [TestKey.F3]: { keyCode: keyCodes.F3, key: 'F3' },
388 [TestKey.F4]: { keyCode: keyCodes.F4, key: 'F4' },
389 [TestKey.F5]: { keyCode: keyCodes.F5, key: 'F5' },
390 [TestKey.F6]: { keyCode: keyCodes.F6, key: 'F6' },
391 [TestKey.F7]: { keyCode: keyCodes.F7, key: 'F7' },
392 [TestKey.F8]: { keyCode: keyCodes.F8, key: 'F8' },
393 [TestKey.F9]: { keyCode: keyCodes.F9, key: 'F9' },
394 [TestKey.F10]: { keyCode: keyCodes.F10, key: 'F10' },
395 [TestKey.F11]: { keyCode: keyCodes.F11, key: 'F11' },
396 [TestKey.F12]: { keyCode: keyCodes.F12, key: 'F12' },
397 [TestKey.META]: { keyCode: keyCodes.META, key: 'Meta' },
398};
399/** A `TestElement` implementation for unit tests. */
400class UnitTestElement {
401 constructor(element, _stabilize) {
402 this.element = element;
403 this._stabilize = _stabilize;
404 }
405 /** Blur the element. */
406 async blur() {
407 triggerBlur(this.element);
408 await this._stabilize();
409 }
410 /** Clear the element's input (for input and textarea elements only). */
411 async clear() {
412 if (!isTextInput(this.element)) {
413 throw Error('Attempting to clear an invalid element');
414 }
415 clearElement(this.element);
416 await this._stabilize();
417 }
418 async click(...args) {
419 const isDisabled = this.element.disabled === true;
420 // If the element is `disabled` and has a `disabled` property, we emit the mouse event
421 // sequence but not dispatch the `click` event. This is necessary to keep the behavior
422 // consistent with an actual user interaction. The click event is not necessarily
423 // automatically prevented by the browser. There is mismatch between Firefox and Chromium:
424 // https://bugzilla.mozilla.org/show_bug.cgi?id=329509.
425 // https://bugs.chromium.org/p/chromium/issues/detail?id=1115661.
426 await this._dispatchMouseEventSequence(isDisabled ? null : 'click', args, 0);
427 await this._stabilize();
428 }
429 async rightClick(...args) {
430 await this._dispatchMouseEventSequence('contextmenu', args, 2);
431 await this._stabilize();
432 }
433 /** Focus the element. */
434 async focus() {
435 triggerFocus(this.element);
436 await this._stabilize();
437 }
438 /** Get the computed value of the given CSS property for the element. */
439 async getCssValue(property) {
440 await this._stabilize();
441 // TODO(mmalerba): Consider adding value normalization if we run into common cases where its
442 // needed.
443 return getComputedStyle(this.element).getPropertyValue(property);
444 }
445 /** Hovers the mouse over the element. */
446 async hover() {
447 this._dispatchPointerEventIfSupported('pointerenter');
448 dispatchMouseEvent(this.element, 'mouseover');
449 dispatchMouseEvent(this.element, 'mouseenter');
450 await this._stabilize();
451 }
452 /** Moves the mouse away from the element. */
453 async mouseAway() {
454 this._dispatchPointerEventIfSupported('pointerleave');
455 dispatchMouseEvent(this.element, 'mouseout');
456 dispatchMouseEvent(this.element, 'mouseleave');
457 await this._stabilize();
458 }
459 async sendKeys(...modifiersAndKeys) {
460 const args = modifiersAndKeys.map(k => (typeof k === 'number' ? keyMap[k] : k));
461 typeInElement(this.element, ...args);
462 await this._stabilize();
463 }
464 /**
465 * Gets the text from the element.
466 * @param options Options that affect what text is included.
467 */
468 async text(options) {
469 await this._stabilize();
470 if (options?.exclude) {
471 return _getTextWithExcludedElements(this.element, options.exclude);
472 }
473 return (this.element.textContent || '').trim();
474 }
475 /**
476 * Sets the value of a `contenteditable` element.
477 * @param value Value to be set on the element.
478 */
479 async setContenteditableValue(value) {
480 const contenteditableAttr = await this.getAttribute('contenteditable');
481 if (contenteditableAttr !== '' && contenteditableAttr !== 'true') {
482 throw new Error('setContenteditableValue can only be called on a `contenteditable` element.');
483 }
484 await this._stabilize();
485 this.element.textContent = value;
486 }
487 /** Gets the value for the given attribute from the element. */
488 async getAttribute(name) {
489 await this._stabilize();
490 return this.element.getAttribute(name);
491 }
492 /** Checks whether the element has the given class. */
493 async hasClass(name) {
494 await this._stabilize();
495 return this.element.classList.contains(name);
496 }
497 /** Gets the dimensions of the element. */
498 async getDimensions() {
499 await this._stabilize();
500 return this.element.getBoundingClientRect();
501 }
502 /** Gets the value of a property of an element. */
503 async getProperty(name) {
504 await this._stabilize();
505 return this.element[name];
506 }
507 /** Sets the value of a property of an input. */
508 async setInputValue(value) {
509 this.element.value = value;
510 await this._stabilize();
511 }
512 /** Selects the options at the specified indexes inside of a native `select` element. */
513 async selectOptions(...optionIndexes) {
514 let hasChanged = false;
515 const options = this.element.querySelectorAll('option');
516 const indexes = new Set(optionIndexes); // Convert to a set to remove duplicates.
517 for (let i = 0; i < options.length; i++) {
518 const option = options[i];
519 const wasSelected = option.selected;
520 // We have to go through `option.selected`, because `HTMLSelectElement.value` doesn't
521 // allow for multiple options to be selected, even in `multiple` mode.
522 option.selected = indexes.has(i);
523 if (option.selected !== wasSelected) {
524 hasChanged = true;
525 dispatchFakeEvent(this.element, 'change');
526 }
527 }
528 if (hasChanged) {
529 await this._stabilize();
530 }
531 }
532 /** Checks whether this element matches the given selector. */
533 async matchesSelector(selector) {
534 await this._stabilize();
535 const elementPrototype = Element.prototype;
536 return (elementPrototype['matches'] || elementPrototype['msMatchesSelector']).call(this.element, selector);
537 }
538 /** Checks whether the element is focused. */
539 async isFocused() {
540 await this._stabilize();
541 return document.activeElement === this.element;
542 }
543 /**
544 * Dispatches an event with a particular name.
545 * @param name Name of the event to be dispatched.
546 */
547 async dispatchEvent(name, data) {
548 const event = createFakeEvent(name);
549 if (data) {
550 // tslint:disable-next-line:ban Have to use `Object.assign` to preserve the original object.
551 Object.assign(event, data);
552 }
553 dispatchEvent(this.element, event);
554 await this._stabilize();
555 }
556 /**
557 * Dispatches a pointer event on the current element if the browser supports it.
558 * @param name Name of the pointer event to be dispatched.
559 * @param clientX Coordinate of the user's pointer along the X axis.
560 * @param clientY Coordinate of the user's pointer along the Y axis.
561 * @param button Mouse button that should be pressed when dispatching the event.
562 */
563 _dispatchPointerEventIfSupported(name, clientX, clientY, offsetX, offsetY, button) {
564 // The latest versions of all browsers we support have the new `PointerEvent` API.
565 // Though since we capture the two most recent versions of these browsers, we also
566 // need to support Safari 12 at time of writing. Safari 12 does not have support for this,
567 // so we need to conditionally create and dispatch these events based on feature detection.
568 if (typeof PointerEvent !== 'undefined' && PointerEvent) {
569 dispatchPointerEvent(this.element, name, clientX, clientY, offsetX, offsetY, {
570 isPrimary: true,
571 button,
572 });
573 }
574 }
575 /**
576 * Dispatches all the events that are part of a mouse event sequence
577 * and then emits a given primary event at the end, if speciifed.
578 */
579 async _dispatchMouseEventSequence(primaryEventName, args, button) {
580 let clientX = undefined;
581 let clientY = undefined;
582 let offsetX = undefined;
583 let offsetY = undefined;
584 let modifiers = {};
585 if (args.length && typeof args[args.length - 1] === 'object') {
586 modifiers = args.pop();
587 }
588 if (args.length) {
589 const { left, top, width, height } = await this.getDimensions();
590 offsetX = args[0] === 'center' ? width / 2 : args[0];
591 offsetY = args[0] === 'center' ? height / 2 : args[1];
592 // Round the computed click position as decimal pixels are not
593 // supported by mouse events and could lead to unexpected results.
594 clientX = Math.round(left + offsetX);
595 clientY = Math.round(top + offsetY);
596 }
597 this._dispatchPointerEventIfSupported('pointerdown', clientX, clientY, offsetX, offsetY, button);
598 dispatchMouseEvent(this.element, 'mousedown', clientX, clientY, offsetX, offsetY, button, modifiers);
599 this._dispatchPointerEventIfSupported('pointerup', clientX, clientY, offsetX, offsetY, button);
600 dispatchMouseEvent(this.element, 'mouseup', clientX, clientY, offsetX, offsetY, button, modifiers);
601 // If a primary event name is specified, emit it after the mouse event sequence.
602 if (primaryEventName !== null) {
603 dispatchMouseEvent(this.element, primaryEventName, clientX, clientY, offsetX, offsetY, button, modifiers);
604 }
605 // This call to _stabilize should not be needed since the callers will already do that them-
606 // selves. Nevertheless it breaks some tests in g3 without it. It needs to be investigated
607 // why removing breaks those tests.
608 // See: https://github.com/angular/components/pull/20758/files#r520886256.
609 await this._stabilize();
610 }
611}
612
613/** The default environment options. */
614const defaultEnvironmentOptions = {
615 queryFn: (selector, root) => root.querySelectorAll(selector),
616};
617/** Whether auto change detection is currently disabled. */
618let disableAutoChangeDetection = false;
619/**
620 * The set of non-destroyed fixtures currently being used by `TestbedHarnessEnvironment` instances.
621 */
622const activeFixtures = new Set();
623/**
624 * Installs a handler for change detection batching status changes for a specific fixture.
625 * @param fixture The fixture to handle change detection batching for.
626 */
627function installAutoChangeDetectionStatusHandler(fixture) {
628 if (!activeFixtures.size) {
629 handleAutoChangeDetectionStatus(({ isDisabled, onDetectChangesNow }) => {
630 disableAutoChangeDetection = isDisabled;
631 if (onDetectChangesNow) {
632 Promise.all(Array.from(activeFixtures).map(detectChanges)).then(onDetectChangesNow);
633 }
634 });
635 }
636 activeFixtures.add(fixture);
637}
638/**
639 * Uninstalls a handler for change detection batching status changes for a specific fixture.
640 * @param fixture The fixture to stop handling change detection batching for.
641 */
642function uninstallAutoChangeDetectionStatusHandler(fixture) {
643 activeFixtures.delete(fixture);
644 if (!activeFixtures.size) {
645 stopHandlingAutoChangeDetectionStatus();
646 }
647}
648/** Whether we are currently in the fake async zone. */
649function isInFakeAsyncZone() {
650 return Zone.current.get('FakeAsyncTestZoneSpec') != null;
651}
652/**
653 * Triggers change detection for a specific fixture.
654 * @param fixture The fixture to trigger change detection for.
655 */
656async function detectChanges(fixture) {
657 fixture.detectChanges();
658 if (isInFakeAsyncZone()) {
659 flush();
660 }
661 else {
662 await fixture.whenStable();
663 }
664}
665/** A `HarnessEnvironment` implementation for Angular's Testbed. */
666class TestbedHarnessEnvironment extends HarnessEnvironment {
667 constructor(rawRootElement, _fixture, options) {
668 super(rawRootElement);
669 this._fixture = _fixture;
670 /** Whether the environment has been destroyed. */
671 this._destroyed = false;
672 this._options = { ...defaultEnvironmentOptions, ...options };
673 this._taskState = TaskStateZoneInterceptor.setup();
674 this._stabilizeCallback = () => this.forceStabilize();
675 installAutoChangeDetectionStatusHandler(_fixture);
676 _fixture.componentRef.onDestroy(() => {
677 uninstallAutoChangeDetectionStatusHandler(_fixture);
678 this._destroyed = true;
679 });
680 }
681 /** Creates a `HarnessLoader` rooted at the given fixture's root element. */
682 static loader(fixture, options) {
683 return new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
684 }
685 /**
686 * Creates a `HarnessLoader` at the document root. This can be used if harnesses are
687 * located outside of a fixture (e.g. overlays appended to the document body).
688 */
689 static documentRootLoader(fixture, options) {
690 return new TestbedHarnessEnvironment(document.body, fixture, options);
691 }
692 /** Gets the native DOM element corresponding to the given TestElement. */
693 static getNativeElement(el) {
694 if (el instanceof UnitTestElement) {
695 return el.element;
696 }
697 throw Error('This TestElement was not created by the TestbedHarnessEnvironment');
698 }
699 /**
700 * Creates an instance of the given harness type, using the fixture's root element as the
701 * harness's host element. This method should be used when creating a harness for the root element
702 * of a fixture, as components do not have the correct selector when they are created as the root
703 * of the fixture.
704 */
705 static async harnessForFixture(fixture, harnessType, options) {
706 const environment = new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
707 await environment.forceStabilize();
708 return environment.createComponentHarness(harnessType, fixture.nativeElement);
709 }
710 /**
711 * Flushes change detection and async tasks captured in the Angular zone.
712 * In most cases it should not be necessary to call this manually. However, there may be some edge
713 * cases where it is needed to fully flush animation events.
714 */
715 async forceStabilize() {
716 if (!disableAutoChangeDetection) {
717 if (this._destroyed) {
718 throw Error('Harness is attempting to use a fixture that has already been destroyed.');
719 }
720 await detectChanges(this._fixture);
721 }
722 }
723 /**
724 * Waits for all scheduled or running async tasks to complete. This allows harness
725 * authors to wait for async tasks outside of the Angular zone.
726 */
727 async waitForTasksOutsideAngular() {
728 // If we run in the fake async zone, we run "flush" to run any scheduled tasks. This
729 // ensures that the harnesses behave inside of the FakeAsyncTestZone similar to the
730 // "AsyncTestZone" and the root zone (i.e. neither fakeAsync or async). Note that we
731 // cannot just rely on the task state observable to become stable because the state will
732 // never change. This is because the task queue will be only drained if the fake async
733 // zone is being flushed.
734 if (isInFakeAsyncZone()) {
735 flush();
736 }
737 // Wait until the task queue has been drained and the zone is stable. Note that
738 // we cannot rely on "fixture.whenStable" since it does not catch tasks scheduled
739 // outside of the Angular zone. For test harnesses, we want to ensure that the
740 // app is fully stabilized and therefore need to use our own zone interceptor.
741 await this._taskState.pipe(takeWhile(state => !state.stable)).toPromise();
742 }
743 /** Gets the root element for the document. */
744 getDocumentRoot() {
745 return document.body;
746 }
747 /** Creates a `TestElement` from a raw element. */
748 createTestElement(element) {
749 return new UnitTestElement(element, this._stabilizeCallback);
750 }
751 /** Creates a `HarnessLoader` rooted at the given raw element. */
752 createEnvironment(element) {
753 return new TestbedHarnessEnvironment(element, this._fixture, this._options);
754 }
755 /**
756 * Gets a list of all elements matching the given selector under this environment's root element.
757 */
758 async getAllRawElements(selector) {
759 await this.forceStabilize();
760 return Array.from(this._options.queryFn(selector, this.rawRootElement));
761 }
762}
763
764export { TestbedHarnessEnvironment, UnitTestElement };
765//# sourceMappingURL=testbed.mjs.map