@interactors/core 1.0.0-rc1.2 → 1.0.0-rc1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cjs/constructor.js +62 -126
  3. package/dist/cjs/constructor.js.map +1 -1
  4. package/dist/cjs/converge.js +3 -5
  5. package/dist/cjs/converge.js.map +1 -1
  6. package/dist/cjs/format.js +50 -0
  7. package/dist/cjs/format.js.map +1 -0
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/inspector.js +2 -1
  10. package/dist/cjs/inspector.js.map +1 -1
  11. package/dist/cjs/interaction.js +59 -35
  12. package/dist/cjs/interaction.js.map +1 -1
  13. package/dist/cjs/match.js +17 -7
  14. package/dist/cjs/match.js.map +1 -1
  15. package/dist/cjs/resolvers.js +98 -0
  16. package/dist/cjs/resolvers.js.map +1 -0
  17. package/dist/cjs/serialize.js +48 -0
  18. package/dist/cjs/serialize.js.map +1 -0
  19. package/dist/constructor.d.ts +0 -6
  20. package/dist/constructor.d.ts.map +1 -1
  21. package/dist/converge.d.ts +2 -1
  22. package/dist/converge.d.ts.map +1 -1
  23. package/dist/esm/constructor.js +59 -117
  24. package/dist/esm/constructor.js.map +1 -1
  25. package/dist/esm/converge.js +3 -5
  26. package/dist/esm/converge.js.map +1 -1
  27. package/dist/esm/{format-table.js → format.js} +19 -1
  28. package/dist/esm/format.js.map +1 -0
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/inspector.js +2 -1
  31. package/dist/esm/inspector.js.map +1 -1
  32. package/dist/esm/interaction.js +56 -29
  33. package/dist/esm/interaction.js.map +1 -1
  34. package/dist/esm/match.js +17 -7
  35. package/dist/esm/match.js.map +1 -1
  36. package/dist/esm/resolvers.js +88 -0
  37. package/dist/esm/resolvers.js.map +1 -0
  38. package/dist/esm/serialize.js +43 -0
  39. package/dist/esm/serialize.js.map +1 -0
  40. package/dist/format.d.ts +10 -0
  41. package/dist/format.d.ts.map +1 -0
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/inspector.d.ts.map +1 -1
  45. package/dist/interaction.d.ts +38 -10
  46. package/dist/interaction.d.ts.map +1 -1
  47. package/dist/match.d.ts +5 -5
  48. package/dist/match.d.ts.map +1 -1
  49. package/dist/resolvers.d.ts +9 -0
  50. package/dist/resolvers.d.ts.map +1 -0
  51. package/dist/serialize.d.ts +13 -0
  52. package/dist/serialize.d.ts.map +1 -0
  53. package/dist/specification.d.ts +14 -11
  54. package/dist/specification.d.ts.map +1 -1
  55. package/dist/tsconfig.build.tsbuildinfo +1 -1
  56. package/package.json +3 -2
  57. package/src/constructor.ts +65 -133
  58. package/src/converge.ts +3 -6
  59. package/src/format.ts +58 -0
  60. package/src/index.ts +1 -1
  61. package/src/inspector.ts +2 -1
  62. package/src/interaction.ts +101 -36
  63. package/src/match.ts +23 -15
  64. package/src/resolvers.ts +100 -0
  65. package/src/serialize.ts +54 -0
  66. package/src/specification.ts +16 -13
  67. package/dist/cjs/format-table.js +0 -30
  68. package/dist/cjs/format-table.js.map +0 -1
  69. package/dist/esm/format-table.js.map +0 -1
  70. package/dist/format-table.d.ts +0 -6
  71. package/dist/format-table.d.ts.map +0 -1
  72. package/src/format-table.ts +0 -34
@@ -1,6 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
 
3
- import { globals } from '@interactors/globals';
4
3
  import { converge } from './converge';
5
4
  import { MergeObjects } from './merge-objects';
