testbed.mjs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. import { flush } from '@angular/core/testing';
  2. import { takeWhile } from 'rxjs/operators';
  3. import { BehaviorSubject } from 'rxjs';
  4. import { getNoKeysSpecifiedError, _getTextWithExcludedElements, TestKey, HarnessEnvironment, handleAutoChangeDetectionStatus, stopHandlingAutoChangeDetectionStatus } from '../testing.mjs';
  5. import { h as PERIOD, B as BACKSPACE, T as TAB, c as ENTER, f as SHIFT, C as CONTROL, d as ALT, g as ESCAPE, a as PAGE_UP, P as PAGE_DOWN, E as END, H as HOME, L as LEFT_ARROW, U as UP_ARROW, R as RIGHT_ARROW, D as DOWN_ARROW, I as INSERT, i as DELETE, F as F1, j as F2, k as F3, l as F4, m as F5, n as F6, o as F7, p as F8, q as F9, r as F10, s as F11, t as F12, e as META, u as COMMA } from '../keycodes-CpHkExLC.mjs';
  6. /** Unique symbol that is used to patch a property to a proxy zone. */
  7. const stateObservableSymbol = Symbol('ProxyZone_PATCHED#stateObservable');
  8. /**
  9. * Interceptor that can be set up in a `ProxyZone` instance. The interceptor
  10. * will keep track of the task state and emit whenever the state changes.
  11. *
  12. * This serves as a workaround for https://github.com/angular/angular/issues/32896.
  13. */
  14. class TaskStateZoneInterceptor {
  15. _lastState = null;
  16. /** Subject that can be used to emit a new state change. */
  17. _stateSubject = new BehaviorSubject(this._lastState ? this._getTaskStateFromInternalZoneState(this._lastState) : { stable: true });
  18. /** Public observable that emits whenever the task state changes. */
  19. state = this._stateSubject;
  20. constructor(lastState) {
  21. this._lastState = lastState;
  22. }
  23. /** This will be called whenever the task state changes in the intercepted zone. */
  24. onHasTask(delegate, current, target, hasTaskState) {
  25. if (current === target) {
  26. this._stateSubject.next(this._getTaskStateFromInternalZoneState(hasTaskState));
  27. }
  28. }
  29. /** Gets the task state from the internal ZoneJS task state. */
  30. _getTaskStateFromInternalZoneState(state) {
  31. return { stable: !state.macroTask && !state.microTask };
  32. }
  33. /**
  34. * Sets up the custom task state Zone interceptor in the `ProxyZone`. Throws if
  35. * no `ProxyZone` could be found.
  36. * @returns an observable that emits whenever the task state changes.
  37. */
  38. static setup() {
  39. if (Zone === undefined) {
  40. throw Error('Could not find ZoneJS. For test harnesses running in TestBed, ' +
  41. 'ZoneJS needs to be installed.');
  42. }
  43. // tslint:disable-next-line:variable-name
  44. const ProxyZoneSpec = Zone['ProxyZoneSpec'];
  45. // If there is no "ProxyZoneSpec" installed, we throw an error and recommend
  46. // setting up the proxy zone by pulling in the testing bundle.
  47. if (!ProxyZoneSpec) {
  48. throw Error('ProxyZoneSpec is needed for the test harnesses but could not be found. ' +
  49. 'Please make sure that your environment includes zone.js/dist/zone-testing.js');
  50. }
  51. // Ensure that there is a proxy zone instance set up, and get
  52. // a reference to the instance if present.
  53. const zoneSpec = ProxyZoneSpec.assertPresent();
  54. // If there already is a delegate registered in the proxy zone, and it
  55. // is type of the custom task state interceptor, we just use that state
  56. // observable. This allows us to only intercept Zone once per test
  57. // (similar to how `fakeAsync` or `async` work).
  58. if (zoneSpec[stateObservableSymbol]) {
  59. return zoneSpec[stateObservableSymbol];
  60. }
  61. // Since we intercept on environment creation and the fixture has been
  62. // created before, we might have missed tasks scheduled before. Fortunately
  63. // the proxy zone keeps track of the previous task state, so we can just pass
  64. // this as initial state to the task zone interceptor.
  65. const interceptor = new TaskStateZoneInterceptor(zoneSpec.lastTaskState);
  66. const zoneSpecOnHasTask = zoneSpec.onHasTask.bind(zoneSpec);
  67. // We setup the task state interceptor in the `ProxyZone`. Note that we cannot register
  68. // the interceptor as a new proxy zone delegate because it would mean that other zone
  69. // delegates (e.g. `FakeAsyncTestZone` or `AsyncTestZone`) can accidentally overwrite/disable
  70. // our interceptor. Since we just intend to monitor the task state of the proxy zone, it is
  71. // sufficient to just patch the proxy zone. This also avoids that we interfere with the task
  72. // queue scheduling logic.
  73. zoneSpec.onHasTask = function (...args) {
  74. zoneSpecOnHasTask(...args);
  75. interceptor.onHasTask(...args);
  76. };
  77. return (zoneSpec[stateObservableSymbol] = interceptor.state);
  78. }
  79. }
  80. /** Used to generate unique IDs for events. */
  81. /**
  82. * Creates a browser MouseEvent with the specified options.
  83. * @docs-private
  84. */
  85. function createMouseEvent(type, clientX = 0, clientY = 0, offsetX = 0, offsetY = 0, button = 0, modifiers = {}) {
  86. // Note: We cannot determine the position of the mouse event based on the screen
  87. // because the dimensions and position of the browser window are not available
  88. // To provide reasonable `screenX` and `screenY` coordinates, we simply use the
  89. // client coordinates as if the browser is opened in fullscreen.
  90. const screenX = clientX;
  91. const screenY = clientY;
  92. const event = new MouseEvent(type, {
  93. bubbles: true,
  94. cancelable: true,
  95. composed: true, // Required for shadow DOM events.
  96. view: window,
  97. detail: 1,
  98. relatedTarget: null,
  99. screenX,
  100. screenY,
  101. clientX,
  102. clientY,
  103. ctrlKey: modifiers.control,
  104. altKey: modifiers.alt,
  105. shiftKey: modifiers.shift,
  106. metaKey: modifiers.meta,
  107. button: button,
  108. buttons: 1,
  109. });
  110. // The `MouseEvent` constructor doesn't allow us to pass these properties into the constructor.
  111. // Override them to `1`, because they're used for fake screen reader event detection.
  112. if (offsetX != null) {
  113. defineReadonlyEventProperty(event, 'offsetX', offsetX);
  114. }
  115. if (offsetY != null) {
  116. defineReadonlyEventProperty(event, 'offsetY', offsetY);
  117. }
  118. return event;
  119. }
  120. /**
  121. * Creates a browser `PointerEvent` with the specified options. Pointer events
  122. * by default will appear as if they are the primary pointer of their type.
  123. * https://www.w3.org/TR/pointerevents2/#dom-pointerevent-isprimary.
  124. *
  125. * For example, if pointer events for a multi-touch interaction are created, the non-primary
  126. * pointer touches would need to be represented by non-primary pointer events.
  127. *
  128. * @docs-private
  129. */
  130. function createPointerEvent(type, clientX = 0, clientY = 0, offsetX, offsetY, options = { isPrimary: true }) {
  131. const event = new PointerEvent(type, {
  132. bubbles: true,
  133. cancelable: true,
  134. composed: true, // Required for shadow DOM events.
  135. view: window,
  136. clientX,
  137. clientY,
  138. ...options,
  139. });
  140. if (offsetX != null) {
  141. defineReadonlyEventProperty(event, 'offsetX', offsetX);
  142. }
  143. if (offsetY != null) {
  144. defineReadonlyEventProperty(event, 'offsetY', offsetY);
  145. }
  146. return event;
  147. }
  148. /**
  149. * Creates a keyboard event with the specified key and modifiers.
  150. * @docs-private
  151. */
  152. function createKeyboardEvent(type, keyCode = 0, key = '', modifiers = {}, code = '') {
  153. return new KeyboardEvent(type, {
  154. bubbles: true,
  155. cancelable: true,
  156. composed: true, // Required for shadow DOM events.
  157. view: window,
  158. keyCode,
  159. key,
  160. shiftKey: modifiers.shift,
  161. metaKey: modifiers.meta,
  162. altKey: modifiers.alt,
  163. ctrlKey: modifiers.control,
  164. code,
  165. });
  166. }
  167. /**
  168. * Creates a fake event object with any desired event type.
  169. * @docs-private
  170. */
  171. function createFakeEvent(type, bubbles = false, cancelable = true, composed = true) {
  172. return new Event(type, { bubbles, cancelable, composed });
  173. }
  174. /**
  175. * Defines a readonly property on the given event object. Readonly properties on an event object
  176. * are always set as configurable as that matches default readonly properties for DOM event objects.
  177. */
  178. function defineReadonlyEventProperty(event, propertyName, value) {
  179. Object.defineProperty(event, propertyName, { get: () => value, configurable: true });
  180. }
  181. /**
  182. * Utility to dispatch any event on a Node.
  183. * @docs-private
  184. */
  185. function dispatchEvent(node, event) {
  186. node.dispatchEvent(event);
  187. return event;
  188. }
  189. /**
  190. * Shorthand to dispatch a fake event on a specified node.
  191. * @docs-private
  192. */
  193. function dispatchFakeEvent(node, type, bubbles) {
  194. return dispatchEvent(node, createFakeEvent(type, bubbles));
  195. }
  196. /**
  197. * Shorthand to dispatch a keyboard event with a specified key code and
  198. * optional modifiers.
  199. * @docs-private
  200. */
  201. function dispatchKeyboardEvent(node, type, keyCode, key, modifiers, code) {
  202. return dispatchEvent(node, createKeyboardEvent(type, keyCode, key, modifiers, code));
  203. }
  204. /**
  205. * Shorthand to dispatch a mouse event on the specified coordinates.
  206. * @docs-private
  207. */
  208. function dispatchMouseEvent(node, type, clientX = 0, clientY = 0, offsetX, offsetY, button, modifiers) {
  209. return dispatchEvent(node, createMouseEvent(type, clientX, clientY, offsetX, offsetY, button, modifiers));
  210. }
  211. /**
  212. * Shorthand to dispatch a pointer event on the specified coordinates.
  213. * @docs-private
  214. */
  215. function dispatchPointerEvent(node, type, clientX = 0, clientY = 0, offsetX, offsetY, options) {
  216. return dispatchEvent(node, createPointerEvent(type, clientX, clientY, offsetX, offsetY, options));
  217. }
  218. function triggerFocusChange(element, event) {
  219. let eventFired = false;
  220. const handler = () => (eventFired = true);
  221. element.addEventListener(event, handler);
  222. element[event]();
  223. element.removeEventListener(event, handler);
  224. if (!eventFired) {
  225. dispatchFakeEvent(element, event);
  226. }
  227. }
  228. /** @docs-private */
  229. function triggerFocus(element) {
  230. triggerFocusChange(element, 'focus');
  231. }
  232. /** @docs-private */
  233. function triggerBlur(element) {
  234. triggerFocusChange(element, 'blur');
  235. }
  236. /** Input types for which the value can be entered incrementally. */
  237. const incrementalInputTypes = new Set([
  238. 'text',
  239. 'email',
  240. 'hidden',
  241. 'password',
  242. 'search',
  243. 'tel',
  244. 'url',
  245. ]);
  246. /**
  247. * Manual mapping of some common characters to their `code` in a keyboard event. Non-exhaustive, see
  248. * the tables on MDN for more info: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
  249. */
  250. const charsToCodes = {
  251. ' ': 'Space',
  252. '.': 'Period',
  253. ',': 'Comma',
  254. '`': 'Backquote',
  255. '-': 'Minus',
  256. '=': 'Equal',
  257. '[': 'BracketLeft',
  258. ']': 'BracketRight',
  259. '\\': 'Backslash',
  260. '/': 'Slash',
  261. "'": 'Quote',
  262. '"': 'Quote',
  263. ';': 'Semicolon',
  264. };
  265. /**
  266. * Determines the `KeyboardEvent.key` from a character. See #27034 and
  267. * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
  268. */
  269. function getKeyboardEventCode(char) {
  270. if (char.length !== 1) {
  271. return '';
  272. }
  273. const charCode = char.charCodeAt(0);
  274. // Key is a letter between a and z, uppercase or lowercase.
  275. if ((charCode >= 97 && charCode <= 122) || (charCode >= 65 && charCode <= 90)) {
  276. return `Key${char.toUpperCase()}`;
  277. }
  278. // Digits from 0 to 9.
  279. if (48 <= charCode && charCode <= 57) {
  280. return `Digit${char}`;
  281. }
  282. return charsToCodes[char] ?? '';
  283. }
  284. /**
  285. * Checks whether the given Element is a text input element.
  286. * @docs-private
  287. */
  288. function isTextInput(element) {
  289. const nodeName = element.nodeName.toLowerCase();
  290. return nodeName === 'input' || nodeName === 'textarea';
  291. }
  292. function typeInElement(element, ...modifiersAndKeys) {
  293. const first = modifiersAndKeys[0];
  294. let modifiers;
  295. let rest;
  296. if (first !== undefined &&
  297. typeof first !== 'string' &&
  298. first.keyCode === undefined &&
  299. first.key === undefined) {
  300. modifiers = first;
  301. rest = modifiersAndKeys.slice(1);
  302. }
  303. else {
  304. modifiers = {};
  305. rest = modifiersAndKeys;
  306. }
  307. const isInput = isTextInput(element);
  308. const inputType = element.getAttribute('type') || 'text';
  309. const keys = rest
  310. .map(k => typeof k === 'string'
  311. ? k.split('').map(c => ({
  312. keyCode: c.toUpperCase().charCodeAt(0),
  313. key: c,
  314. code: getKeyboardEventCode(c),
  315. }))
  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, key.code);
  340. dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers, key.code);
  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, key.code);
  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. */
  358. function clearElement(element) {
  359. triggerFocus(element);
  360. element.value = '';
  361. dispatchFakeEvent(element, 'input');
  362. }
  363. /** Maps `TestKey` constants to the `keyCode` and `key` values used by native browser events. */
  364. const keyMap = {
  365. [TestKey.BACKSPACE]: { keyCode: BACKSPACE, key: 'Backspace', code: 'Backspace' },
  366. [TestKey.TAB]: { keyCode: TAB, key: 'Tab', code: 'Tab' },
  367. [TestKey.ENTER]: { keyCode: ENTER, key: 'Enter', code: 'Enter' },
  368. [TestKey.SHIFT]: { keyCode: SHIFT, key: 'Shift', code: 'ShiftLeft' },
  369. [TestKey.CONTROL]: { keyCode: CONTROL, key: 'Control', code: 'ControlLeft' },
  370. [TestKey.ALT]: { keyCode: ALT, key: 'Alt', code: 'AltLeft' },
  371. [TestKey.ESCAPE]: { keyCode: ESCAPE, key: 'Escape', code: 'Escape' },
  372. [TestKey.PAGE_UP]: { keyCode: PAGE_UP, key: 'PageUp', code: 'PageUp' },
  373. [TestKey.PAGE_DOWN]: { keyCode: PAGE_DOWN, key: 'PageDown', code: 'PageDown' },
  374. [TestKey.END]: { keyCode: END, key: 'End', code: 'End' },
  375. [TestKey.HOME]: { keyCode: HOME, key: 'Home', code: 'Home' },
  376. [TestKey.LEFT_ARROW]: { keyCode: LEFT_ARROW, key: 'ArrowLeft', code: 'ArrowLeft' },
  377. [TestKey.UP_ARROW]: { keyCode: UP_ARROW, key: 'ArrowUp', code: 'ArrowUp' },
  378. [TestKey.RIGHT_ARROW]: { keyCode: RIGHT_ARROW, key: 'ArrowRight', code: 'ArrowRight' },
  379. [TestKey.DOWN_ARROW]: { keyCode: DOWN_ARROW, key: 'ArrowDown', code: 'ArrowDown' },
  380. [TestKey.INSERT]: { keyCode: INSERT, key: 'Insert', code: 'Insert' },
  381. [TestKey.DELETE]: { keyCode: DELETE, key: 'Delete', code: 'Delete' },
  382. [TestKey.F1]: { keyCode: F1, key: 'F1', code: 'F1' },
  383. [TestKey.F2]: { keyCode: F2, key: 'F2', code: 'F2' },
  384. [TestKey.F3]: { keyCode: F3, key: 'F3', code: 'F3' },
  385. [TestKey.F4]: { keyCode: F4, key: 'F4', code: 'F4' },
  386. [TestKey.F5]: { keyCode: F5, key: 'F5', code: 'F5' },
  387. [TestKey.F6]: { keyCode: F6, key: 'F6', code: 'F6' },
  388. [TestKey.F7]: { keyCode: F7, key: 'F7', code: 'F7' },
  389. [TestKey.F8]: { keyCode: F8, key: 'F8', code: 'F8' },
  390. [TestKey.F9]: { keyCode: F9, key: 'F9', code: 'F9' },
  391. [TestKey.F10]: { keyCode: F10, key: 'F10', code: 'F10' },
  392. [TestKey.F11]: { keyCode: F11, key: 'F11', code: 'F11' },
  393. [TestKey.F12]: { keyCode: F12, key: 'F12', code: 'F12' },
  394. [TestKey.META]: { keyCode: META, key: 'Meta', code: 'MetaLeft' },
  395. [TestKey.COMMA]: { keyCode: COMMA, key: ',', code: 'Comma' },
  396. };
  397. /** A `TestElement` implementation for unit tests. */
  398. class UnitTestElement {
  399. element;
  400. _stabilize;
  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 !== '' &&
  482. contenteditableAttr !== 'true' &&
  483. contenteditableAttr !== 'plaintext-only') {
  484. throw new Error('setContenteditableValue can only be called on a `contenteditable` element.');
  485. }
  486. await this._stabilize();
  487. this.element.textContent = value;
  488. }
  489. /** Gets the value for the given attribute from the element. */
  490. async getAttribute(name) {
  491. await this._stabilize();
  492. return this.element.getAttribute(name);
  493. }
  494. /** Checks whether the element has the given class. */
  495. async hasClass(name) {
  496. await this._stabilize();
  497. return this.element.classList.contains(name);
  498. }
  499. /** Gets the dimensions of the element. */
  500. async getDimensions() {
  501. await this._stabilize();
  502. return this.element.getBoundingClientRect();
  503. }
  504. /** Gets the value of a property of an element. */
  505. async getProperty(name) {
  506. await this._stabilize();
  507. return this.element[name];
  508. }
  509. /** Sets the value of a property of an input. */
  510. async setInputValue(value) {
  511. this.element.value = value;
  512. await this._stabilize();
  513. }
  514. /** Selects the options at the specified indexes inside of a native `select` element. */
  515. async selectOptions(...optionIndexes) {
  516. let hasChanged = false;
  517. const options = this.element.querySelectorAll('option');
  518. const indexes = new Set(optionIndexes); // Convert to a set to remove duplicates.
  519. for (let i = 0; i < options.length; i++) {
  520. const option = options[i];
  521. const wasSelected = option.selected;
  522. // We have to go through `option.selected`, because `HTMLSelectElement.value` doesn't
  523. // allow for multiple options to be selected, even in `multiple` mode.
  524. option.selected = indexes.has(i);
  525. if (option.selected !== wasSelected) {
  526. hasChanged = true;
  527. dispatchFakeEvent(this.element, 'change');
  528. }
  529. }
  530. if (hasChanged) {
  531. await this._stabilize();
  532. }
  533. }
  534. /** Checks whether this element matches the given selector. */
  535. async matchesSelector(selector) {
  536. await this._stabilize();
  537. const elementPrototype = Element.prototype;
  538. return (elementPrototype['matches'] || elementPrototype['msMatchesSelector']).call(this.element, selector);
  539. }
  540. /** Checks whether the element is focused. */
  541. async isFocused() {
  542. await this._stabilize();
  543. return document.activeElement === this.element;
  544. }
  545. /**
  546. * Dispatches an event with a particular name.
  547. * @param name Name of the event to be dispatched.
  548. */
  549. async dispatchEvent(name, data) {
  550. const event = createFakeEvent(name);
  551. if (data) {
  552. // tslint:disable-next-line:ban Have to use `Object.assign` to preserve the original object.
  553. Object.assign(event, data);
  554. }
  555. dispatchEvent(this.element, event);
  556. await this._stabilize();
  557. }
  558. /**
  559. * Dispatches a pointer event on the current element if the browser supports it.
  560. * @param name Name of the pointer event to be dispatched.
  561. * @param clientX Coordinate of the user's pointer along the X axis.
  562. * @param clientY Coordinate of the user's pointer along the Y axis.
  563. * @param button Mouse button that should be pressed when dispatching the event.
  564. */
  565. _dispatchPointerEventIfSupported(name, clientX, clientY, offsetX, offsetY, button) {
  566. // The latest versions of all browsers we support have the new `PointerEvent` API.
  567. // Though since we capture the two most recent versions of these browsers, we also
  568. // need to support Safari 12 at time of writing. Safari 12 does not have support for this,
  569. // so we need to conditionally create and dispatch these events based on feature detection.
  570. if (typeof PointerEvent !== 'undefined' && PointerEvent) {
  571. dispatchPointerEvent(this.element, name, clientX, clientY, offsetX, offsetY, {
  572. isPrimary: true,
  573. button,
  574. });
  575. }
  576. }
  577. /**
  578. * Dispatches all the events that are part of a mouse event sequence
  579. * and then emits a given primary event at the end, if speciifed.
  580. */
  581. async _dispatchMouseEventSequence(primaryEventName, args, button) {
  582. let clientX = undefined;
  583. let clientY = undefined;
  584. let offsetX = undefined;
  585. let offsetY = undefined;
  586. let modifiers = {};
  587. if (args.length && typeof args[args.length - 1] === 'object') {
  588. modifiers = args.pop();
  589. }
  590. if (args.length) {
  591. const { left, top, width, height } = await this.getDimensions();
  592. offsetX = args[0] === 'center' ? width / 2 : args[0];
  593. offsetY = args[0] === 'center' ? height / 2 : args[1];
  594. // Round the computed click position as decimal pixels are not
  595. // supported by mouse events and could lead to unexpected results.
  596. clientX = Math.round(left + offsetX);
  597. clientY = Math.round(top + offsetY);
  598. }
  599. this._dispatchPointerEventIfSupported('pointerdown', clientX, clientY, offsetX, offsetY, button);
  600. dispatchMouseEvent(this.element, 'mousedown', clientX, clientY, offsetX, offsetY, button, modifiers);
  601. this._dispatchPointerEventIfSupported('pointerup', clientX, clientY, offsetX, offsetY, button);
  602. dispatchMouseEvent(this.element, 'mouseup', clientX, clientY, offsetX, offsetY, button, modifiers);
  603. // If a primary event name is specified, emit it after the mouse event sequence.
  604. if (primaryEventName !== null) {
  605. dispatchMouseEvent(this.element, primaryEventName, clientX, clientY, offsetX, offsetY, button, modifiers);
  606. }
  607. // This call to _stabilize should not be needed since the callers will already do that them-
  608. // selves. Nevertheless it breaks some tests in g3 without it. It needs to be investigated
  609. // why removing breaks those tests.
  610. // See: https://github.com/angular/components/pull/20758/files#r520886256.
  611. await this._stabilize();
  612. }
  613. }
  614. /** The default environment options. */
  615. const defaultEnvironmentOptions = {
  616. queryFn: (selector, root) => root.querySelectorAll(selector),
  617. };
  618. /** Whether auto change detection is currently disabled. */
  619. let disableAutoChangeDetection = false;
  620. /**
  621. * The set of non-destroyed fixtures currently being used by `TestbedHarnessEnvironment` instances.
  622. */
  623. const activeFixtures = new Set();
  624. /**
  625. * Installs a handler for change detection batching status changes for a specific fixture.
  626. * @param fixture The fixture to handle change detection batching for.
  627. */
  628. function installAutoChangeDetectionStatusHandler(fixture) {
  629. if (!activeFixtures.size) {
  630. handleAutoChangeDetectionStatus(({ isDisabled, onDetectChangesNow }) => {
  631. disableAutoChangeDetection = isDisabled;
  632. if (onDetectChangesNow) {
  633. Promise.all(Array.from(activeFixtures).map(detectChanges)).then(onDetectChangesNow);
  634. }
  635. });
  636. }
  637. activeFixtures.add(fixture);
  638. }
  639. /**
  640. * Uninstalls a handler for change detection batching status changes for a specific fixture.
  641. * @param fixture The fixture to stop handling change detection batching for.
  642. */
  643. function uninstallAutoChangeDetectionStatusHandler(fixture) {
  644. activeFixtures.delete(fixture);
  645. if (!activeFixtures.size) {
  646. stopHandlingAutoChangeDetectionStatus();
  647. }
  648. }
  649. /** Whether we are currently in the fake async zone. */
  650. function isInFakeAsyncZone() {
  651. return typeof Zone !== 'undefined' && Zone.current.get('FakeAsyncTestZoneSpec') != null;
  652. }
  653. /**
  654. * Triggers change detection for a specific fixture.
  655. * @param fixture The fixture to trigger change detection for.
  656. */
  657. async function detectChanges(fixture) {
  658. fixture.detectChanges();
  659. if (isInFakeAsyncZone()) {
  660. flush();
  661. }
  662. else {
  663. await fixture.whenStable();
  664. }
  665. }
  666. /** A `HarnessEnvironment` implementation for Angular's Testbed. */
  667. class TestbedHarnessEnvironment extends HarnessEnvironment {
  668. _fixture;
  669. /** Whether the environment has been destroyed. */
  670. _destroyed = false;
  671. /** Observable that emits whenever the test task state changes. */
  672. _taskState;
  673. /** The options for this environment. */
  674. _options;
  675. /** Environment stabilization callback passed to the created test elements. */
  676. _stabilizeCallback;
  677. constructor(rawRootElement, _fixture, options) {
  678. super(rawRootElement);
  679. this._fixture = _fixture;
  680. this._options = { ...defaultEnvironmentOptions, ...options };
  681. if (typeof Zone !== 'undefined') {
  682. this._taskState = TaskStateZoneInterceptor.setup();
  683. }
  684. this._stabilizeCallback = () => this.forceStabilize();
  685. installAutoChangeDetectionStatusHandler(_fixture);
  686. _fixture.componentRef.onDestroy(() => {
  687. uninstallAutoChangeDetectionStatusHandler(_fixture);
  688. this._destroyed = true;
  689. });
  690. }
  691. /** Creates a `HarnessLoader` rooted at the given fixture's root element. */
  692. static loader(fixture, options) {
  693. return new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
  694. }
  695. /**
  696. * Creates a `HarnessLoader` at the document root. This can be used if harnesses are
  697. * located outside of a fixture (e.g. overlays appended to the document body).
  698. */
  699. static documentRootLoader(fixture, options) {
  700. return new TestbedHarnessEnvironment(document.body, fixture, options);
  701. }
  702. /** Gets the native DOM element corresponding to the given TestElement. */
  703. static getNativeElement(el) {
  704. if (el instanceof UnitTestElement) {
  705. return el.element;
  706. }
  707. throw Error('This TestElement was not created by the TestbedHarnessEnvironment');
  708. }
  709. /**
  710. * Creates an instance of the given harness type, using the fixture's root element as the
  711. * harness's host element. This method should be used when creating a harness for the root element
  712. * of a fixture, as components do not have the correct selector when they are created as the root
  713. * of the fixture.
  714. */
  715. static async harnessForFixture(fixture, harnessType, options) {
  716. const environment = new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
  717. await environment.forceStabilize();
  718. return environment.createComponentHarness(harnessType, fixture.nativeElement);
  719. }
  720. /**
  721. * Flushes change detection and async tasks captured in the Angular zone.
  722. * In most cases it should not be necessary to call this manually. However, there may be some edge
  723. * cases where it is needed to fully flush animation events.
  724. */
  725. async forceStabilize() {
  726. if (!disableAutoChangeDetection) {
  727. if (this._destroyed) {
  728. throw Error('Harness is attempting to use a fixture that has already been destroyed.');
  729. }
  730. await detectChanges(this._fixture);
  731. }
  732. }
  733. /**
  734. * Waits for all scheduled or running async tasks to complete. This allows harness
  735. * authors to wait for async tasks outside of the Angular zone.
  736. */
  737. async waitForTasksOutsideAngular() {
  738. // If we run in the fake async zone, we run "flush" to run any scheduled tasks. This
  739. // ensures that the harnesses behave inside of the FakeAsyncTestZone similar to the
  740. // "AsyncTestZone" and the root zone (i.e. neither fakeAsync or async). Note that we
  741. // cannot just rely on the task state observable to become stable because the state will
  742. // never change. This is because the task queue will be only drained if the fake async
  743. // zone is being flushed.
  744. if (isInFakeAsyncZone()) {
  745. flush();
  746. }
  747. // Wait until the task queue has been drained and the zone is stable. Note that
  748. // we cannot rely on "fixture.whenStable" since it does not catch tasks scheduled
  749. // outside of the Angular zone. For test harnesses, we want to ensure that the
  750. // app is fully stabilized and therefore need to use our own zone interceptor.
  751. await this._taskState?.pipe(takeWhile(state => !state.stable)).toPromise();
  752. }
  753. /** Gets the root element for the document. */
  754. getDocumentRoot() {
  755. return document.body;
  756. }
  757. /** Creates a `TestElement` from a raw element. */
  758. createTestElement(element) {
  759. return new UnitTestElement(element, this._stabilizeCallback);
  760. }
  761. /** Creates a `HarnessLoader` rooted at the given raw element. */
  762. createEnvironment(element) {
  763. return new TestbedHarnessEnvironment(element, this._fixture, this._options);
  764. }
  765. /**
  766. * Gets a list of all elements matching the given selector under this environment's root element.
  767. */
  768. async getAllRawElements(selector) {
  769. await this.forceStabilize();
  770. return Array.from(this._options.queryFn(selector, this.rawRootElement));
  771. }
  772. }
  773. export { TestbedHarnessEnvironment, UnitTestElement };
  774. //# sourceMappingURL=testbed.mjs.map