browsergym-workarena 0.1.0rc7__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- browsergym/workarena/__init__.py +3 -2
- browsergym/workarena/api/ui_themes.py +35 -0
- browsergym/workarena/api/user.py +153 -0
- browsergym/workarena/api/utils.py +1 -1
- browsergym/workarena/config.py +43 -1
- browsergym/workarena/data_files/setup_files/ui_themes/workarena_themes.xml +2313 -0
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -0
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -0
- browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -0
- browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -0
- browsergym/workarena/install.py +620 -155
- browsergym/workarena/tasks/base.py +85 -26
- browsergym/workarena/tasks/dashboard.py +620 -0
- browsergym/workarena/tasks/form.py +121 -85
- browsergym/workarena/tasks/knowledge.py +30 -14
- browsergym/workarena/tasks/list.py +121 -67
- browsergym/workarena/tasks/navigation.py +18 -16
- browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +272 -0
- browsergym/workarena/tasks/scripts/generate_forms.py +2 -2
- browsergym/workarena/tasks/scripts/list.py +2 -2
- browsergym/workarena/tasks/scripts/validate.py +2 -2
- browsergym/workarena/tasks/service_catalog.py +106 -74
- browsergym/workarena/tasks/utils/form.py +5 -3
- browsergym/workarena/tasks/utils/js_utils.js +123 -2
- browsergym/workarena/tasks/utils/string.py +15 -0
- browsergym/workarena/tasks/utils/utils.py +20 -0
- browsergym/workarena/utils.py +31 -2
- {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.0.dist-info}/METADATA +7 -3
- {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.0.dist-info}/RECORD +32 -21
- {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.0.dist-info}/WHEEL +1 -1
- {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.0.dist-info}/entry_points.txt +0 -0
- {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate configurations for the report and dashboard tasks
|
|
3
|
+
|
|
4
|
+
Notes: sometimes it crashes (e.g., timeout, etc.). Just relaunch and it will be fine.
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import multiprocessing
|
|
10
|
+
import random
|
|
11
|
+
import tenacity
|
|
12
|
+
|
|
13
|
+
from functools import partial
|
|
14
|
+
from playwright.sync_api import sync_playwright
|
|
15
|
+
|
|
16
|
+
from browsergym.workarena.api.utils import table_api_call, table_column_info
|
|
17
|
+
from browsergym.workarena.config import (
|
|
18
|
+
REPORT_DATE_FILTER,
|
|
19
|
+
REPORT_PATCH_FLAG,
|
|
20
|
+
REPORT_RETRIEVAL_MINMAX_CONFIG_PATH,
|
|
21
|
+
REPORT_RETRIEVAL_VALUE_CONFIG_PATH,
|
|
22
|
+
DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH,
|
|
23
|
+
DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH,
|
|
24
|
+
)
|
|
25
|
+
from browsergym.workarena.instance import SNowInstance
|
|
26
|
+
from browsergym.workarena.tasks.dashboard import DashboardRetrievalTask
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
N_CPU = 20
|
|
30
|
+
MAX_CONFIGS = 1000
|
|
31
|
+
REPORT = True # Set to True for reports, False for dashboards
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DummyDashboard(DashboardRetrievalTask):
|
|
35
|
+
def all_configs(self):
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
"url": "",
|
|
39
|
+
"chart_title": "",
|
|
40
|
+
"chart_series": "",
|
|
41
|
+
"question": "max",
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_report_urls(instance):
|
|
47
|
+
# Generate a bunch of reports on the fly based on valid table fields
|
|
48
|
+
ON_THE_FLY_REPORTS = []
|
|
49
|
+
for table in [
|
|
50
|
+
"alm_asset",
|
|
51
|
+
"alm_hardware",
|
|
52
|
+
"asmt_assessment_instance_question",
|
|
53
|
+
"asmt_m2m_stakeholder",
|
|
54
|
+
"ast_contract",
|
|
55
|
+
"change_request",
|
|
56
|
+
"cmdb_ci_computer",
|
|
57
|
+
"incident",
|
|
58
|
+
"sc_cat_item",
|
|
59
|
+
"sys_user",
|
|
60
|
+
]:
|
|
61
|
+
cols = [
|
|
62
|
+
x
|
|
63
|
+
for x, y in table_column_info(instance=instance, table=table).items()
|
|
64
|
+
if y.get("cangroup", False)
|
|
65
|
+
and y.get("type", None) == "choice"
|
|
66
|
+
and "upon" not in x.lower()
|
|
67
|
+
]
|
|
68
|
+
for col in cols:
|
|
69
|
+
ON_THE_FLY_REPORTS.append({"table": table, "field": col, "type": "pie"})
|
|
70
|
+
ON_THE_FLY_REPORTS.append({"table": table, "field": col, "type": "bar"})
|
|
71
|
+
|
|
72
|
+
# Reports that are already in the instance
|
|
73
|
+
system_report_tables = "alm_asset,alm_hardware,asmt_assessment_instance_question,asmt_m2m_stakeholder,ast_contract,change_request,cmdb_ci_computer"
|
|
74
|
+
SYSTEM_REPORTS = table_api_call(
|
|
75
|
+
instance=instance,
|
|
76
|
+
table="sys_report",
|
|
77
|
+
params={
|
|
78
|
+
"sysparm_query": f"sys_class_name=sys_report^active=true^typeINtrend,donut,vertical_bar,line,horizontal_bar,pie,bar,spline,area^descriptionLIKE{REPORT_PATCH_FLAG}^tableIN{system_report_tables}",
|
|
79
|
+
"sysparm_fields": "sys_id",
|
|
80
|
+
},
|
|
81
|
+
)["result"]
|
|
82
|
+
|
|
83
|
+
REPORTS = ON_THE_FLY_REPORTS + SYSTEM_REPORTS
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
(
|
|
87
|
+
f"/now/nav/ui/classic/params/target/sys_report_template.do%3Fsysparm_field%3D{report['field']}%26sysparm_type%3D{report['type']}%26sysparm_table%3D{report['table']}%26sysparm_from_list%3Dtrue%26sysparm_chart_size%3Dlarge%26sysparm_manual_labor%3Dtrue%26sysparm_query=sys_created_on<javascript:gs.dateGenerate('{REPORT_DATE_FILTER}','00:00:00')^EQ"
|
|
88
|
+
if report.get("sys_id", None) is None
|
|
89
|
+
else f"/now/nav/ui/classic/params/target/sys_report_template.do%3Fjvar_report_id={report['sys_id']}"
|
|
90
|
+
)
|
|
91
|
+
for report in REPORTS
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_dashboard_urls(instance):
|
|
96
|
+
# XXX: It's not ideal to use sys_ids but I couldn't find a better way
|
|
97
|
+
DASHBOARDS = [
|
|
98
|
+
"812fa4400f1130101527008c07767e1a", # Assessment overview
|
|
99
|
+
"fa5fe3e1773130107384c087cc5a99d5", # Asset overview
|
|
100
|
+
"68ee1f30770230107384c087cc5a992e", # Asset contract overview
|
|
101
|
+
"05b0a8b7c3123010a282a539e540dd69", # Change overview
|
|
102
|
+
"18b1f472533130104c90ddeeff7b12a6", # Incident overview
|
|
103
|
+
"287d07d1ff3130106c1ef9a7cddcbd5d", # Request overview
|
|
104
|
+
"7ab78953eb32011008f2951ff15228e6", # Service catalog overview
|
|
105
|
+
"2d297c880f1130101527008c07767e27", # Survey overview
|
|
106
|
+
"6b706f448f231110953ddffc9071a4f3", # Telemetry - Table growth
|
|
107
|
+
"15c5d2d377213010a435478c4f5a993c", # Usage overview
|
|
108
|
+
"85a57f9677100110ba155631dc5a9905", # Web api usage overview
|
|
109
|
+
"c38ca3a273031010ae8dd21efaf6a747", # Data classification
|
|
110
|
+
"3d48f669538223008329ddeeff7b1253", # Problem overview
|
|
111
|
+
]
|
|
112
|
+
return [
|
|
113
|
+
f"/now/nav/ui/classic/params/target/%24pa_dashboard.do%3Fsysparm_dashboard%3D{dashboard}"
|
|
114
|
+
for dashboard in DASHBOARDS
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@tenacity.retry(
|
|
119
|
+
wait=tenacity.wait_fixed(1),
|
|
120
|
+
stop=tenacity.stop_after_attempt(10),
|
|
121
|
+
)
|
|
122
|
+
def get_all_configs_by_url(url, is_report):
|
|
123
|
+
with sync_playwright() as p:
|
|
124
|
+
browser = p.chromium.launch(headless=True)
|
|
125
|
+
page = browser.new_page()
|
|
126
|
+
task = DummyDashboard(
|
|
127
|
+
instance=SNowInstance(),
|
|
128
|
+
fixed_config={
|
|
129
|
+
"url": url,
|
|
130
|
+
"chart_title": "",
|
|
131
|
+
"chart_series": "",
|
|
132
|
+
"question": "max",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
task.setup(page=page)
|
|
136
|
+
|
|
137
|
+
# Handle the case where a dashboard is not found
|
|
138
|
+
task._wait_for_ready(page)
|
|
139
|
+
iframe = page.frame(name=task.iframe_id)
|
|
140
|
+
assert iframe.get_by_text("not found").count() == 0, "Report or dashboard not found"
|
|
141
|
+
|
|
142
|
+
# Find all the charts
|
|
143
|
+
charts = task._get_charts(page)
|
|
144
|
+
|
|
145
|
+
# Check enough charts
|
|
146
|
+
if len(charts) == 0:
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
questions = []
|
|
150
|
+
for chart in charts:
|
|
151
|
+
try:
|
|
152
|
+
chart_title = chart[0] if not is_report else "" # No title for reports
|
|
153
|
+
_, chart_data, _ = task._get_chart_by_title(page, chart_title)
|
|
154
|
+
|
|
155
|
+
# Select a series randomly
|
|
156
|
+
for series_idx in range(len(chart_data)):
|
|
157
|
+
series_name = chart_data[series_idx]["name"] if len(chart_data) > 1 else ""
|
|
158
|
+
data = chart_data[series_idx]["data"]
|
|
159
|
+
|
|
160
|
+
# Check if the data is interesting
|
|
161
|
+
labels = [point["label"] for point in data]
|
|
162
|
+
if len(labels) <= 1:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if any(l.isdigit() for l in labels):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# Generate all value questions
|
|
169
|
+
for format in ["count", "percent"]:
|
|
170
|
+
for label in labels:
|
|
171
|
+
questions.append(
|
|
172
|
+
{
|
|
173
|
+
"url": url,
|
|
174
|
+
"chart_title": chart_title,
|
|
175
|
+
"chart_series": series_name,
|
|
176
|
+
"question": f"value; {format}; {label}",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Generate all other questions
|
|
181
|
+
questions.append(
|
|
182
|
+
{
|
|
183
|
+
"url": url,
|
|
184
|
+
"chart_title": chart_title,
|
|
185
|
+
"chart_series": series_name,
|
|
186
|
+
"question": "min",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
questions.append(
|
|
190
|
+
{
|
|
191
|
+
"url": url,
|
|
192
|
+
"chart_title": chart_title,
|
|
193
|
+
"chart_series": series_name,
|
|
194
|
+
"question": "max",
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print("Exception in worker", url, chart_title, e)
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
if len(questions) == 0:
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
# Test out all questions and keep only those that work
|
|
205
|
+
valid_questions = []
|
|
206
|
+
for question in questions:
|
|
207
|
+
chat_messages = []
|
|
208
|
+
task.config = question
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
task.cheat(page=page, chat_messages=chat_messages)
|
|
212
|
+
valid = task.validate(page=page, chat_messages=chat_messages)[0]
|
|
213
|
+
except Exception as e:
|
|
214
|
+
# raise e
|
|
215
|
+
print("Exception in worker config validations", url, question, e)
|
|
216
|
+
valid = 0
|
|
217
|
+
|
|
218
|
+
if valid == 1:
|
|
219
|
+
valid_questions.append(question)
|
|
220
|
+
else:
|
|
221
|
+
print(f"Failed to validate question {question}")
|
|
222
|
+
|
|
223
|
+
print("Worker found", len(valid_questions), "valid questions")
|
|
224
|
+
return valid_questions
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
instance = SNowInstance()
|
|
229
|
+
reports = get_report_urls(instance)
|
|
230
|
+
gen_func = partial(get_all_configs_by_url, is_report=REPORT)
|
|
231
|
+
|
|
232
|
+
if REPORT:
|
|
233
|
+
urls = get_report_urls(instance)
|
|
234
|
+
output_by_question = {
|
|
235
|
+
"value": REPORT_RETRIEVAL_VALUE_CONFIG_PATH,
|
|
236
|
+
"min,max": REPORT_RETRIEVAL_MINMAX_CONFIG_PATH,
|
|
237
|
+
}
|
|
238
|
+
else:
|
|
239
|
+
urls = get_dashboard_urls(instance)
|
|
240
|
+
output_by_question = {
|
|
241
|
+
"value": DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH,
|
|
242
|
+
"min,max": DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
print(f"Generating configs for {len(urls)} URLs")
|
|
246
|
+
configs = []
|
|
247
|
+
with multiprocessing.Pool(processes=N_CPU) as pool:
|
|
248
|
+
results = pool.map(gen_func, urls)
|
|
249
|
+
pool.close()
|
|
250
|
+
pool.join()
|
|
251
|
+
|
|
252
|
+
# Flatten results list into config
|
|
253
|
+
for result in results:
|
|
254
|
+
configs += result
|
|
255
|
+
|
|
256
|
+
# Post-process the configs and save them
|
|
257
|
+
for question_type in output_by_question:
|
|
258
|
+
types = [x.strip() for x in question_type.split(",")]
|
|
259
|
+
|
|
260
|
+
type_configs = [
|
|
261
|
+
config for config in configs if config["question"].split(";")[0].strip() in types
|
|
262
|
+
]
|
|
263
|
+
type_configs = set(
|
|
264
|
+
json.dumps(c) for c in type_configs
|
|
265
|
+
) # Serialize to string to make unique
|
|
266
|
+
type_configs = [json.loads(c) for c in type_configs]
|
|
267
|
+
random.shuffle(type_configs)
|
|
268
|
+
type_configs = type_configs[:MAX_CONFIGS]
|
|
269
|
+
|
|
270
|
+
print("Saving", len(type_configs), "configs for", question_type)
|
|
271
|
+
with open(output_by_question[question_type], "w") as f:
|
|
272
|
+
json.dump(type_configs, f)
|
|
@@ -28,12 +28,12 @@ def generate_form_task_configs(task_name, task_class, num_configs=1):
|
|
|
28
28
|
"""Try to setup and cheat a task, and return its configuration if it's new"""
|
|
29
29
|
try:
|
|
30
30
|
with sync_playwright() as p:
|
|
31
|
-
task = task_class()
|
|
31
|
+
task = task_class(seed=seed)
|
|
32
32
|
browser = p.chromium.launch()
|
|
33
33
|
context = browser.new_context() # Set the timeout here
|
|
34
34
|
context.set_default_timeout(5000)
|
|
35
35
|
page = context.new_page()
|
|
36
|
-
task._generate_random_config(
|
|
36
|
+
task._generate_random_config(page=page)
|
|
37
37
|
config = {
|
|
38
38
|
"template_record": task.template_record,
|
|
39
39
|
"fields": {
|
|
@@ -47,12 +47,12 @@ def generate_task_configs(task_class, num_configs=1000, task_type="sort"):
|
|
|
47
47
|
"""Try to setup and cheat a task, and return its configuration if it's new"""
|
|
48
48
|
try:
|
|
49
49
|
with sync_playwright() as p:
|
|
50
|
-
task = task_class()
|
|
50
|
+
task = task_class(seed=seed)
|
|
51
51
|
browser = p.chromium.launch()
|
|
52
52
|
context = browser.new_context() # Set the timeout here
|
|
53
53
|
context.set_default_timeout(5000)
|
|
54
54
|
page = context.new_page()
|
|
55
|
-
goal, _ = task._generate_random_config(
|
|
55
|
+
goal, _ = task._generate_random_config(page=page)
|
|
56
56
|
chat_messages = []
|
|
57
57
|
try:
|
|
58
58
|
task.cheat(page=page, chat_messages=chat_messages)
|
|
@@ -143,8 +143,8 @@ def validate_on_page(task_class, task_config, page):
|
|
|
143
143
|
cheat_passed = False
|
|
144
144
|
task_done = False
|
|
145
145
|
reward = 0.0
|
|
146
|
-
task = task_class(fixed_config=task_config)
|
|
147
|
-
task.setup(page=page
|
|
146
|
+
task = task_class(seed=1, fixed_config=task_config)
|
|
147
|
+
task.setup(page=page)
|
|
148
148
|
chat_messages = []
|
|
149
149
|
task.cheat(page=page, chat_messages=chat_messages)
|
|
150
150
|
cheat_passed = True
|
|
@@ -5,13 +5,21 @@ Tasks that require interacting with the service catalog
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
-
from
|
|
9
|
-
from urllib import parse
|
|
10
|
-
|
|
8
|
+
from typing import List
|
|
11
9
|
import numpy as np
|
|
12
10
|
import playwright.sync_api
|
|
11
|
+
|
|
13
12
|
from playwright.sync_api import Page
|
|
14
|
-
from
|
|
13
|
+
from time import sleep
|
|
14
|
+
from urllib import parse
|
|
15
|
+
|
|
16
|
+
from .base import AbstractServiceNowTask
|
|
17
|
+
from .utils.form import fill_text
|
|
18
|
+
|
|
19
|
+
from ..api.requests import (
|
|
20
|
+
get_request_by_id,
|
|
21
|
+
db_delete_from_table,
|
|
22
|
+
)
|
|
15
23
|
from ..config import (
|
|
16
24
|
ORDER_DEVELOPER_LAPTOP_TASK_CONFIG_PATH,
|
|
17
25
|
ORDER_IPAD_MINI_TASK_CONFIG_PATH,
|
|
@@ -23,12 +31,8 @@ from ..config import (
|
|
|
23
31
|
ORDER_DEVELOPMENT_LAPTOP_PC_TASK_CONFIG_PATH,
|
|
24
32
|
ORDER_LOANER_LAPTOP_TASK_CONFIG_PATH,
|
|
25
33
|
)
|
|
26
|
-
from
|
|
27
|
-
from
|
|
28
|
-
get_request_by_id,
|
|
29
|
-
db_delete_from_table,
|
|
30
|
-
)
|
|
31
|
-
from .base import AbstractServiceNowTask
|
|
34
|
+
from ..instance import SNowInstance
|
|
35
|
+
from .utils.utils import check_url_suffix_match
|
|
32
36
|
|
|
33
37
|
ADDITIONAL_SOFTWARE = [
|
|
34
38
|
"Slack",
|
|
@@ -142,6 +146,8 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
142
146
|
|
|
143
147
|
Parameters:
|
|
144
148
|
-----------
|
|
149
|
+
seed: int
|
|
150
|
+
Random seed
|
|
145
151
|
instance: SNowInstance
|
|
146
152
|
The instance to use.
|
|
147
153
|
fixed_config: dict
|
|
@@ -154,12 +160,14 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
154
160
|
|
|
155
161
|
def __init__(
|
|
156
162
|
self,
|
|
163
|
+
seed: int,
|
|
157
164
|
instance: SNowInstance = None,
|
|
158
165
|
fixed_request_item: str = None,
|
|
159
166
|
fixed_config: dict = None,
|
|
160
167
|
config_path: str = None,
|
|
161
168
|
):
|
|
162
169
|
super().__init__(
|
|
170
|
+
seed=seed,
|
|
163
171
|
instance=instance,
|
|
164
172
|
start_rel_url="/now/nav/ui/classic/params/target/catalog_home.do%3Fsysparm_view%3Dcatalog_default",
|
|
165
173
|
final_rel_url="/now/nav/ui/classic/params/target/com.glideapp.servicecatalog_checkout_view_v2.do",
|
|
@@ -177,9 +185,9 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
177
185
|
with open(config_path, "r") as f:
|
|
178
186
|
self.all_configs = json.load(f)
|
|
179
187
|
|
|
180
|
-
def _wait_for_ready(self, page: Page, wait_for_form_api=False) -> None:
|
|
188
|
+
def _wait_for_ready(self, page: Page, wait_for_form_api: bool = False) -> None:
|
|
181
189
|
"""
|
|
182
|
-
Waits for the main iframe to be
|
|
190
|
+
Waits for the the main iframe to be loaded
|
|
183
191
|
|
|
184
192
|
"""
|
|
185
193
|
logging.debug(f"Waiting for {self.js_prefix} to be fully loaded")
|
|
@@ -197,60 +205,86 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
197
205
|
def form_js_selector(self):
|
|
198
206
|
return self.js_prefix + "." + self.js_api_forms
|
|
199
207
|
|
|
200
|
-
def
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
def get_init_scripts(self) -> List[str]:
|
|
209
|
+
return super().get_init_scripts() + [
|
|
210
|
+
"registerGsftMainLoaded()",
|
|
211
|
+
self._get_disable_add_to_cart_script(),
|
|
212
|
+
self._get_remove_top_items_panel_script(),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def _get_disable_add_to_cart_script(self):
|
|
216
|
+
"""
|
|
217
|
+
Disables the 'Add to Cart' button on the service catalog page
|
|
218
|
+
This is necessary so that agents running in parallel do not interfere with each other (cart is shared between sessions)
|
|
219
|
+
|
|
220
|
+
"""
|
|
221
|
+
script = """
|
|
222
|
+
function disableAddToCartButton() {
|
|
223
|
+
waLog('Searching for top items panel...', 'disableAddToCartButton');
|
|
224
|
+
let button = document.querySelector('button[aria-label="Add to Cart"]');
|
|
225
|
+
if (button) {
|
|
226
|
+
button.disabled = true;
|
|
227
|
+
waLog('WorkArena: Disabled the "Add to Cart" button', 'disableAddToCartButton');
|
|
228
|
+
} else {
|
|
229
|
+
waLog('WorkArena: Could not find the "Add to Cart" button', 'disableAddToCartButton');
|
|
230
|
+
}
|
|
209
231
|
}
|
|
210
|
-
|
|
232
|
+
|
|
233
|
+
runInGsftMainOnlyAndProtectByURL(disableAddToCartButton, 'glideapp.servicecatalog_cat_item_view.do');
|
|
211
234
|
"""
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
return script
|
|
236
|
+
|
|
237
|
+
def _get_remove_top_items_panel_script(self):
|
|
238
|
+
"""Get script that removes the 'top items' panel that sometimes on the landing page of service catalog
|
|
239
|
+
Disables the 'Top Requests' panel that sometimes appears on the landing page of the service catalog
|
|
240
|
+
Runs in a loop to keep checking for the host element and shadow root
|
|
241
|
+
URL is secured by running only on the catalog_home page; this is a heuristic to avoid running on other pages
|
|
242
|
+
and does not check that the URL is an exact match, as moving back and forth between pages can cause the URL
|
|
243
|
+
to change, but catalog_home will always be present.
|
|
244
|
+
"""
|
|
245
|
+
script = """
|
|
246
|
+
function removeTopItemsPanel() {
|
|
247
|
+
waLog('Searching for top items panel...', 'removeTopItemsPanel');
|
|
248
|
+
let headings = Array.from(document.querySelectorAll('[role="heading"]'));
|
|
249
|
+
headings.forEach((heading) => {
|
|
250
|
+
if (heading.textContent.includes("Top Requests")) {
|
|
251
|
+
let parentDiv = heading.closest('div.drag_section');
|
|
252
|
+
if (parentDiv) {
|
|
253
|
+
parentDiv.remove();
|
|
254
|
+
waLog('Removed parent div for heading: ' + heading.textContent, 'removeTopItemsPanel');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
runInGsftMainOnlyAndProtectByURL(removeTopItemsPanel, `catalog_home`);
|
|
261
|
+
"""
|
|
262
|
+
return script
|
|
263
|
+
|
|
264
|
+
def setup_goal(self, page: Page) -> tuple[str, dict]:
|
|
265
|
+
super().setup_goal(page=page)
|
|
266
|
+
|
|
267
|
+
# Get the task configuration
|
|
217
268
|
assert self.all_configs is not None, "No configuration available for the task."
|
|
218
269
|
config = self.fixed_config if self.fixed_config else self.random.choice(self.all_configs)
|
|
219
|
-
# use fixed config if any
|
|
220
270
|
self.requested_item = config["item"]
|
|
221
271
|
self.short_description = config["description"]
|
|
222
272
|
self.quantity = config["quantity"]
|
|
223
273
|
self.requested_configuration = config["configuration"]
|
|
224
274
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# generate goal
|
|
275
|
+
# Generate goal
|
|
228
276
|
goal = f'Go to the hardware store and order {self.quantity} "{self.requested_item}"'
|
|
229
277
|
if len(self.requested_configuration) > 0:
|
|
230
278
|
goal += f" with configuration {dict((k, v[1]) for k, v in self.requested_configuration.items())}"
|
|
231
279
|
info = {}
|
|
232
280
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def _remove_top_items_panel(self, page: Page):
|
|
236
|
-
"""Removes the 'top items' panel that sometimes on the landing page"""
|
|
237
|
-
frame = page.wait_for_selector("iframe#gsft_main").content_frame()
|
|
281
|
+
# Used to keep track of the sysid of the request for validation
|
|
282
|
+
self.request_sysid = None
|
|
238
283
|
|
|
239
|
-
|
|
240
|
-
frame.evaluate(
|
|
241
|
-
"""() => {
|
|
242
|
-
const headings = Array.from(document.querySelectorAll('[role="heading"]'));
|
|
243
|
-
headings.forEach((heading) => {
|
|
244
|
-
if (heading.textContent.includes("Top Requests")) {
|
|
245
|
-
let parentDiv = heading.closest('div.drag_section');
|
|
246
|
-
if (parentDiv) parentDiv.remove();
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
}"""
|
|
250
|
-
)
|
|
284
|
+
return goal, info
|
|
251
285
|
|
|
252
286
|
def cheat(self, page: Page, chat_messages: list[str]) -> None:
|
|
253
|
-
super().cheat(page, chat_messages)
|
|
287
|
+
super().cheat(page=page, chat_messages=chat_messages)
|
|
254
288
|
self._wait_for_ready(page=page)
|
|
255
289
|
|
|
256
290
|
iframe = page.frame(self.js_prefix)
|
|
@@ -323,22 +357,9 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
323
357
|
with page.expect_navigation():
|
|
324
358
|
order_now_button.click()
|
|
325
359
|
|
|
326
|
-
def _generate_random_config(self,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
# is not affected so we'll make sure the agent can only use that one
|
|
330
|
-
disable_add_to_cart = """
|
|
331
|
-
window.addEventListener('DOMContentLoaded', (event) => {
|
|
332
|
-
const button = document.querySelector('button[aria-label="Add to Cart"]');
|
|
333
|
-
if (button) {
|
|
334
|
-
button.disabled = true;
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
"""
|
|
338
|
-
self._add_init_scripts_to_context_and_reload(
|
|
339
|
-
page, ["registerGsftMainLoaded()", disable_add_to_cart]
|
|
340
|
-
)
|
|
341
|
-
self._wait_for_ready(page)
|
|
360
|
+
def _generate_random_config(self, page: Page):
|
|
361
|
+
"""Generate a random configuration for the task"""
|
|
362
|
+
self.setup(page=page, do_start=False)
|
|
342
363
|
if self.fixed_request_item:
|
|
343
364
|
self.requested_item = self.fixed_request_item
|
|
344
365
|
else:
|
|
@@ -346,15 +367,16 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
346
367
|
self.requested_item = self.random.choice(list(META_CONFIGS.keys()))
|
|
347
368
|
|
|
348
369
|
meta_config = META_CONFIGS[self.requested_item]
|
|
349
|
-
self.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
370
|
+
self.fixed_config = {
|
|
371
|
+
"item": self.requested_item,
|
|
372
|
+
"description": meta_config["desc"],
|
|
373
|
+
"quantity": self.random.randint(1, 11),
|
|
374
|
+
"configuration": {
|
|
375
|
+
ctrl_name: (ctrl_type, self.random.choice(values))
|
|
376
|
+
for ctrl_name, (ctrl_type, values) in meta_config["options"].items()
|
|
377
|
+
},
|
|
355
378
|
}
|
|
356
|
-
|
|
357
|
-
self.request_sysid = None
|
|
379
|
+
self.setup(page=page, do_start=True)
|
|
358
380
|
|
|
359
381
|
def _get_control_description(self, page, field):
|
|
360
382
|
"""
|
|
@@ -394,7 +416,17 @@ class OrderHardwareTask(AbstractServiceNowTask):
|
|
|
394
416
|
)
|
|
395
417
|
|
|
396
418
|
def validate(self, page: Page, chat_messages: list[str]) -> tuple[int, bool, str, dict]:
|
|
397
|
-
self.
|
|
419
|
+
right_url = check_url_suffix_match(page, expected_url=self.final_url, task=self)
|
|
420
|
+
if not right_url:
|
|
421
|
+
return (
|
|
422
|
+
0,
|
|
423
|
+
False,
|
|
424
|
+
"",
|
|
425
|
+
{
|
|
426
|
+
"message": f"The page is not in the right URL to validate task {self.__class__.__name__}."
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
|
|
398
430
|
# Retrieve the request sysid from the URL
|
|
399
431
|
current_url = parse.urlparse(
|
|
400
432
|
parse.unquote(self.page.evaluate("() => window.location.href"))
|
|
@@ -32,19 +32,21 @@ def fill_text(page, input_field, value, iframe=None):
|
|
|
32
32
|
# Fill in the value using a procedure that triggers the autocomplete
|
|
33
33
|
input_field.fill(value[:-1])
|
|
34
34
|
page.keyboard.press(value[-1])
|
|
35
|
+
time.sleep(0.5)
|
|
35
36
|
|
|
36
|
-
# Wait
|
|
37
|
+
# Wait for the autocomplete menu to open and be ready
|
|
37
38
|
max_wait_time = SNOW_BROWSER_TIMEOUT # maximum time to wait in seconds
|
|
38
39
|
start_time = time.time()
|
|
39
40
|
while True:
|
|
40
|
-
if input_field.get_attribute("aria-expanded") == "true"
|
|
41
|
+
if input_field.get_attribute("aria-expanded") == "true" and not input_field.evaluate(
|
|
42
|
+
"e => e.ac.isResolving()"
|
|
43
|
+
):
|
|
41
44
|
break
|
|
42
45
|
if time.time() - start_time > (max_wait_time / 1000):
|
|
43
46
|
raise TimeoutError("Timeout waiting for autocompletion menu to open")
|
|
44
47
|
time.sleep(0.5) # wait for a short period before checking again
|
|
45
48
|
|
|
46
49
|
# Select the desired value
|
|
47
|
-
time.sleep(0.5) # wait for the list to be populated
|
|
48
50
|
options = iframe.locator("[id^='ac_option_']")
|
|
49
51
|
for i in range(options.count()):
|
|
50
52
|
opt = options.nth(i)
|