6
5
  import {
@@ -19,98 +18,14 @@ import {
19
18
  import { FilterSet } from './filter-set';
20
19
  import { Locator } from './locator';
21
20
  import { MatchFilter, applyFilter } from './match';
22
- import { formatTable } from './format-table';
21
+ import { formatDescription } from './format';
23
22
  import { FilterNotMatchingError } from './errors';
24
- import { interaction, interactionFilter, check, checkFilter, Interaction, ReadonlyInteraction } from './interaction';
25
- import { Match } from './match';
26
- import { NoSuchElementError, NotAbsentError, AmbiguousElementError } from './errors';
23
+ import { createInteraction, AssertionInteraction, ActionInteraction } from './interaction';
27
24
  import { isMatcher } from './matcher';
28
25
  import { matching } from './matchers/matching';
26
+ import { hasMatchMatching, resolveEmpty, resolveFirst, resolveUnique, unsafeSyncResolveParent, unsafeSyncResolveUnique } from './resolvers';
29
27
 
30
28
  const defaultLocator: FilterDefinition<string, Element> = (element) => element.textContent || "";
31
- const defaultSelector = 'div';
32
-
33
- export function findElements<E extends Element>(parentElement: Element, interactor: InteractorOptions<any, any, any>): E[] {
34
- if(typeof(interactor.specification.selector) === 'function') {
35
- return interactor.specification.selector(parentElement);
36
- } else if(interactor.specification.selector === ':root') {
37
- // this is a bit of a hack, because otherwise there isn't a good way of selecting the root element
38
- return [parentElement.ownerDocument.querySelector(':root') as E];
39
- } else {
40
- return Array.from(parentElement.querySelectorAll(interactor.specification.selector || defaultSelector));
41
- }
42
- }
43
-
44
- function findMatches(parentElement: Element, interactor: InteractorOptions<any, any, any>): Match<Element, any>[] {
45
- if (!interactor.name) {
46
- throw new Error('One of your interactors was created without a name. Please provide a label for your interactor:\n\tHTML.extend(\'my interactor\') || createInteractor(\'my interactor\')');
47
- }
48
- return findElements(parentElement, interactor).map((e) => new Match(e, interactor.filter, interactor.locator));
49
- }
50
-
51
- function findMatchesMatching(parentElement: Element, interactor: InteractorOptions<any, any, any>): Match<Element, any>[] {
52
- return findMatches(parentElement, interactor).filter((m) => m.matches);
53
- }
54
-
55
- function findMatchesNonEmpty(parentElement: Element, interactor: InteractorOptions<any, any, any>): Match<Element, any>[] {
56
- let matches = findMatches(parentElement, interactor);
57
- let matching = matches.filter((m) => m.matches);
58
- if(matching.length > 0) {
59
- return matching;
60
- } else if(matches.length === 0) {
61
- throw new NoSuchElementError(`did not find ${description(interactor)}`);
62
- } else {
63
- let table = formatTable({
64
- headers: interactor.locator ? [interactor.name, ...interactor.filter.asTableHeader()] : interactor.filter.asTableHeader(),
65
- rows: matches.slice().sort((a, b) => b.sortWeight - a.sortWeight).map((m) => m.asTableRow()),
66
- });
67
- throw new NoSuchElementError(`did not find ${description(interactor)}, did you mean one of:\n\n${table}`);
68
- }
69
- }
70
-
71
- // Given a parent element, and an interactor, find exactly one matching element
72
- // and return it. If no elements match, raise an error. If more than one
73
- // element matches, raise an error.
74
- export function resolveUnique(parentElement: Element, interactor: InteractorOptions<any, any, any>): Element {
75
- let matching = findMatchesNonEmpty(parentElement, interactor);
76
-
77
- if(matching.length === 1) {
78
- return matching[0].element;
79
- } else {
80
- let alternatives = matching.map((m) => '- ' + m.elementDescription());
81
- throw new AmbiguousElementError(`${description(interactor)} matches multiple elements:\n\n${alternatives.join('\n')}`);
82
- }
83
- }
84
-
85
- // Given a parent element, and an interactor, find all matching elements and
86
- // return them. If no elements match, raise an error.
87
- export function resolveNonEmpty(parentElement: Element, interactor: InteractorOptions<any, any, any>): Element[] {
88
- return findMatchesNonEmpty(parentElement, interactor).map(m => m.element);
89
- }
90
-
91
- // Given a parent element, and an interactor, check if there are any matching
92
- // elements, and throw an error if there are. Otherwise return undefined.
93
- export function resolveEmpty(parentElement: Element, interactor: InteractorOptions<any, any, any>): void {
94
- let matching = findMatchesMatching(parentElement, interactor);
95
-
96
- if(matching.length !== 0) {
97
- let alternatives = matching.map((m) => '- ' + m.elementDescription());
98
- throw new NotAbsentError(`${description(interactor)} exists but should not:\n\n${alternatives.join('\n')}`);
99
- }
100
- }
101
-
102
- function ownDescription(options: InteractorOptions<any, any, any>): string {
103
- if(options.locator) {
104
- return `${options.name} ${options.locator.description} ${options.filter.description}`.trim();
105
- } else {
106
- return `${options.name} ${options.filter.description}`.trim();
107
- }
108
- }
109
-
110
- function description(options: InteractorOptions<any, any, any>): string {
111
- let ancestorsAndSelf: InteractorOptions<any, any, any>[] = options.ancestors.concat(options);
112
- return ancestorsAndSelf.reverse().map(ownDescription).join(' within ');
113
- }
114
29
 
115
30
  /**
116
31
  * Removes any default values for a filter from the lookup if that filter is present in the
@@ -131,14 +46,6 @@ function getLookupFilterForAssertion<E extends Element, F extends Filters<E>>(fi
131
46
  return lookupFilter;
132
47
  }
133
48
 
134
- export function unsafeSyncResolveParent(options: InteractorOptions<any, any, any>): Element {
135
- return options.ancestors.reduce(resolveUnique, globals.document.documentElement);
136
- }
137
-
138
- export function unsafeSyncResolveUnique<E extends Element>(options: InteractorOptions<E, any, any>): E {
139
- return resolveUnique(unsafeSyncResolveParent(options), options) as E;
140
- }
141
-
142
49
  export function instantiateInteractor<E extends Element, F extends Filters<E>, A extends Actions<E, Interactor<E, FilterParams<E, F>>>>(
143
50
  options: InteractorOptions<E, F, A>,
144
51
  resolver: (options: InteractorOptions<E, F, A>) => E
@@ -147,35 +54,48 @@ export function instantiateInteractor<E extends Element, F extends Filters<E>, A
147
54
  options,
148
55
 
149
56
  get description(): string {
150
- return description(options);
57
+ return formatDescription(options);
151
58
  },
152
59
 
153
- perform<T>(fn: (element: E) => T): Interaction<T> {
154
- return interaction(`run perform on ${description(options)}`, () => converge(() => fn(resolver(options))))
60
+ perform<T>(fn: (element: E) => T): ActionInteraction<E, T> {
61
+ return createInteraction('action', {
62
+ name: "perform",
63
+ interactor,
64
+ args: [],
65
+ description: `run perform on ${formatDescription(options)}`,
66
+ run: (interactor) => converge(() => fn(resolver(interactor.options))),
67
+ });
155
68
  },
156
69
 
157
- assert(fn: (element: E) => void): ReadonlyInteraction<void> {
158
- return check(`${description(options)} asserts`, () => {
159
- return converge(() => {
160
- fn(resolver(options));
161
- });
70
+ assert(fn: (element: E) => void): AssertionInteraction<E> {
71
+ return createInteraction('assertion', {
72
+ name: "assert",
73
+ interactor,
74
+ args: [],
75
+ description: `${formatDescription(options)} asserts`,
76
+ run: (interactor) => converge(() => { fn(resolver(interactor.options)) }),
162
77
  });
163
78
  },
164
79
 
165
- has(filters: FilterParams<E, F>): ReadonlyInteraction<void> {
80
+ has(filters: FilterParams<E, F>): AssertionInteraction<E> {
166
81
  return interactor.is(filters);
167
82
  },
168
83
 
169
- is(filters: FilterParams<E, F>): ReadonlyInteraction<void> {
84
+ is(filters: FilterParams<E, F>): AssertionInteraction<E> {
170
85
  let filter = new FilterSet(options.specification, filters);
171
- return check(`${description(options)} matches filters: ${filter.description}`, () => {
172
- return converge(() => {
173
- let element = resolver({...options, filter: getLookupFilterForAssertion(options.filter, filters) });
86
+ return createInteraction('assertion', {
87
+ name: "is",
88
+ interactor,
89
+ args: [filters],
90
+ filters,
91
+ description: `${formatDescription(options)} matches filters: ${filter.description}`,
92
+ run: (interactor) => converge(() => {
93
+ let element = resolver({...interactor.options, filter: getLookupFilterForAssertion(interactor.options.filter, filters) });
174
94
  let match = new MatchFilter(element, filter);
175
95
  if (!match.matches) {
176
- throw new FilterNotMatchingError(`${description(options)} does not match filters:\n\n${match.formatAsExpectations()}`);
96
+ throw new FilterNotMatchingError(`${formatDescription(options)} does not match filters:\n\n${match.formatAsExpectations()}`);
177
97
  }
178
- });
98
+ }),
179
99
  });
180
100
  },
181
101
 
@@ -186,24 +106,28 @@ export function instantiateInteractor<E extends Element, F extends Filters<E>, A
186
106
  }, resolver) as unknown as T;
187
107
  },
188
108
 
189
- exists(): ReadonlyInteraction<void> & FilterObject<boolean, Element> {
190
- return checkFilter(`${description(options)} exists`, () => {
191
- return converge(() => {
192
- resolveNonEmpty(unsafeSyncResolveParent(options), options);
193
- });
194
- }, (element) => {
195
- return findMatchesMatching(element, options).length > 0;
196
- });
109
+ exists(): AssertionInteraction<E> & FilterObject<boolean, Element> {
110
+ return createInteraction('assertion', {
111
+ name: 'exists',
112
+ interactor,
113
+ args: [],
114
+ description: `${formatDescription(options)} exists`,
115
+ run: (interactor) => converge(() => {
116
+ resolveFirst(unsafeSyncResolveParent(interactor.options), options);
117
+ }),
118
+ }, (element) => hasMatchMatching(element, options));
197
119
  },
198
120
 
199
- absent(): ReadonlyInteraction<void> & FilterObject<boolean, Element> {
200
- return checkFilter(`${description(options)} does not exist`, () => {
201
- return converge(() => {
202
- resolveEmpty(unsafeSyncResolveParent(options), options);
203
- });
204
- }, (element) => {
205
- return findMatchesMatching(element, options).length === 0;
206
- });
121
+ absent(): AssertionInteraction<E> & FilterObject<boolean, Element> {
122
+ return createInteraction('assertion', {
123
+ name: 'absent',
124
+ interactor,
125
+ args: [],
126
+ description: `${formatDescription(options)} does not exist`,
127
+ run: (interactor) => converge(() => {
128
+ resolveEmpty(unsafeSyncResolveParent(interactor.options), options);
129
+ }),
130
+ }, (element) => !hasMatchMatching(element, options));
207
131
  },
208
132
 
209
133
  apply(parentElement: Element): string {
@@ -220,10 +144,13 @@ export function instantiateInteractor<E extends Element, F extends Filters<E>, A
220
144
  if (args.length) {
221
145
  actionDescription += ` with ` + args.map((a) => JSON.stringify(a)).join(', ');
222
146
  }
223
- return interaction(
224
- `${actionDescription} on ${description(options)}`,
225
- () => action(interactor as Interactor<E, FilterParams<E, F>> & FilterMethods<E, F> & ActionMethods<E, A, Interactor<E, FilterParams<E, F>>>, ...args)
226
- );
147
+ return createInteraction('action', {
148
+ name: actionName,
149
+ interactor,
150
+ args,
151
+ description: `${actionDescription} on ${formatDescription(options)}`,
152
+ run: (interactor) => action(interactor as Interactor<E, FilterParams<E, F>> & FilterMethods<E, F> & ActionMethods<E, A, Interactor<E, FilterParams<E, F>>>, ...args)
153
+ });
227
154
  },
228
155
  configurable: true,
229
156
  writable: true,
@@ -236,8 +163,12 @@ export function instantiateInteractor<E extends Element, F extends Filters<E>, A
236
163
  if (!interactor.hasOwnProperty(filterName)) {
237
164
  Object.defineProperty(interactor, filterName, {
238
165
  value: function() {
239
- return interactionFilter(`${filterName} of ${description(options)}`, async () => {
240
- return converge(() => applyFilter(filter, resolver(options)));
166
+ return createInteraction('assertion', {
167
+ name: filterName,
168
+ interactor,
169
+ args: [],
170
+ description: `${filterName} of ${formatDescription(options)}`,
171
+ run: (interactor) => converge(() => applyFilter(filter, resolver(interactor.options))),
241
172
  }, (parentElement) => {
242
173
  let element = [...options.ancestors, options].reduce(resolveUnique, parentElement);
243
174
  return applyFilter(filter, element);
@@ -270,6 +201,7 @@ export function createConstructor<E extends Element, FP extends FilterParams<any
270
201
  }
271
202
 
272
203
  return Object.assign(initInteractor, {
204
+ interactorName: name,
273
205
  selector: (value: string): InteractorConstructor<E, FP, FM, AM> => {
274
206
  return createConstructor(name, { ...specification, selector: value });
275
207
  },
package/src/converge.ts CHANGED
@@ -1,11 +1,8 @@
1
+ import { Operation, sleep } from '@effection/core';
1
2
  import { performance } from 'performance-api';
2
3
  import { globals } from '@interactors/globals'
3
4
 
4
- function wait(ms: number) {
5
- return new Promise((resolve) => setTimeout(resolve, ms));
6
- }
7
-
8
- export async function converge<T>(fn: () => T): Promise<T> {
5
+ export function* converge<T>(fn: () => T): Operation<T> {
9
6
  let startTime = performance.now();
10
7
  while(true) {
11
8
  try {
@@ -15,7 +12,7 @@ export async function converge<T>(fn: () => T): Promise<T> {
15
12
  if(diff > globals.interactorTimeout) {
16
13
  throw e;
17
14
  } else {
18
- await wait(1);
15
+ yield sleep(1);
19
16
  }
20
17
  }
21
18
  }
package/src/format.ts ADDED
@@ -0,0 +1,58 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Match } from "./match";
3
+ import { InteractorOptions } from "./specification";
4
+
5
+ export interface TableOptions {
6
+ headers: string[];
7
+ rows: string[][];
8
+ }
9
+
10
+ const MAX_COLUMN_WIDTH = 40
11
+
12
+ function formatValue(value: string, width: number) {
13
+ if(value.length > width) {
14
+ return value.slice(0, width - 1) + '…';
15
+ } else {
16
+ return value.padEnd(width);
17
+ }
18
+ }
19
+
20
+ export function formatTable(options: TableOptions): string {
21
+ let columnWidths = options.headers.map((h, index) => {
22
+ return Math.min(MAX_COLUMN_WIDTH, Math.max(h.length, ...options.rows.map((r) => r[index].length)));
23
+ });
24
+
25
+ let formatRow = (cells: string[]) => {
26
+ return '┃ ' + cells.map((c, index) => formatValue(c, columnWidths[index])).join(' ┃ ') + ' ┃';
27
+ }
28
+
29
+ let spacerRow = () => {
30
+ return '┣━' + columnWidths.map((w) => "━".repeat(w)).join('━╋━') + '━┫';
31
+ }
32
+
33
+ return [
34
+ formatRow(options.headers),
35
+ spacerRow(),
36
+ ...options.rows.map((row) => formatRow(row))
37
+ ].join('\n');
38
+ }
39
+
40
+ function ownDescription(options: InteractorOptions<any, any, any>): string {
41
+ if(options.locator) {
42
+ return `${options.name} ${options.locator.description} ${options.filter.description}`.trim();
43
+ } else {
44
+ return `${options.name} ${options.filter.description}`.trim();
45
+ }
46
+ }
47
+
48
+ export function formatDescription(options: InteractorOptions<any, any, any>): string {
49
+ let ancestorsAndSelf: InteractorOptions<any, any, any>[] = options.ancestors.concat(options);
50
+ return ancestorsAndSelf.reverse().map(ownDescription).join(' within ');
51
+ }
52
+
53
+ export function formatMatchesTable(interactor: InteractorOptions<any, any, any>, matches: Match<Element, any>[]): string {
54
+ return formatTable({
55
+ headers: interactor.locator ? [interactor.name, ...interactor.filter.asTableHeader()] : interactor.filter.asTableHeader(),
56
+ rows: matches.slice().sort((a, b) => b.sortWeight - a.sortWeight).map((m) => m.asTableRow()),
57
+ })
58
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Interactor, InteractorConstructor, InteractorSpecification, EmptyObject, FilterMethods, ActionMethods } from './specification';
2
- export { Interaction, ReadonlyInteraction, isInteraction } from './interaction';
2
+ export { Interaction, ActionInteraction, AssertionInteraction, isInteraction } from './interaction';
3
3
  export { createInteractor } from './create-interactor';
4
4
  export { createInspector } from './inspector'
5
5
  export { isVisible } from 'element-is-visible';
package/src/inspector.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
 
3
- import { findElements, unsafeSyncResolveParent, instantiateInteractor } from "./constructor";
3
+ import { instantiateInteractor } from "./constructor";
4
+ import { findElements, unsafeSyncResolveParent } from './resolvers'
4
5
  import { Interactor, Filters, InteractorConstructor, FilterParams } from "./specification";
5
6
 
6
7
  type GetElement<I extends InteractorConstructor<any, any, any, any>> = I extends InteractorConstructor<infer E, any, any, any> ? E : never
@@ -1,9 +1,12 @@
1
- import { globals } from '@interactors/globals';
2
- import type { FilterObject } from './specification';
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Operation, Task, run, Symbol } from '@effection/core';
3
+ import { InteractionOptions as SerializedInteractionOptions, globals, InteractionType } from '@interactors/globals';
4
+ import type { Interactor, FilterObject, FilterFn, FilterParams } from './specification';
5
+ import { serializeInteractionOptions } from './serialize';
3
6
 
4
- const interactionSymbol = Symbol.for('interaction');
7
+ const interactionSymbol: unique symbol = Symbol.for('interaction') as any;
5
8
 
6
- export function isInteraction(x: unknown): x is Interaction<unknown> {
9
+ export function isInteraction(x: unknown): x is Interaction<Element, unknown> {
7
10
  return typeof x === 'object' && x != null && interactionSymbol in x
8
11
  }
9
12
 
@@ -18,17 +21,38 @@ export function isInteraction(x: unknown): x is Interaction<unknown> {
18
21
  *
19
22
  * @typeParam T the return value of the promise that this interaction evaluates to.
20
23
  */
21
- export interface Interaction<T> extends Promise<T> {
24
+ export interface Interaction<E extends Element, T = void> extends Promise<T> {
25
+ type: InteractionType;
26
+
27
+ interactor: Interactor<E, any>;
28
+ run: (interactor: Interactor<E, any>) => Operation<T>;
22
29
  /**
23
30
  * Return a description of the interaction
24
31
  */
25
32
  description: string;
33
+ /**
34
+ * Return a code representation of the interaction
35
+ */
36
+ code: () => string;
37
+ /**
38
+ * Return a serialized options of the interaction
39
+ */
40
+ options: SerializedInteractionOptions;
26
41
  /**
27
42
  * Perform the interaction
28
43
  */
29
- action: () => Promise<T>;
44
+ action: () => Task<T>;
45
+
46
+ check?: () => Task<T>;
30
47
 
31
- [interactionSymbol]: true
48
+ halt: () => Promise<void>;
49
+
50
+ [interactionSymbol]: true;
51
+ }
52
+
53
+ export interface ActionInteraction<E extends Element, T = void> extends Interaction<E, T> {
54
+ type: "action";
55
+ check: undefined;
32
56
  }
33
57
 
34
58
  /**
@@ -36,47 +60,88 @@ export interface Interaction<T> extends Promise<T> {
36
60
  *
37
61
  * @typeParam T the return value of the promise that this interaction evaluates to.
38
62
  */
39
- export interface ReadonlyInteraction<T> extends Interaction<T> {
63
+ export interface AssertionInteraction<E extends Element, T = void> extends Interaction<E, T> {
64
+ type: "assertion";
40
65
  /**
41
66
  * Perform the check
42
67
  */
43
- check: () => Promise<T>;
68
+ check: () => Task<T>;
69
+ }
70
+
71
+ export type InteractionOptions<E extends Element, T> = {
72
+ name: string;
73
+ description: string;
74
+ filters?: FilterParams<any, any>;
75
+ args: unknown[];
76
+ interactor: Interactor<E, any>;
77
+ run: (interactor: Interactor<E, any>) => Operation<T>;
44
78
  }
45
79
 
46
- function createInteraction<T>(description: string, action: () => Promise<T>): Interaction<T> {
47
- let promise: Promise<T>;
48
- return {
49
- description,
80
+ export function createInteraction<E extends Element, T>(type: 'action', options: InteractionOptions<E, T>): ActionInteraction<E, T>
81
+ export function createInteraction<E extends Element, T>(type: 'assertion', options: InteractionOptions<E, T>): AssertionInteraction<E, T>
82
+ export function createInteraction<E extends Element, T, Q>(type: 'action', options: InteractionOptions<E, T>, apply: FilterFn<Q, Element>): ActionInteraction<E, T> & FilterObject<Q, Element>
83
+ export function createInteraction<E extends Element, T, Q>(type: 'assertion', options: InteractionOptions<E, T>, apply: FilterFn<Q, Element>): AssertionInteraction<E, T> & FilterObject<Q, Element>
84
+ export function createInteraction<E extends Element, T, Q>(type: InteractionType, options: InteractionOptions<E, T>, apply?: FilterFn<Q, Element>): Interaction<E, T> & Operation<T> {
85
+ let task: Task<T>;
86
+ let shouldCatchHalt = false
87
+
88
+ function operation(scope: Task): Operation<T> {
89
+ let run = () => options.run(options.interactor);
90
+
91
+ return (globals.wrapInteraction
92
+ ? globals.wrapInteraction(() => scope.run(run), interaction)
93
+ : globals.wrapAction(interaction.description, () => scope.run(run), type)) as Operation<T>;
94
+ };
95
+
96
+ function action(): Task<T> {
97
+ if(!task) {
98
+ task = run(operation);
99
+ }
100
+ return task;
101
+ };
102
+
103
+ function catchHalt<T extends (reason: any) => any>(onReject?: T | null) {
104
+ return (reason: any) => {
105
+ if (shouldCatchHalt && reason instanceof Error && reason.message == 'halted') {
106
+ return reason
107
+ } else {
108
+ onReject?.(reason)
109
+ return reason
110
+ }
111
+ }
112
+ }
113
+
114
+ let serializedOptions = serializeInteractionOptions(type, options)
115
+ let interaction: Interaction<E, T> & Operation<T> = {
116
+ type,
117
+ options: serializedOptions,
118
+ description: options.description,
119
+ interactor: options.interactor,
120
+ run: options.run,
121
+ args: options.args,
50
122
  action,
123
+ check: type === 'assertion' ? action : undefined,
124
+ code: () => serializedOptions.code(),
125
+ halt: () => {
126
+ shouldCatchHalt = true
127
+ return action().halt()
128
+ },
51
129
  [interactionSymbol]: true,
52
- [Symbol.toStringTag]: `[interaction ${description}]`,
130
+ [Symbol.toStringTag]: `[interaction ${options.description}]`,
131
+ [Symbol.operation]: operation,
53
132
  then(onFulfill, onReject) {
54
- if(!promise) { promise = this.action(); }
55
- return promise.then(onFulfill, onReject);
133
+ return action().then(onFulfill, catchHalt(onReject));
56
134
  },
57
135
  catch(onReject) {
58
- if(!promise) { promise = this.action(); }
59
- return promise.catch(onReject);
136
+ return action().catch(catchHalt(onReject));
60
137
  },
61
138
  finally(handler) {
62
- if(!promise) { promise = this.action(); }
63
- return promise.finally(handler);
139
+ return action().finally(handler);
64
140
  }
65
141
  }
66
- }
67
-
68
- export function interaction<T>(description: string, action: () => Promise<T>): Interaction<T> {
69
- return createInteraction(description, globals.wrapAction(description, action, 'interaction'))
70
- }
71
-
72
- export function check<T>(description: string, check: () => Promise<T>): ReadonlyInteraction<T> {
73
- return { check() { return this.action() }, ...createInteraction(description, globals.wrapAction(description, check, 'check')) };
74
- }
75
-
76
- export function interactionFilter<T, Q>(description: string, action: () => Promise<T>, filter: (element: Element) => Q): Interaction<T> & FilterObject<Q, Element> {
77
- return { apply: filter, ...interaction(description, action) };
78
- }
79
-
80
- export function checkFilter<T, Q>(description: string, action: () => Promise<T>, filter: (element: Element) => Q): ReadonlyInteraction<T> & FilterObject<Q, Element> {
81
- return { apply: filter , ...check(description, action) };
142
+ if (apply) {
143
+ return Object.assign(interaction, { apply });
144
+ } else {
145
+ return interaction;
146
+ }
82
147
  }
package/src/match.ts CHANGED
@@ -9,7 +9,6 @@ const check = (value: unknown): string => value ? "✓" : "⨯";
9
9
  export class Match<E extends Element, F extends Filters<E>> {
10
10
  public matchLocator?: MatchLocator<E>;
11
11
  public matchFilter: MatchFilter<E, F>;
12
- public matches: boolean;
13
12
 
14
13
  constructor(
15
14
  public element: E,
@@ -18,7 +17,10 @@ export class Match<E extends Element, F extends Filters<E>> {
18
17
  ) {
19
18
  this.matchLocator = locator && new MatchLocator(element, locator);
20
19
  this.matchFilter = new MatchFilter(element, filterSet);
21
- this.matches = (this.matchLocator ? this.matchLocator.matches : true) && this.matchFilter.matches;
20
+ }
21
+
22
+ get matches(): boolean {
23
+ return (this.matchLocator ? this.matchLocator.matches : true) && this.matchFilter.matches;
22
24
  }
23
25
 
24
26
  asTableHeader(name: string): string[] {
@@ -51,7 +53,6 @@ export class Match<E extends Element, F extends Filters<E>> {
51
53
  }
52
54
 
53
55
  export class MatchLocator<E extends Element> {
54
- public matches: boolean;
55
56
  public expected: MaybeMatcher<string> | null;
56
57
  public actual: string | null;
57
58
 
@@ -61,7 +62,10 @@ export class MatchLocator<E extends Element> {
61
62
  ) {
62
63
  this.expected = locator.value;
63
64
  this.actual = locator.apply(element);
64
- this.matches = applyMatcher(this.expected, this.actual);
65
+ }
66
+
67
+ get matches(): boolean {
68
+ return applyMatcher(this.expected, this.actual)
65
69
  }
66
70
 
67
71
  formatActual(): string {
@@ -78,7 +82,6 @@ export class MatchLocator<E extends Element> {
78
82
  }
79
83
 
80
84
  export class MatchFilter<E extends Element, F extends Filters<E>> {
81
- public matches: boolean;
82
85
  public items: MatchFilterItem<unknown, E, F>[];
83
86
 
84
87
  constructor(
@@ -88,7 +91,10 @@ export class MatchFilter<E extends Element, F extends Filters<E>> {
88
91
  this.items = Object.entries(filter.all).map(([key, expected]) => {
89
92
  return new MatchFilterItem(element, filter, key, expected)
90
93
  });
91
- this.matches = this.items.every((match) => match.matches)
94
+ }
95
+
96
+ get matches(): boolean {
97
+ return this.items.every((match) => match.matches)
92
98
  }
93
99
 
94
100
  asTableRow(): string[] {
@@ -114,23 +120,25 @@ export function applyFilter<T>(definition: FilterDefinition<T, any>, element: El
114
120
  }
115
121
 
116
122
  export class MatchFilterItem<T, E extends Element, F extends Filters<E>> {
117
- public actual: T;
118
- public matches: boolean;
119
-
120
123
  constructor(
121
124
  public element: E,
122
125
  public filter: FilterSet<E, F>,
123
126
  public key: string,
124
127
  public expected: MaybeMatcher<T>,
125
- ) {
126
- if(this.filter.specification.filters && this.filter.specification.filters[this.key]) {
128
+ ) {}
129
+
130
+ get matches(): boolean {
131
+ return applyMatcher(this.expected, this.actual);
132
+ }
133
+
134
+ get actual(): T {
135
+ if (this.filter.specification.filters && this.filter.specification.filters[this.key]) {
127
136
  let definition = this.filter.specification.filters[this.key];
128
- if(typeof(definition) === 'function') {
129
- this.actual = definition(this.element) as T;
137
+ if (typeof definition === 'function') {
138
+ return definition(this.element) as T;
130
139
  } else {
131
- this.actual = definition.apply(this.element) as T;
140
+ return definition.apply(this.element) as T;
132
141
  }
133
- this.matches = applyMatcher(this.expected, this.actual);
134
142
  } else {
135
143
  throw new Error(`interactor does not define a filter named ${JSON.stringify(this.key)}`);
136
144
  }