@teaflask/assistant 0.7.0 → 0.8.0

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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ import { TeaflaskAssistant } from "@teaflask/assistant";
63
63
 
64
64
  ## What your agent can do
65
65
 
66
- - **Take actions.** It calls your API on the user's behalf, with the user's own session. You set every action to Allow, Ask or Deny.
66
+ - **Take actions.** It calls your API on the user's behalf, with the user's own session. You set every action to Allow, Ask or Deny. On a [GraphQL](https://docs.teaflask.com/adapters#graphql) API, it runs them through your app's own client.
67
67
  - **Follow your workflows.** Write a procedure once, like how refunds work or when to escalate, and the agent follows it.
68
68
  - **Guide users around your app.** It takes people to the right page and highlights what to click.
69
69
  - **Fill in forms.** It fills the fields; the user reviews and submits.
@@ -0,0 +1,315 @@
1
+ import { CLOSE_SETTLE_MS, clicked, OPEN_SETTLE_MS, PICK_SETTLE_MS, pressKey, pressLikeAPointer, settledReading, theClickWouldSubmitOrLeaveThePage, theControlIsInert, } from "./page-gestures.js";
2
+ import { CONTROL_REDRAWN_REASON, CURATED_OPTIONS_REASON, DROPDOWN_NEVER_OPENED_REASON, DROPDOWN_PICK_DID_NOT_LAND_REASON, INERT_FIELD_REASON, MULTI_CHOICE_REASON, NO_OPTIONS_SHOWN_REASON, noMatchingOptionReasonListing, SUBMITTING_CONTROL_REASON, unavailableOptionReason, } from "./prefill-reasons.js";
3
+ import { accessibleNameOf, } from "../reader/accessible-name.js";
4
+ import { ariaBooleanOf, roleOf, theControlIsDisabled, } from "../reader/roles.js";
5
+ import { alreadyShowsTheValue, comboboxOwnerOf, confirmsLabel, displayNamesTheMatch, ownDisplayIsReliable, ownerShowsThePick, shownTextOf, } from "./dropdown-display.js";
6
+ import { _normalizedNodeName } from "../reader/snapshot-node.js";
7
+ import { lastReadIgnoreSelectors } from "../reader/snapshot.js";
8
+ import { theElementSitsInACuratedSubtree } from "../reader/snapshot-root.js";
9
+ import { visibilityOracleOf } from "../reader/visibility.js";
10
+ export async function customDropdownRefusalOf(element, value, probe, listingBudget, whenNoListOpens) {
11
+ const entryRefusal = _dropdownEntryRefusalOf(element);
12
+ if (entryRefusal !== null) {
13
+ return entryRefusal;
14
+ }
15
+ const want = value.trim();
16
+ if (ownDisplayIsReliable(element) &&
17
+ alreadyShowsTheValue(element, want, probe, null)) {
18
+ return null;
19
+ }
20
+ if (!(element instanceof HTMLInputElement) &&
21
+ theClickWouldSubmitOrLeaveThePage(element)) {
22
+ return SUBMITTING_CONTROL_REASON;
23
+ }
24
+ const found = await _openedListboxFor(element, probe);
25
+ const listbox = found.listbox;
26
+ const closedIfOurs = async () => {
27
+ if (found.provenance === "ours") {
28
+ await _closedPopup(element, listbox, probe);
29
+ }
30
+ };
31
+ if (listbox === null) {
32
+ if (!element.isConnected) {
33
+ return CONTROL_REDRAWN_REASON;
34
+ }
35
+ if (found.gesturesWereDispatched) {
36
+ await _closedPopup(element, listbox, probe);
37
+ }
38
+ return whenNoListOpens !== undefined
39
+ ? whenNoListOpens(DROPDOWN_NEVER_OPENED_REASON)
40
+ : DROPDOWN_NEVER_OPENED_REASON;
41
+ }
42
+ if (!element.isConnected) {
43
+ return CONTROL_REDRAWN_REASON;
44
+ }
45
+ if (!ownDisplayIsReliable(element) &&
46
+ alreadyShowsTheValue(element, want, probe, listbox)) {
47
+ await closedIfOurs();
48
+ return null;
49
+ }
50
+ if (theElementSitsInACuratedSubtree(listbox, lastReadIgnoreSelectors())) {
51
+ await closedIfOurs();
52
+ return CURATED_OPTIONS_REASON;
53
+ }
54
+ if (ariaBooleanOf(listbox.getAttribute("aria-multiselectable")) === true) {
55
+ await closedIfOurs();
56
+ return MULTI_CHOICE_REASON;
57
+ }
58
+ const options = _visibleOptionsOf(listbox, probe);
59
+ if (options.length === 0) {
60
+ await closedIfOurs();
61
+ return whenNoListOpens !== undefined
62
+ ? whenNoListOpens(NO_OPTIONS_SHOWN_REASON)
63
+ : NO_OPTIONS_SHOWN_REASON;
64
+ }
65
+ const cache = new Map();
66
+ const nameOf = (option) => accessibleNameOf(option, probe, cache).trim();
67
+ const match = _optionMatching(options, want, value, nameOf);
68
+ if (match === null) {
69
+ await closedIfOurs();
70
+ return _noMatchRefusalOf(element, options, nameOf, listingBudget);
71
+ }
72
+ const label = nameOf(match) !== "" ? nameOf(match) : want;
73
+ if (match.getAttribute("aria-selected") === "true" &&
74
+ displayNamesTheMatch(shownTextOf(element, probe, listbox), label, options, nameOf)) {
75
+ await closedIfOurs();
76
+ return null;
77
+ }
78
+ if (theControlIsDisabled(match, "option")) {
79
+ await closedIfOurs();
80
+ return unavailableOptionReason(label);
81
+ }
82
+ if (theClickWouldSubmitOrLeaveThePage(match)) {
83
+ await closedIfOurs();
84
+ return SUBMITTING_CONTROL_REASON;
85
+ }
86
+ return _confirmedPickRefusalOf(element, listbox, match, label, probe);
87
+ }
88
+ async function _confirmedPickRefusalOf(element, listbox, match, label, probe) {
89
+ const beforeText = shownTextOf(element, probe, listbox);
90
+ clicked(match);
91
+ const after = await settledReading(() => shownTextOf(element, probe, listbox), (text) => !element.isConnected ||
92
+ (text !== beforeText && confirmsLabel(text, label)), PICK_SETTLE_MS);
93
+ await _closedPopup(element, listbox, probe);
94
+ if (!element.isConnected) {
95
+ return CONTROL_REDRAWN_REASON;
96
+ }
97
+ const landed = after !== beforeText && confirmsLabel(after, label);
98
+ return landed ? null : DROPDOWN_PICK_DID_NOT_LAND_REASON;
99
+ }
100
+ export async function listboxPickRefusalOf(element, value, probe, listingBudget) {
101
+ if (theControlIsInert(element)) {
102
+ return INERT_FIELD_REASON;
103
+ }
104
+ if (ariaBooleanOf(element.getAttribute("aria-multiselectable")) === true) {
105
+ return MULTI_CHOICE_REASON;
106
+ }
107
+ const options = _visibleOptionsOf(element, probe);
108
+ if (options.length === 0) {
109
+ return NO_OPTIONS_SHOWN_REASON;
110
+ }
111
+ const cache = new Map();
112
+ const nameOf = (option) => accessibleNameOf(option, probe, cache).trim();
113
+ const want = value.trim();
114
+ const match = _optionMatching(options, want, value, nameOf);
115
+ if (match === null) {
116
+ return _noMatchRefusalOf(element, options, nameOf, listingBudget);
117
+ }
118
+ if (match.getAttribute("aria-selected") === "true") {
119
+ return null;
120
+ }
121
+ const label = nameOf(match) !== "" ? nameOf(match) : want;
122
+ if (theControlIsDisabled(match, "option")) {
123
+ return unavailableOptionReason(label);
124
+ }
125
+ if (theClickWouldSubmitOrLeaveThePage(match)) {
126
+ return SUBMITTING_CONTROL_REASON;
127
+ }
128
+ const owner = comboboxOwnerOf(element);
129
+ const ownerBefore = owner === null ? "" : shownTextOf(owner, probe, element);
130
+ clicked(match);
131
+ const selected = await settledReading(() => match.getAttribute("aria-selected"), (state) => state === "true" || !element.isConnected || !match.isConnected, PICK_SETTLE_MS);
132
+ if (element.isConnected && match.isConnected) {
133
+ return selected === "true" ? null : DROPDOWN_PICK_DID_NOT_LAND_REASON;
134
+ }
135
+ if (owner !== null) {
136
+ return (await ownerShowsThePick(owner, ownerBefore, label, probe))
137
+ ? null
138
+ : DROPDOWN_PICK_DID_NOT_LAND_REASON;
139
+ }
140
+ return CONTROL_REDRAWN_REASON;
141
+ }
142
+ async function _openedListboxFor(element, probe) {
143
+ if (element.getAttribute("aria-expanded") === "true") {
144
+ const alreadyOpen = _openedListboxOf(element, probe, new Set(), (candidate) => _popupBelongsToTrigger(candidate, element, probe));
145
+ return {
146
+ listbox: alreadyOpen,
147
+ provenance: alreadyOpen !== null ? "users" : "unattributed",
148
+ gesturesWereDispatched: false,
149
+ };
150
+ }
151
+ const alreadyShowing = visibilityOracleOf(probe);
152
+ const before = new Set(Array.from(document.querySelectorAll('[role="listbox"]')).filter((candidate) => alreadyShowing.isVisible(candidate)));
153
+ if (element instanceof HTMLElement) {
154
+ element.focus();
155
+ }
156
+ pressKey(element, "ArrowDown");
157
+ const viaKeyboard = await settledReading(() => _openedListboxOf(element, probe, before, () => true), (listbox) => listbox !== null || !element.isConnected, OPEN_SETTLE_MS);
158
+ if (viaKeyboard !== null) {
159
+ return {
160
+ listbox: viaKeyboard,
161
+ provenance: "ours",
162
+ gesturesWereDispatched: true,
163
+ };
164
+ }
165
+ if (!element.isConnected ||
166
+ theClickWouldSubmitOrLeaveThePage(element) ||
167
+ element.getAttribute("aria-expanded") === "true") {
168
+ return {
169
+ listbox: null,
170
+ provenance: "unattributed",
171
+ gesturesWereDispatched: true,
172
+ };
173
+ }
174
+ pressLikeAPointer(element);
175
+ const viaPointer = await settledReading(() => _openedListboxOf(element, probe, before, () => true), (listbox) => listbox !== null || !element.isConnected, OPEN_SETTLE_MS);
176
+ return {
177
+ listbox: viaPointer,
178
+ provenance: viaPointer !== null ? "ours" : "unattributed",
179
+ gesturesWereDispatched: true,
180
+ };
181
+ }
182
+ const KINSHIP_CLIMB_MAX = 5;
183
+ function _popupBelongsToTrigger(candidate, trigger, probe) {
184
+ if (candidate.contains(trigger) || trigger.contains(candidate)) {
185
+ return true;
186
+ }
187
+ const oracle = visibilityOracleOf(probe);
188
+ const body = trigger.ownerDocument.body;
189
+ let current = trigger.parentElement;
190
+ for (let depth = 0; depth < KINSHIP_CLIMB_MAX && current !== null && current !== body; depth++) {
191
+ const visibleInside = Array.from(current.querySelectorAll('[role="listbox"]')).filter((listbox) => oracle.isVisible(listbox));
192
+ if (visibleInside.length > 0) {
193
+ return visibleInside.length === 1 && visibleInside[0] === candidate;
194
+ }
195
+ current = current.parentElement;
196
+ }
197
+ return false;
198
+ }
199
+ function _openedListboxOf(trigger, probe, before, unlinkedIsOurs) {
200
+ const oracle = visibilityOracleOf(probe);
201
+ const byId = _idReferencedListboxOf(trigger, "aria-controls") ??
202
+ _idReferencedListboxOf(trigger, "aria-owns");
203
+ if (byId !== null && oracle.isVisible(byId)) {
204
+ return byId;
205
+ }
206
+ const active = _resolvedIdNear(trigger, trigger.getAttribute("aria-activedescendant"));
207
+ const viaActive = active?.closest('[role="listbox"]') ?? null;
208
+ if (viaActive !== null && oracle.isVisible(viaActive)) {
209
+ return viaActive;
210
+ }
211
+ for (const candidate of Array.from(document.querySelectorAll('[role="listbox"]'))) {
212
+ if (!before.has(candidate) &&
213
+ oracle.isVisible(candidate) &&
214
+ unlinkedIsOurs(candidate)) {
215
+ return candidate;
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+ function _idReferencedListboxOf(trigger, attribute) {
221
+ const ids = (trigger.getAttribute(attribute) ?? "")
222
+ .split(/\s+/)
223
+ .filter((id) => id !== "");
224
+ for (const id of ids) {
225
+ const hit = _resolvedIdNear(trigger, id);
226
+ if (hit === null) {
227
+ continue;
228
+ }
229
+ if (roleOf(hit) === "listbox") {
230
+ return hit;
231
+ }
232
+ const inner = hit.querySelector('[role="listbox"]');
233
+ if (inner !== null) {
234
+ return inner;
235
+ }
236
+ }
237
+ return null;
238
+ }
239
+ function _resolvedIdNear(trigger, id) {
240
+ if (id === null || id === "") {
241
+ return null;
242
+ }
243
+ const root = trigger.getRootNode();
244
+ if (root instanceof Document || root instanceof ShadowRoot) {
245
+ return root.getElementById(id);
246
+ }
247
+ return document.getElementById(id);
248
+ }
249
+ function _optionMatching(options, want, rawValue, nameOf) {
250
+ if (want === "") {
251
+ return null;
252
+ }
253
+ return (options.find((option) => nameOf(option) === want) ??
254
+ options.find((option) => nameOf(option).toLowerCase() === want.toLowerCase()) ??
255
+ options.find((option) => {
256
+ const shownToTheModel = _normalizedNodeName(nameOf(option));
257
+ const comparable = shownToTheModel.trimEnd();
258
+ return (shownToTheModel !== nameOf(option) &&
259
+ (comparable === want || comparable.toLowerCase() === want.toLowerCase()));
260
+ }) ??
261
+ options.find((option) => option.getAttribute("data-value") === rawValue) ??
262
+ null);
263
+ }
264
+ function _visibleOptionsOf(listbox, probe) {
265
+ const oracle = visibilityOracleOf(probe);
266
+ const ignoreSelectors = lastReadIgnoreSelectors();
267
+ return Array.from(listbox.querySelectorAll('[role="option"]')).filter((option) => oracle.isVisible(option) &&
268
+ !theElementSitsInACuratedSubtree(option, ignoreSelectors));
269
+ }
270
+ async function _closedPopup(control, listbox, probe) {
271
+ if (_popupLooksClosed(control, listbox, probe)) {
272
+ return;
273
+ }
274
+ const active = control.ownerDocument.activeElement;
275
+ const focused = active !== null &&
276
+ (control.contains(active) || (listbox?.contains(active) ?? false))
277
+ ? active
278
+ : null;
279
+ if (focused instanceof HTMLInputElement) {
280
+ focused.blur();
281
+ }
282
+ else if (control instanceof HTMLInputElement) {
283
+ control.blur();
284
+ }
285
+ else {
286
+ pressKey(focused ?? control, "Escape");
287
+ }
288
+ await settledReading(() => _popupLooksClosed(control, listbox, probe), (closed) => closed, CLOSE_SETTLE_MS);
289
+ }
290
+ function _popupLooksClosed(control, listbox, probe) {
291
+ if (!control.isConnected) {
292
+ return true;
293
+ }
294
+ if (control.hasAttribute("aria-expanded")) {
295
+ return control.getAttribute("aria-expanded") !== "true";
296
+ }
297
+ return !_popupShowing(listbox, probe);
298
+ }
299
+ function _popupShowing(listbox, probe) {
300
+ return (listbox !== null &&
301
+ listbox.isConnected &&
302
+ visibilityOracleOf(probe).isVisible(listbox));
303
+ }
304
+ function _dropdownEntryRefusalOf(element) {
305
+ if (theControlIsInert(element) || theControlIsDisabled(element, "combobox")) {
306
+ return INERT_FIELD_REASON;
307
+ }
308
+ return null;
309
+ }
310
+ function _noMatchRefusalOf(element, options, nameOf, listingBudget) {
311
+ const labels = options
312
+ .filter((option) => !theControlIsDisabled(option, "option"))
313
+ .map(nameOf);
314
+ return noMatchingOptionReasonListing(labels, listingBudget, element instanceof HTMLInputElement && !element.readOnly);
315
+ }
@@ -0,0 +1,39 @@
1
+ import { clicked, settledReading, theClickWouldSubmitOrLeaveThePage, theControlIsInert, TOGGLE_SETTLE_MS, } from "./page-gestures.js";
2
+ import { CHECKBOX_VALUE_REASON, CHECKED_STATE_UNKNOWN_REASON, CONTROL_REDRAWN_REASON, INERT_FIELD_REASON, RADIO_VALUE_REASON, SUBMITTING_CONTROL_REASON, VALUE_DID_NOT_LAND_REASON, } from "./prefill-reasons.js";
3
+ import { roleOf } from "../reader/roles.js";
4
+ export async function ariaToggleRefusalOf(element, value) {
5
+ if (roleOf(element) === "radio" && value !== "true") {
6
+ return RADIO_VALUE_REASON;
7
+ }
8
+ if (value !== "true" && value !== "false") {
9
+ return CHECKBOX_VALUE_REASON;
10
+ }
11
+ if (theControlIsInert(element)) {
12
+ return INERT_FIELD_REASON;
13
+ }
14
+ const state = _rawCheckedOf(element);
15
+ if (state === null) {
16
+ return CHECKED_STATE_UNKNOWN_REASON;
17
+ }
18
+ if (state === value) {
19
+ return null;
20
+ }
21
+ if (theClickWouldSubmitOrLeaveThePage(element)) {
22
+ return SUBMITTING_CONTROL_REASON;
23
+ }
24
+ for (let attempt = 0; attempt < 2; attempt++) {
25
+ const before = _rawCheckedOf(element);
26
+ clicked(element);
27
+ const after = await settledReading(() => _rawCheckedOf(element), (read) => read === value || !element.isConnected, TOGGLE_SETTLE_MS);
28
+ if (after === value || after === before) {
29
+ break;
30
+ }
31
+ }
32
+ if (!element.isConnected) {
33
+ return CONTROL_REDRAWN_REASON;
34
+ }
35
+ return _rawCheckedOf(element) === value ? null : VALUE_DID_NOT_LAND_REASON;
36
+ }
37
+ function _rawCheckedOf(element) {
38
+ return element.getAttribute("aria-checked")?.toLowerCase() ?? null;
39
+ }
@@ -0,0 +1,129 @@
1
+ import { PICK_SETTLE_MS, settledReading } from "./page-gestures.js";
2
+ import { roleOf } from "../reader/roles.js";
3
+ import { lastReadIgnoreSelectors } from "../reader/snapshot.js";
4
+ import { _theSubtreeIsCurated } from "../reader/snapshot-root.js";
5
+ import { visibilityOracleOf, } from "../reader/visibility.js";
6
+ export function shownTextOf(control, probe, excluding) {
7
+ if (control instanceof HTMLInputElement) {
8
+ const own = _normalized(control.value);
9
+ if (own !== "") {
10
+ return own;
11
+ }
12
+ return _nearestAncestorTextOf(control, probe, excluding);
13
+ }
14
+ const text = _visibleTextOf(control, probe, excluding);
15
+ if (text !== "") {
16
+ return text;
17
+ }
18
+ const inner = Array.from(control.querySelectorAll("input")).find((candidate) => !excluding?.contains(candidate));
19
+ return inner === undefined ? "" : _normalized(inner.value);
20
+ }
21
+ export function ownDisplayIsReliable(element) {
22
+ return element instanceof HTMLInputElement
23
+ ? element.value.trim() !== ""
24
+ : element.querySelector('[role="listbox"]') === null;
25
+ }
26
+ export function alreadyShowsTheValue(element, want, probe, excluding) {
27
+ return (want !== "" &&
28
+ _normalized(shownTextOf(element, probe, excluding)).toLowerCase() ===
29
+ _normalized(want).toLowerCase());
30
+ }
31
+ export function confirmsLabel(text, label) {
32
+ const shown = _normalized(text).toLowerCase();
33
+ const wanted = _normalized(label).toLowerCase();
34
+ if (shown === wanted) {
35
+ return true;
36
+ }
37
+ return _containsAsAWholeToken(shown, wanted);
38
+ }
39
+ export function displayNamesTheMatch(shown, matchLabel, options, nameOf) {
40
+ if (!confirmsLabel(shown, matchLabel)) {
41
+ return false;
42
+ }
43
+ return options.every((option) => {
44
+ const other = nameOf(option);
45
+ return other.length <= matchLabel.length || !confirmsLabel(shown, other);
46
+ });
47
+ }
48
+ export function comboboxOwnerOf(listbox) {
49
+ const id = listbox.id;
50
+ if (id === "") {
51
+ return null;
52
+ }
53
+ const root = listbox.getRootNode();
54
+ const scope = root instanceof Document || root instanceof ShadowRoot ? root : document;
55
+ for (const candidate of Array.from(scope.querySelectorAll("[aria-controls], [aria-owns]"))) {
56
+ const named = [
57
+ candidate.getAttribute("aria-controls") ?? "",
58
+ candidate.getAttribute("aria-owns") ?? "",
59
+ ]
60
+ .join(" ")
61
+ .split(/\s+/)
62
+ .filter((token) => token !== "");
63
+ if (named.includes(id) && roleOf(candidate) === "combobox") {
64
+ return candidate;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ export async function ownerShowsThePick(owner, ownerBefore, label, probe) {
70
+ const after = await settledReading(() => shownTextOf(owner, probe, null), (text) => text !== ownerBefore && confirmsLabel(text, label), PICK_SETTLE_MS);
71
+ return after !== ownerBefore && confirmsLabel(after, label);
72
+ }
73
+ function _containsAsAWholeToken(shown, wanted) {
74
+ if (wanted === "") {
75
+ return false;
76
+ }
77
+ for (let from = 0;; from += 1) {
78
+ const at = shown.indexOf(wanted, from);
79
+ if (at === -1) {
80
+ return false;
81
+ }
82
+ const before = at === 0 ? "" : shown[at - 1];
83
+ const after = at + wanted.length >= shown.length ? "" : shown[at + wanted.length];
84
+ if (!_isWordCharacter(before) && !_isWordCharacter(after)) {
85
+ return true;
86
+ }
87
+ from = at;
88
+ }
89
+ }
90
+ function _isWordCharacter(character) {
91
+ return character !== "" && /[\p{L}\p{N}_]/u.test(character);
92
+ }
93
+ function _nearestAncestorTextOf(control, probe, excluding) {
94
+ const body = control.ownerDocument.body;
95
+ let current = control.parentElement;
96
+ for (let depth = 0; depth < 5 && current !== null && current !== body; depth++) {
97
+ const text = _visibleTextOf(current, probe, excluding);
98
+ if (text !== "") {
99
+ return text;
100
+ }
101
+ current = current.parentElement;
102
+ }
103
+ return "";
104
+ }
105
+ function _visibleTextOf(root, probe, excluding) {
106
+ return _normalized(_textWithin(root, visibilityOracleOf(probe), excluding));
107
+ }
108
+ function _textWithin(node, oracle, excluding) {
109
+ if (node.nodeType === Node.TEXT_NODE) {
110
+ return node.textContent ?? "";
111
+ }
112
+ if (!(node instanceof Element) || node === excluding) {
113
+ return "";
114
+ }
115
+ if (node.getAttribute("aria-hidden") === "true" || !oracle.isVisible(node)) {
116
+ return "";
117
+ }
118
+ if (_theSubtreeIsCurated(node, lastReadIgnoreSelectors())) {
119
+ return "";
120
+ }
121
+ let text = "";
122
+ for (const child of Array.from(node.childNodes)) {
123
+ text += _textWithin(child, oracle, excluding);
124
+ }
125
+ return text;
126
+ }
127
+ function _normalized(text) {
128
+ return text.replace(/\s+/g, " ").trim();
129
+ }
@@ -0,0 +1,91 @@
1
+ import { ariaBooleanOf, roleOf, theControlIsDisabled, } from "../reader/roles.js";
2
+ export const TOGGLE_SETTLE_MS = 500;
3
+ export const OPEN_SETTLE_MS = 750;
4
+ export const PICK_SETTLE_MS = 750;
5
+ export const CLOSE_SETTLE_MS = 500;
6
+ const SETTLE_POLL_MS = 25;
7
+ export function typeLikeAUser(control, value) {
8
+ const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(control), "value");
9
+ if (descriptor?.set !== undefined) {
10
+ descriptor.set.call(control, value);
11
+ }
12
+ else {
13
+ control.value = value;
14
+ }
15
+ control.dispatchEvent(new Event("input", { bubbles: true }));
16
+ control.dispatchEvent(new Event("change", { bubbles: true }));
17
+ }
18
+ export function pressKey(target, key) {
19
+ const init = {
20
+ key,
21
+ code: key,
22
+ bubbles: true,
23
+ cancelable: true,
24
+ };
25
+ target.dispatchEvent(new KeyboardEvent("keydown", init));
26
+ target.dispatchEvent(new KeyboardEvent("keyup", init));
27
+ }
28
+ export function pressLikeAPointer(target) {
29
+ const armed = target.dispatchEvent(_pointerEventOf("pointerdown"));
30
+ if (armed) {
31
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
32
+ }
33
+ target.dispatchEvent(_pointerEventOf("pointerup"));
34
+ if (armed) {
35
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
36
+ }
37
+ clicked(target);
38
+ }
39
+ export function clicked(target) {
40
+ if (target instanceof HTMLElement) {
41
+ target.click();
42
+ }
43
+ else {
44
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
45
+ }
46
+ }
47
+ function _pointerEventOf(type) {
48
+ const init = { bubbles: true, cancelable: true, pointerId: 1 };
49
+ return typeof PointerEvent === "function"
50
+ ? new PointerEvent(type, init)
51
+ : new MouseEvent(type, init);
52
+ }
53
+ export async function settledReading(read, isSettled, budgetMs) {
54
+ const deadline = Date.now() + budgetMs;
55
+ await _afterMs(0);
56
+ let value = read();
57
+ while (!isSettled(value) && Date.now() < deadline) {
58
+ await _afterMs(SETTLE_POLL_MS);
59
+ value = read();
60
+ }
61
+ return value;
62
+ }
63
+ export function settledValueOf(read) {
64
+ return settledReading(read, () => true, 0);
65
+ }
66
+ function _afterMs(ms) {
67
+ return new Promise((resolve) => {
68
+ setTimeout(resolve, ms);
69
+ });
70
+ }
71
+ export function theClickWouldSubmitOrLeaveThePage(element) {
72
+ if (element.closest("a[href], area[href]") !== null) {
73
+ return true;
74
+ }
75
+ const button = element.closest("button");
76
+ return button !== null && button.form !== null && button.type !== "button";
77
+ }
78
+ export function theControlIsInert(element) {
79
+ if (theControlIsDisabled(element, roleOf(element))) {
80
+ return true;
81
+ }
82
+ if (ariaBooleanOf(element.getAttribute("aria-readonly")) === true) {
83
+ return true;
84
+ }
85
+ const group = element.closest('[role="radiogroup"]');
86
+ if (group !== null &&
87
+ ariaBooleanOf(group.getAttribute("aria-readonly")) === true) {
88
+ return true;
89
+ }
90
+ return element.closest("[inert]") !== null;
91
+ }
@@ -0,0 +1,75 @@
1
+ export const SECRET_FIELD_REASON = "This field holds a secret (a password or payment detail) — the user " +
2
+ "must type it themselves.";
3
+ export const FILE_FIELD_REASON = "File pickers cannot be filled by the assistant — the user must choose " +
4
+ "the file themselves.";
5
+ export const INERT_FIELD_REASON = "This field is disabled or read-only and cannot take a value right now.";
6
+ export const NOT_FILLABLE_REASON = "This element is not a fillable form field — take a fresh read and " +
7
+ "pick a textbox, dropdown, checkbox, radio button, switch, or editor ref.";
8
+ export const CHECKBOX_VALUE_REASON = 'A checkbox or switch takes "true" or "false", nothing else — call ' +
9
+ "again with one of those.";
10
+ export const RADIO_VALUE_REASON = 'A radio button can only be selected — pass "true" on the option to ' +
11
+ "select instead.";
12
+ const NO_MATCHING_OPTION_REASON = "No option in this dropdown matches that value — read the page to see " +
13
+ "the options it offers.";
14
+ export const VALUE_DID_NOT_LAND_REASON = "The page's own scripts rejected this value — it did not stay in the " +
15
+ "field.";
16
+ export const EDITOR_UNAVAILABLE_REASON = "This editor cannot be filled on this page — tell the user what to " +
17
+ "type instead.";
18
+ export const MIRROR_INPUT_REASON = "This checkbox is the page's hidden mirror of a styled control — take " +
19
+ "a fresh read and fill the visible checkbox or switch beside it instead.";
20
+ export const CHECKED_STATE_UNKNOWN_REASON = "This control does not report its checked state — tell the user to " +
21
+ "toggle it themselves.";
22
+ export const CONTROL_REDRAWN_REASON = "The page redrew this control while it was being set — take a fresh " +
23
+ "read and use the new ref.";
24
+ export const SUBMITTING_CONTROL_REASON = "Clicking this control would submit the form or leave the page — tell " +
25
+ "the user to set it themselves.";
26
+ export const DROPDOWN_NEVER_OPENED_REASON = "This dropdown would not open its options — tell the user to pick the " +
27
+ "option themselves.";
28
+ export const CURATED_OPTIONS_REASON = "This dropdown's options sit in a part of the page that has opted out " +
29
+ "of page reads — tell the user to pick it themselves.";
30
+ export const MULTI_CHOICE_REASON = "This control takes several choices at once, which prefill cannot set " +
31
+ "safely — tell the user which options to pick themselves.";
32
+ export const NO_OPTIONS_SHOWN_REASON = "This dropdown opened with nothing to pick — tell the user to fill it " +
33
+ "themselves.";
34
+ export const DROPDOWN_PICK_DID_NOT_LAND_REASON = "Picking that option did not change the dropdown's value — tell the " +
35
+ "user to pick it themselves.";
36
+ const OPTION_LIST_MAX = 15;
37
+ const OPTION_LABEL_MAX = 60;
38
+ const LISTING_REPORT_BUDGET_CHARS = 8_000;
39
+ export function reportListingBudget() {
40
+ return { remainingChars: LISTING_REPORT_BUDGET_CHARS };
41
+ }
42
+ export function noMatchingOptionReasonListing(labels, budget, freeTextHint = false) {
43
+ const named = labels.filter((label) => label !== "");
44
+ const shown = named.slice(0, OPTION_LIST_MAX);
45
+ if (shown.length === 0) {
46
+ return NO_MATCHING_OPTION_REASON;
47
+ }
48
+ const more = named.length - shown.length;
49
+ const overflow = more > 0 ? `, and ${String(more)} more` : "";
50
+ const next = shown.some((label) => label.length > OPTION_LABEL_MAX)
51
+ ? "— a label ending in … is cut short here: read the page for its " +
52
+ "full text, then call prefill_form again with one exactly."
53
+ : "— call prefill_form again with one of these exactly.";
54
+ const hint = freeTextHint
55
+ ? " If this field accepts typed text instead, tell the user the " +
56
+ "exact value to enter."
57
+ : "";
58
+ const listed = "No option in this dropdown matches that value. It offers: " +
59
+ `${shown.map(_quotedLabel).join(", ")}${overflow} ${next}${hint}`;
60
+ if (listed.length > budget.remainingChars) {
61
+ return NO_MATCHING_OPTION_REASON;
62
+ }
63
+ budget.remainingChars -= listed.length;
64
+ return listed;
65
+ }
66
+ export function unavailableOptionReason(label) {
67
+ return (`The option ${_quotedLabel(label)} is disabled right now — call ` +
68
+ "again with a different option or tell the user to pick it themselves.");
69
+ }
70
+ function _quotedLabel(label) {
71
+ const cut = label.length > OPTION_LABEL_MAX
72
+ ? `${label.slice(0, OPTION_LABEL_MAX)}…`
73
+ : label;
74
+ return `"${cut}"`;
75
+ }