browsergym-workarena 0.1.0rc7__py3-none-any.whl → 0.2.1__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.
Files changed (35) hide show
  1. browsergym/workarena/__init__.py +3 -2
  2. browsergym/workarena/api/ui_themes.py +35 -0
  3. browsergym/workarena/api/user.py +153 -0
  4. browsergym/workarena/api/utils.py +1 -1
  5. browsergym/workarena/config.py +43 -1
  6. browsergym/workarena/data_files/setup_files/ui_themes/workarena_themes.xml +2313 -0
  7. browsergym/workarena/data_files/task_configs/all_menu.json +94 -94
  8. browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -0
  9. browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -0
  10. browsergym/workarena/data_files/task_configs/filter_service_catalog_item_list_task.json +7985 -7981
  11. browsergym/workarena/data_files/task_configs/impersonation_users.json +2 -2
  12. browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -0
  13. browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -0
  14. browsergym/workarena/install.py +620 -155
  15. browsergym/workarena/tasks/base.py +85 -26
  16. browsergym/workarena/tasks/dashboard.py +620 -0
  17. browsergym/workarena/tasks/form.py +121 -85
  18. browsergym/workarena/tasks/knowledge.py +30 -14
  19. browsergym/workarena/tasks/list.py +121 -67
  20. browsergym/workarena/tasks/navigation.py +18 -16
  21. browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +272 -0
  22. browsergym/workarena/tasks/scripts/generate_forms.py +2 -2
  23. browsergym/workarena/tasks/scripts/list.py +2 -2
  24. browsergym/workarena/tasks/scripts/validate.py +2 -2
  25. browsergym/workarena/tasks/service_catalog.py +106 -74
  26. browsergym/workarena/tasks/utils/form.py +5 -3
  27. browsergym/workarena/tasks/utils/js_utils.js +123 -2
  28. browsergym/workarena/tasks/utils/string.py +15 -0
  29. browsergym/workarena/tasks/utils/utils.py +20 -0
  30. browsergym/workarena/utils.py +31 -2
  31. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/METADATA +15 -5
  32. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/RECORD +35 -24
  33. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/WHEEL +1 -1
  34. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/entry_points.txt +0 -0
  35. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.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(seed=seed, page=page)
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(seed=seed, page=page)
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, seed=1)
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 time import sleep
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 ..instance import SNowInstance
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 .utils.form import fill_text
27
- from ..api.requests import (
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 fully loaded
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 setup(self, page: playwright.sync_api.Page, seed: int = None) -> None:
201
- self.pre_setup(page=page, seed=seed)
202
- # the cart is shared for all agents running in parallel. The "Order Now" button
203
- # is not affected so we'll make sure the agent can only use that one
204
- disable_add_to_cart = """
205
- window.addEventListener('DOMContentLoaded', (event) => {
206
- const button = document.querySelector('button[aria-label="Add to Cart"]');
207
- if (button) {
208
- button.disabled = true;
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
- self._add_init_scripts_to_context_and_reload(
213
- page, ["registerGsftMainLoaded()", disable_add_to_cart]
214
- )
215
- self._wait_for_ready(page)
216
- self._remove_top_items_panel(page)
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
- self.request_sysid = None
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
- return goal, info
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
- # Use evaluate to find and remove divs containing an element with role="heading" and the text "Top Requests"
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, seed: int, page: Page):
327
- self.pre_setup(page=page, seed=seed)
328
- # the cart is shared for all agents running in parallel. The "Order Now" button
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.short_description = meta_config["desc"]
350
- # ... choose a random quantity and configuration
351
- self.quantity = self.random.randint(1, 11)
352
- self.requested_configuration = {
353
- ctrl_name: (ctrl_type, self.random.choice(values))
354
- for ctrl_name, (ctrl_type, values) in meta_config["options"].items()
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._wait_for_ready(page)
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 until the attribute of the locator changes to the desired value
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)