browsergym-workarena 0.2.1__py3-none-any.whl → 0.3.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 (91) hide show
  1. browsergym/workarena/__init__.py +13 -1
  2. browsergym/workarena/api/category.py +74 -0
  3. browsergym/workarena/api/change_request.py +87 -0
  4. browsergym/workarena/api/computer_asset.py +90 -0
  5. browsergym/workarena/api/cost_center.py +19 -0
  6. browsergym/workarena/api/expense_line.py +89 -0
  7. browsergym/workarena/api/incident.py +45 -0
  8. browsergym/workarena/api/knowledge.py +29 -0
  9. browsergym/workarena/api/problem.py +90 -0
  10. browsergym/workarena/api/report.py +183 -0
  11. browsergym/workarena/api/requested_items.py +63 -0
  12. browsergym/workarena/api/user.py +11 -8
  13. browsergym/workarena/api/utils.py +47 -3
  14. browsergym/workarena/config.py +21 -1
  15. browsergym/workarena/data_files/setup_files/forms/expected_incident_form_fields.json +1 -1
  16. browsergym/workarena/data_files/setup_files/forms/expected_request_item_form_fields.json +1 -0
  17. browsergym/workarena/data_files/setup_files/knowledge/protocols.json +46 -0
  18. browsergym/workarena/data_files/setup_files/knowledge/test.html +1 -0
  19. browsergym/workarena/data_files/setup_files/lists/expected_asset_list_columns.json +2 -24
  20. browsergym/workarena/data_files/setup_files/lists/expected_change_request_list_columns.json +4 -40
  21. browsergym/workarena/data_files/setup_files/lists/expected_expense_line_list_columns.json +12 -0
  22. browsergym/workarena/data_files/setup_files/lists/expected_hardware_list_columns.json +1 -42
  23. browsergym/workarena/data_files/setup_files/lists/expected_incident_list_columns.json +2 -18
  24. browsergym/workarena/data_files/setup_files/lists/expected_problem_list_columns.json +12 -0
  25. browsergym/workarena/data_files/setup_files/lists/expected_requested_items_list_columns.json +12 -0
  26. browsergym/workarena/data_files/setup_files/lists/expected_service_catalog_list_columns.json +2 -19
  27. browsergym/workarena/data_files/setup_files/lists/expected_user_list_columns.json +3 -50
  28. browsergym/workarena/data_files/task_configs/all_menu.json +1 -1
  29. browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -1
  30. browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -1
  31. browsergym/workarena/data_files/task_configs/filter_service_catalog_item_list_task.json +1 -1
  32. browsergym/workarena/data_files/task_configs/impersonation_users.json +1 -1
  33. browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -1
  34. browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -1
  35. browsergym/workarena/human_eval/console.js +176 -0
  36. browsergym/workarena/human_eval/tool.py +366 -0
  37. browsergym/workarena/install.py +81 -20
  38. browsergym/workarena/tasks/base.py +55 -20
  39. browsergym/workarena/tasks/comp_building_block.py +4 -0
  40. browsergym/workarena/tasks/compositional/__init__.py +76 -0
  41. browsergym/workarena/tasks/compositional/base.py +364 -0
  42. browsergym/workarena/tasks/compositional/dash_do_base.py +1366 -0
  43. browsergym/workarena/tasks/compositional/dash_do_catalog.py +1127 -0
  44. browsergym/workarena/tasks/compositional/dash_do_catalog_infeasible.py +2047 -0
  45. browsergym/workarena/tasks/compositional/dash_do_create_incident.py +403 -0
  46. browsergym/workarena/tasks/compositional/dash_do_create_incident_infeasible.py +278 -0
  47. browsergym/workarena/tasks/compositional/dash_do_create_problem.py +336 -0
  48. browsergym/workarena/tasks/compositional/dash_do_create_problem_infeasible.py +235 -0
  49. browsergym/workarena/tasks/compositional/dash_do_filter.py +1600 -0
  50. browsergym/workarena/tasks/compositional/dash_do_request_item.py +1315 -0
  51. browsergym/workarena/tasks/compositional/dash_do_request_item_infeasible.py +693 -0
  52. browsergym/workarena/tasks/compositional/delete_record.py +341 -0
  53. browsergym/workarena/tasks/compositional/edit_knowledge_base.py +457 -0
  54. browsergym/workarena/tasks/compositional/expense_management.py +598 -0
  55. browsergym/workarena/tasks/compositional/filter_and_do.py +139 -0
  56. browsergym/workarena/tasks/compositional/find_and_order_item.py +345 -0
  57. browsergym/workarena/tasks/compositional/manage_change_request_schedule.py +1417 -0
  58. browsergym/workarena/tasks/compositional/mark_duplicate_problems.py +499 -0
  59. browsergym/workarena/tasks/compositional/maximize_investment_return.py +1763 -0
  60. browsergym/workarena/tasks/compositional/navigate_and_do.py +1151 -0
  61. browsergym/workarena/tasks/compositional/navigate_and_do_infeasible.py +2100 -0
  62. browsergym/workarena/tasks/compositional/offboard_user.py +207 -0
  63. browsergym/workarena/tasks/compositional/onboard_user.py +226 -0
  64. browsergym/workarena/tasks/compositional/update_task.py +145 -0
  65. browsergym/workarena/tasks/compositional/utils/curriculum.py +215 -0
  66. browsergym/workarena/tasks/compositional/utils/infeasible_configs.py +151 -0
  67. browsergym/workarena/tasks/compositional/utils/knapsack.py +192 -0
  68. browsergym/workarena/tasks/compositional/warranty_check.py +227 -0
  69. browsergym/workarena/tasks/compositional/work_assignment.py +804 -0
  70. browsergym/workarena/tasks/compositional/workload_balancing.py +396 -0
  71. browsergym/workarena/tasks/dashboard.py +194 -12
  72. browsergym/workarena/tasks/form.py +1024 -232
  73. browsergym/workarena/tasks/knowledge.py +216 -25
  74. browsergym/workarena/tasks/list.py +519 -102
  75. browsergym/workarena/tasks/mark_duplicate_problem.py +171 -0
  76. browsergym/workarena/tasks/navigation.py +55 -13
  77. browsergym/workarena/tasks/scripts/extract_all_menu_items.py +9 -2
  78. browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +6 -5
  79. browsergym/workarena/tasks/scripts/service_catalog.py +2 -1
  80. browsergym/workarena/tasks/scripts/validate.py +8 -2
  81. browsergym/workarena/tasks/send_chat_message.py +90 -0
  82. browsergym/workarena/tasks/service_catalog.py +94 -26
  83. browsergym/workarena/tasks/utils/form.py +1 -4
  84. browsergym/workarena/tasks/utils/private_tasks.py +63 -0
  85. browsergym/workarena/tasks/utils/utils.py +13 -0
  86. {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.1.dist-info}/METADATA +19 -18
  87. browsergym_workarena-0.3.1.dist-info/RECORD +138 -0
  88. {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.1.dist-info}/entry_points.txt +1 -0
  89. browsergym_workarena-0.2.1.dist-info/RECORD +0 -85
  90. {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.1.dist-info}/WHEEL +0 -0
  91. {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,29 @@
1
+ import inspect
1
2
  import json
2
3
  import logging
3
4
  import playwright.sync_api
4
5
  import re
5
6
 
7
+ from collections import OrderedDict
6
8
  from english_words import get_english_words_set
9
+ from faker import Faker
10
+
11
+ fake = Faker()
7
12
  from playwright.sync_api._generated import Page
13
+ from tenacity import retry, stop_after_delay, retry_if_exception_type
8
14
  from time import sleep
9
15
  from typing import List, Tuple
10
16
  from urllib import parse
11
17
 
18
+ from .base import AbstractServiceNowTask
19
+ from .comp_building_block import CompositionalBuildingBlockTask
20
+
12
21
  from ..api.utils import (
13
22
  db_delete_from_table,
14
23
  table_api_call,
15
24
  table_column_info,
16
25
  HTTPError,
17
26
  )
18
- from .base import AbstractServiceNowTask
19
27
  from ..config import (
20
28
  SNOW_BROWSER_TIMEOUT,
21
29
  # Paths to the configuration files
@@ -30,23 +38,60 @@ from ..config import (
30
38
  EXPECTED_INCIDENT_FORM_FIELDS_PATH,
31
39
  EXPECTED_PROBLEM_FORM_FIELDS_PATH,
32
40
  EXPECTED_USER_FORM_FIELDS_PATH,
41
+ EXPECTED_REQUEST_ITEM_FORM_FIELDS_PATH,
33
42
  )
34
43
  from ..instance import SNowInstance
35
44
  from .utils.form import fill_text
36
- from .utils.utils import check_url_suffix_match
45
+ from .utils.utils import check_url_suffix_match, prettyprint_enum
37
46
 
38
47
 
39
48
  ENGLISH_WORDS = list(get_english_words_set(["web2"]))
40
49
 
41
50
 
42
51
  class ServiceNowFormTask(AbstractServiceNowTask):
52
+ """
53
+ Generic task for record manipulation (create/edit) in a table using a Glide form.
54
+
55
+ Class attributes:
56
+ -----------------
57
+ config_path: str
58
+ Path to the JSON file containing all possible configurations for the task. Defined in subclasses
59
+ expected_fields_path: str
60
+ Path to the JSON file containing all expected fields for the task. Defined in subclasses
61
+
62
+ Parameters:
63
+ -----------------
64
+ form_url: str
65
+ The URL of the form to use to create the record.
66
+ instance: SNowInstance
67
+ The instance on which to create the record.
68
+ extra_mandatory_fields: List
69
+ List of fields that should be marked as mandatory in the form (overrides the page specification).
70
+ unique_valued_fields: dict
71
+ Dictionary of fields that should have a unique value. Keys are the field names and values are functions
72
+ used to make the fields unique (e.g., appending self.unique).
73
+ fixed_config: dict
74
+ Configuration to use for the task. If provided, the task will use the provided configuration instead of
75
+ selecting a random one. See browsergym/workarena/data_files/task_configs/create_hardware_asset_task.json
76
+ for an example of a configuration file.
77
+ check_record_created: bool
78
+ Whether to check if the record is created in cheat. This step uses the localStorage to get the sys_id, which is None when creating multiple forms. Hence, we bypass this step in the cheat.
79
+ """
80
+
81
+ config_path = None
82
+ expected_fields_path = None
83
+
43
84
  def __init__(
44
85
  self,
45
- seed: int,
46
- start_rel_url,
86
+ form_url: str,
87
+ table_label: str,
47
88
  instance: SNowInstance = None,
48
89
  extra_mandatory_fields: List = [],
49
90
  prohibited_fields: List = [],
91
+ unique_valued_fields: dict = {},
92
+ fixed_config: dict = None,
93
+ check_record_created: bool = True,
94
+ seed: int = None,
50
95
  ) -> None:
51
96
  # The type of fields that we support interacting with
52
97
  self.supported_types = [
@@ -70,19 +115,55 @@ class ServiceNowFormTask(AbstractServiceNowTask):
70
115
 
71
116
  # Prohibited fields: fields that we shouldn't interact with
72
117
  self.prohibited_fields = prohibited_fields
118
+ self.table_metadata = None
119
+ self.fields = None
120
+ self.mandatory_fields = None
121
+ self.optional_fields = None
122
+
123
+ super().__init__(seed=seed, instance=instance, start_rel_url=form_url)
124
+
125
+ self.form_url = form_url
126
+
127
+ # Table pretty printed name
128
+ self.table_label = table_label
129
+ self.table_name = self.form_url.split("/")[-1].split(".do")[0]
130
+
131
+ # Key in which the sys_id of the created record will be stored in the local storage
132
+ self.session_sys_id_field = f"{id(self)}.record_sys_id"
133
+
134
+ # Fields that should have a unique value (will append them with a uuid)
135
+ self.unique_valued_fields = unique_valued_fields
136
+
137
+ # Fixed configuration
138
+ # We set the task fields, template record and created sysids to allow for easy access in compositional task creation
139
+ self.fixed_config = fixed_config
140
+ self.template_record = None
141
+ self.task_fields = None
142
+ self.fields = None
143
+ self.protected_fields = None # Fields that should not be edited
144
+ if fixed_config is not None:
145
+ self._set_required_config_attributes(fixed_config)
146
+
147
+ self.n_extra_fields = None
148
+ self.created_sysids = []
149
+ if self.config_path:
150
+ self.all_configs = self.all_configs()
151
+ if self.expected_fields_path:
152
+ with open(self.expected_fields_path, "r") as f:
153
+ self.expected_fields = json.load(f)
154
+ self.check_record_created = check_record_created
73
155
 
74
- super().__init__(seed=seed, instance=instance, start_rel_url=start_rel_url)
156
+ @classmethod
157
+ def all_configs(cls) -> List[dict]:
158
+ with open(cls.config_path, "r") as f:
159
+ return json.load(f)
75
160
 
76
161
  def _get_form(self, page):
77
162
  """
78
163
  Loads a bunch of info about the form on a page into object variables
79
-
80
164
  """
81
-
82
165
  # Extract Glide table information
83
166
  logging.debug("Extracting Glide table metadata")
84
- # ... name of data table
85
- self.table_name = page.evaluate(f"{self.form_js_selector}.getTableName()")
86
167
  # ... expand reference fields
87
168
  # XXX: We need to expand reference fields and the referenced field is missing from the
88
169
  # form's client-side info so we are going to use the meta API to get that info.
@@ -107,6 +188,14 @@ class ServiceNowFormTask(AbstractServiceNowTask):
107
188
  },
108
189
  )["result"][0]["label"].lower()
109
190
 
191
+ def _get_fields(self, page: Page) -> None:
192
+ """
193
+ Get the form fields; split them into mandatory and optional
194
+ """
195
+ page.wait_for_function(
196
+ f"typeof window.{self.js_prefix} !== 'undefined' && window.{self.js_prefix}.WORKARENA_LOAD_COMPLETE",
197
+ )
198
+
110
199
  # Get the form fields
111
200
  def is_field_visible(field):
112
201
  return page.evaluate(
@@ -192,28 +281,43 @@ class ServiceNowFormTask(AbstractServiceNowTask):
192
281
 
193
282
  return value
194
283
 
195
- def _wait_for_ready(self, page: Page) -> None:
284
+ def _wait_for_ready(self, page: Page, iframe_only=False) -> None:
196
285
  """
197
- Waits for the main iframe to be fully loaded.
286
+ Waits for the main iframe and APIs to be fully loaded
287
+
288
+ Parameters:
289
+ ----------
290
+ page: playwright.sync_api.Page
291
+ The page on which to wait for the iframe to be loaded
292
+ iframe_only: bool
293
+ If True, only wait for the iframe to be loaded. If False, also wait for the APIs to be available.
198
294
 
199
295
  """
200
296
  logging.debug(f"Waiting for {self.js_prefix} to be fully loaded")
201
- page.wait_for_function(
202
- f"typeof window.{self.js_prefix} !== 'undefined' && window.{self.js_prefix}.WORKARENA_LOAD_COMPLETE",
203
- )
297
+ try:
298
+ page.wait_for_function(
299
+ f"typeof window.{self.js_prefix} !== 'undefined' && window.{self.js_prefix}.WORKARENA_LOAD_COMPLETE",
300
+ )
301
+ except:
302
+ page.wait_for_load_state("networkidle")
303
+ return
204
304
  logging.debug(f"Detected {self.js_prefix} ready")
205
305
 
206
- logging.debug("Waiting for Glide form API to be available")
207
- page.wait_for_function(f"window.{self.form_js_selector}")
208
- logging.debug("Detected Glide form API ready")
306
+ if not iframe_only:
307
+ logging.debug("Waiting for Glide form API to be available")
308
+ page.wait_for_function(f"window.{self.form_js_selector}")
309
+ logging.debug("Detected Glide form API ready")
209
310
 
210
- logging.debug("Waiting for Glide tabs API to be available")
211
- page.wait_for_function(f"typeof window.{self.js_prefix}.g_tabs2Sections !== 'undefined'")
212
- logging.debug("Detected Glide tabs API ready")
311
+ logging.debug("Waiting for Glide tabs API to be available")
312
+ page.wait_for_function(
313
+ f"typeof window.{self.js_prefix}.g_tabs2Sections !== 'undefined'"
314
+ )
315
+ logging.debug("Detected Glide tabs API ready")
213
316
 
214
317
  def get_init_scripts(self) -> List[str]:
215
318
  # Extract expected URL suffix
216
319
  url_suffix = parse.urlparse(self.start_url).path.split("/")[-1]
320
+ url_suffix = self.table_name
217
321
 
218
322
  # Add a few initialization scripts
219
323
  return super().get_init_scripts() + [
@@ -228,6 +332,45 @@ class ServiceNowFormTask(AbstractServiceNowTask):
228
332
 
229
333
  runInGsftMainOnlyAndProtectByURL(addFormMandatoryFields, '{url_suffix}');
230
334
  """,
335
+ f"""
336
+ function patchSubmitButton() {{
337
+ waLog('Attempting to override form submit function', 'patchSubmitButton');
338
+ // Save the original function if it hasn't been saved yet
339
+ if(typeof old_gsftSubmit == 'undefined'){{
340
+ old_gsftSubmit = new Function('return ' + gsftSubmit.toString())();
341
+ waLog('Saved original submit function', 'patchSubmitButton');
342
+ }}
343
+
344
+ // Override the function to save the sys_id in the local storage
345
+ gsftSubmit = function(control, form, action_name) {{
346
+ localStorage['{self.session_sys_id_field}'] = {self.js_api_forms}.getUniqueValue();
347
+ old_gsftSubmit(control, form, action_name);
348
+ }};
349
+ waLog('Patched submit function. All done.', 'patchSubmitButton');
350
+ }}
351
+
352
+ runInGsftMainOnlyAndProtectByURL(patchSubmitButton, '{url_suffix}');
353
+ """,
354
+ # Ensure that only the expected fields are changed
355
+ f"""
356
+ function monitorChangeOnFields() {{
357
+ let predefinedList = {json.dumps(self.protected_fields)};
358
+ console.log('Predefined list: ' + predefinedList);
359
+ document.querySelectorAll("input, select, textarea").forEach((e) => {{
360
+ // Get the field name - some fields are like incident.xyz.field_name
361
+ let fieldName = e.name.split('.').pop();
362
+ if (!predefinedList.includes(fieldName)) {{
363
+ e.addEventListener("change", () => {{
364
+ window.WORKARENA_BAD_FIELD_CHANGED = true;
365
+ console.log("Field " + e.name + " changed and was not expected to.");
366
+ }})
367
+ waLog('Added change listener to field ' + e.name, 'monitorChangeOnFields');
368
+ }}
369
+ }})
370
+ }}
371
+
372
+ runInGsftMainOnlyAndProtectByURL(monitorChangeOnFields, '{url_suffix}');
373
+ """,
231
374
  ]
232
375
 
233
376
  def start(self, page: Page) -> None:
@@ -235,6 +378,177 @@ class ServiceNowFormTask(AbstractServiceNowTask):
235
378
  self._wait_for_ready(page)
236
379
  self._get_form(page)
237
380
 
381
+ def _fill_fields(
382
+ self,
383
+ page: Page,
384
+ iframe: playwright.sync_api.Frame,
385
+ task_fields: List[str],
386
+ update: bool = False,
387
+ ) -> None:
388
+ """
389
+ Fill the fields in the form with the values from the template record. The fields to fill are specified in the
390
+ task_fields list. Update is a flag that indicates if the task is an update task.
391
+ """
392
+ # XXX We need to ensure the table metadata as well as fields are set
393
+ # before we can proceed with the cheat function
394
+ if self.table_metadata is None:
395
+ self._get_form(page)
396
+ if self.fields is None:
397
+ self._get_fields(page)
398
+
399
+ # From now on, we assume we are on the form page
400
+ self._wait_for_ready(page)
401
+
402
+ # Retry on TypeError since in very rare occasions, element evaluates to null, which raises a TypeError
403
+ @retry(
404
+ stop=stop_after_delay(SNOW_BROWSER_TIMEOUT // 1000),
405
+ retry=retry_if_exception_type(TypeError),
406
+ )
407
+ def show_field_tab(field):
408
+ """
409
+ Finds the control that allows to show the section where a field is located
410
+ and clicks on it.
411
+
412
+ """
413
+ section = page.evaluate(
414
+ f"""() => {{
415
+ const element = {self.form_js_selector}.getElement('{field}');
416
+ const ancestors = element.ancestors();
417
+ for (let ancestor of ancestors) {{
418
+ // Ancestor IDs are of the form "section-<section name>"
419
+ if (ancestor.id.startsWith('section-')) {{
420
+ return ancestor.id;
421
+ }}
422
+ }}
423
+ return null; // Return null if no matching ancestor is found
424
+ }}"""
425
+ )
426
+ section_id = section.split("-")[-1]
427
+ tab_sections = {
428
+ s.split(".")[-1]: i
429
+ for i, s in enumerate(page.evaluate(f"{self.js_prefix}.g_tabs2Sections.tabIDs"))
430
+ }
431
+
432
+ # If the section is not in the tabs do nothing (it's probably the main section)
433
+ if section_id not in tab_sections:
434
+ return
435
+
436
+ page.evaluate_handle(
437
+ f"""{self.js_prefix}.g_tabs2Sections.tabsTabs[
438
+ {tab_sections[section_id]}
439
+ ].element"""
440
+ ).click(force=True)
441
+
442
+ for field in task_fields:
443
+ # Get the field's input control
444
+ control = iframe.get_by_label(
445
+ page.evaluate(f"{self.form_js_selector}.getLabelOf('{field}')"),
446
+ exact=True,
447
+ )
448
+ if control.count() > 1:
449
+ control = control.nth(0)
450
+ # If the field is in a section, click on its header to make it visible
451
+ show_field_tab(field)
452
+
453
+ # Some fields are marked as string by the API but accept selection-based input
454
+ # We use the select tag condition to match these fields. Others are marked as integers.
455
+ if self.table_metadata[field]["type"] == "choice":
456
+ control.select_option(str(self.template_record[field]))
457
+
458
+ # Checkboxes
459
+ elif self.table_metadata[field]["type"] == "boolean":
460
+ control.set_checked(1 if self.template_record[field] == "true" else 0)
461
+
462
+ # Any text-based input
463
+ else:
464
+ fill_text(
465
+ page=page,
466
+ iframe=iframe,
467
+ input_field=control,
468
+ value=self.template_record[field],
469
+ )
470
+
471
+ # Click on the submit button
472
+ page.wait_for_timeout(1000)
473
+ if update:
474
+ iframe.locator("#sysverb_update").click()
475
+ else:
476
+ iframe.locator("#sysverb_insert").click()
477
+
478
+ # Check if the record was created
479
+ if self.check_record_created:
480
+ # This does not work if multiple forms are created at once. The localStorage returns null after the first form
481
+ for attempt in range(5):
482
+ # in update tasks, the sys_id is already known as the asset is created from the start
483
+ if update:
484
+ sys_id = self.record_sys_id
485
+ else:
486
+ sys_id = page.evaluate("localStorage").get(self.session_sys_id_field, None)
487
+
488
+ # Pull the record from the database
489
+ record = table_api_call(
490
+ instance=self.instance,
491
+ table=self.table_name,
492
+ params={
493
+ "sysparm_query": f"sys_id={sys_id}",
494
+ "sysparm_display_value": True,
495
+ },
496
+ )["result"]
497
+ if len(record) > 0:
498
+ break
499
+ page.wait_for_timeout(1500)
500
+ if attempt == 4:
501
+ raise ValueError("The record was not created.")
502
+
503
+ def _set_required_config_attributes(self, config: dict) -> None:
504
+ """
505
+ Set the required attributes for the task configuration.
506
+ """
507
+ # XXX Warning: Some subclasses may expect a specific order of elements
508
+ self.template_record = config["template_record"]
509
+ for f, func in self.unique_valued_fields.items():
510
+ self.template_record[f] = func(self.template_record[f])
511
+ self.task_fields = config["task_fields"]
512
+
513
+ def get_new_field_value(self, field: str, template_record: dict, table_metadata: dict) -> str:
514
+ """
515
+ Generate a new value for a field based on the field type.
516
+ """
517
+ new_value = template_record[
518
+ field
519
+ ] # Default to the template value in case the task field is not of the supported types
520
+ if field in self.unique_valued_fields:
521
+ return new_value
522
+ if "choices" in table_metadata[field]:
523
+ if (
524
+ # ... if the field has choices that are not available in the UI
525
+ template_record[field] not in table_metadata[field]["choices"].values()
526
+ or
527
+ # ... avoid empty values if there are other choices
528
+ (
529
+ (template_record[field] is None or template_record[field] == "")
530
+ and len(table_metadata[field]["choices"]) > 1
531
+ )
532
+ ):
533
+ # XXX: We skip empty-string values because 1) they are not really interesting to
534
+ # ask for since the agent doesn't have to do anything. They also cause issues
535
+ # in the validation since they don't get saved properly to the database.
536
+ choices = [v for k, v in table_metadata[field]["choices"].items() if k != ""]
537
+ new_value = self.random.choice(choices)
538
+ elif table_metadata[field]["type"] in self.string_types:
539
+ # ... if the field is a string, we want to make sure that it's not empty
540
+
541
+ if table_metadata[field]["type"] == "string":
542
+ new_value = " ".join(self.random.choice(ENGLISH_WORDS, size=5))
543
+ elif table_metadata[field]["type"] == "email":
544
+ new_value = f"{'.'.join(self.random.choice(ENGLISH_WORDS, size=2))}@workarena.com"
545
+ elif table_metadata[field]["type"] == "ph_number":
546
+ new_value = (
547
+ f"(514) {self.random.randint(100, 999)}-{self.random.randint(1000, 9999)}"
548
+ )
549
+
550
+ return new_value
551
+
238
552
 
239
553
  class GenericNewRecordTask(ServiceNowFormTask):
240
554
  """
@@ -242,32 +556,17 @@ class GenericNewRecordTask(ServiceNowFormTask):
242
556
 
243
557
  Parameters:
244
558
  -----------
245
- form_url: str
246
- The URL of the form to use to create the record.
247
- instance: SNowInstance
248
- The instance on which to create the record.
249
- extra_mandatory_fields: List
250
- List of fields that should be marked as mandatory in the form (overrides the page specification).
251
- unique_valued_fields: dict
252
- Dictionary of fields that should have a unique value. Keys are the field names and values are functions
253
- used to make the fields unique (e.g., appending self.unique).
254
559
  min_fields: int
255
560
  Minimum number of fields to fill (except if mandatory is more).
256
561
  max_fields: int
257
562
  Maximum number of fields to fill (except if mandatory is more).
258
- fixed_config: dict
259
- Configuration to use for the task. If provided, the task will use the provided configuration instead of
260
- selecting a random one. See browsergym/workarena/data_files/task_configs/create_hardware_asset_task.json
261
- for an example of a configuration file.
262
- config_path:
263
- The path to the JSON file containing all configurations for the task. Provided by subclasses
264
- expected_fields_path:
265
- The path to the JSON file containing all expected fields for the task. Provided by subclasses
266
563
  """
267
564
 
565
+ config_path = None
566
+ expected_fields_path = None
567
+
268
568
  def __init__(
269
569
  self,
270
- seed: int,
271
570
  form_url: str,
272
571
  table_label: str,
273
572
  instance: SNowInstance = None,
@@ -277,70 +576,26 @@ class GenericNewRecordTask(ServiceNowFormTask):
277
576
  min_fields: int = 5,
278
577
  max_fields: int = None,
279
578
  fixed_config: dict = None,
280
- config_path: str = None,
281
- expected_fields_path: str = None,
579
+ seed: int = None,
580
+ check_record_created: bool = True,
282
581
  ) -> None:
283
582
  super().__init__(
284
583
  seed=seed,
584
+ form_url=form_url,
585
+ table_label=table_label,
285
586
  instance=instance,
286
- start_rel_url=form_url,
287
587
  extra_mandatory_fields=extra_mandatory_fields,
288
588
  prohibited_fields=prohibited_fields,
589
+ unique_valued_fields=unique_valued_fields,
590
+ fixed_config=fixed_config,
591
+ check_record_created=check_record_created,
289
592
  )
290
- self.form_url = form_url
291
-
292
- # Table pretty printed name
293
- self.table_label = table_label
294
-
295
- # Key in which the sys_id of the created record will be stored in the local storage
296
- self.session_sys_id_field = f"{id(self)}.record_sys_id"
297
-
298
- # Fields that should have a unique value (will append them with a uuid)
299
- self.unique_valued_fields = unique_valued_fields
300
-
301
593
  # Maximum number of fields to fill (except if mandatory is more)
302
594
  self.min_fields = min_fields
303
595
  self.max_fields = 999999999 if max_fields is None else max_fields
304
-
305
- # Fixed configuration
306
- self.fixed_config = fixed_config
307
-
308
- self.n_extra_fields = None
309
- self.template_record = None
310
- self.created_sysids = None
311
- if config_path:
312
- with open(config_path, "r") as f:
313
- self.all_configs = json.load(f)
314
- if expected_fields_path:
315
- with open(expected_fields_path, "r") as f:
316
- self.expected_fields = json.load(f)
317
-
318
- def get_init_scripts(self) -> List[str]:
319
- # Extract expected URL suffix
320
- url_suffix = parse.urlparse(self.start_url).path.split("/")[-1]
321
-
322
- # Add a few initialization scripts
323
- return super().get_init_scripts() + [
324
- f"""
325
- function patchSubmitButton() {{
326
- waLog('Attempting to override form submit function', 'patchSubmitButton');
327
- // Save the original function if it hasn't been saved yet
328
- if(typeof old_gsftSubmit == 'undefined'){{
329
- old_gsftSubmit = new Function('return ' + gsftSubmit.toString())();
330
- waLog('Saved original submit function', 'patchSubmitButton');
331
- }}
332
-
333
- // Override the function to save the sys_id in the local storage
334
- gsftSubmit = function(control, form, action_name) {{
335
- localStorage['{self.session_sys_id_field}'] = {self.js_api_forms}.getUniqueValue();
336
- old_gsftSubmit(control, form, action_name);
337
- }};
338
- waLog('Patched submit function. All done.', 'patchSubmitButton');
339
- }}
340
-
341
- runInGsftMainOnlyAndProtectByURL(patchSubmitButton, '{url_suffix}');
342
- """
343
- ]
596
+ self.page_on_form_view = (
597
+ False # Indicates if the page is on the form view; used in validation
598
+ )
344
599
 
345
600
  def setup_goal(self, page: Page) -> tuple[str, dict]:
346
601
  super().setup_goal(page=page)
@@ -348,16 +603,14 @@ class GenericNewRecordTask(ServiceNowFormTask):
348
603
  # Get the task configuration
349
604
  assert self.all_configs is not None, "No configuration available for the task."
350
605
  config = self.fixed_config if self.fixed_config else self.random.choice(self.all_configs)
351
- self.template_record = config["template_record"]
352
- for f, func in self.unique_valued_fields.items():
353
- self.template_record[f] = func(self.template_record[f])
354
- self.task_fields = config["task_fields"]
355
- self.created_sysids = []
356
-
606
+ # If fixed_config is not None we already set the required attributes in the constructor
607
+ if self.fixed_config is None:
608
+ self._set_required_config_attributes(config)
609
+ self.protected_fields = self.task_fields
357
610
  # Generate the goal
358
611
  goal = (
359
612
  f"Create a new {self.table_label} with "
360
- + " and ".join(
613
+ + prettyprint_enum(
361
614
  [
362
615
  f'a value of "{self.template_record[f]}"'
363
616
  + f' for field "{config["fields"][f]}"'
@@ -439,37 +692,8 @@ class GenericNewRecordTask(ServiceNowFormTask):
439
692
 
440
693
  # Replace some field values
441
694
  for f in self.fields:
442
- if "choices" in self.table_metadata[f]:
443
- if (
444
- # ... if the field has choices that are not available in the UI
445
- self.template_record[f] not in self.table_metadata[f]["choices"].values()
446
- or
447
- # ... avoid empty values if there are other choices
448
- (
449
- (self.template_record[f] is None or self.template_record[f] == "")
450
- and len(self.table_metadata[f]["choices"]) > 1
451
- )
452
- ):
453
- # XXX: We skip empty-string values because 1) they are not really interesting to
454
- # ask for since the agent doesn't have to do anything. They also cause issues
455
- # in the validation since they don't get saved properly to the database.
456
- choices = [v for k, v in self.table_metadata[f]["choices"].items() if k != ""]
457
- self.template_record[f] = self.random.choice(choices)
458
- elif self.table_metadata[f]["type"] in self.string_types:
459
- # ... if the field is a string, we want to make sure that it's not empty
460
- if self.template_record[f] == "":
461
- if self.table_metadata[f]["type"] == "string":
462
- self.template_record[f] = " ".join(
463
- self.random.choice(ENGLISH_WORDS, size=5)
464
- )
465
- elif self.table_metadata[f]["type"] == "email":
466
- self.template_record[f] = (
467
- f"{'.'.join(self.random.choice(ENGLISH_WORDS, size=2))}@workarena.com"
468
- )
469
- elif self.table_metadata[f]["type"] == "ph_number":
470
- self.template_record[f] = (
471
- f"(514) {self.random.randint(100, 999)}-{self.random.randint(1000, 9999)}"
472
- )
695
+ new_value = self.get_new_field_value(f, self.template_record, self.table_metadata)
696
+ self.template_record[f] = new_value
473
697
 
474
698
  # Make sure the value satisfies the max length for the field
475
699
  self.template_record = {
@@ -497,84 +721,55 @@ class GenericNewRecordTask(ServiceNowFormTask):
497
721
  info = {}
498
722
  return goal, info
499
723
 
724
+ def get_pretty_printed_description(self) -> str:
725
+ """
726
+ Get the task info for this task when used in a private task; Used in L3 compositional tasks.
727
+ called by subclasses
728
+ """
729
+ class_name = self.__class__.__name__
730
+ class_name = class_name.replace("Create", "").replace("Task", "")
731
+
732
+ # Split the words
733
+ words = re.findall(r"[A-Z][^A-Z]*", class_name)
734
+ class_name_formatted = " ".join(words)
735
+ table_metadata = table_column_info(instance=self.instance, table=self.table_name)
736
+ # pretty field names that are displayed to the user
737
+ task_fields = []
738
+ for field in self.task_fields:
739
+ # In feasible tasks, the fields are always present
740
+ if field in table_metadata:
741
+ field_name = table_metadata[field]["label"]
742
+ # In infeasible tasks, the fields are absent from table_metadata
743
+ else:
744
+ field_name = " ".join(field.split("_")).capitalize()
745
+
746
+ task_fields.append(field_name)
747
+
748
+ field_values = [self.template_record[field] for field in self.task_fields]
749
+ current_task_info = dict(zip(task_fields, field_values))
750
+ task_info = f"- Create a {class_name_formatted} with the following information: \n"
751
+ for field, value in current_task_info.items():
752
+ task_info += f" - {field}: {value} \n"
753
+
754
+ return task_info
755
+
500
756
  def cheat(self, page: Page, chat_messages: list[str]) -> None:
501
757
  super().cheat(page=page, chat_messages=chat_messages)
502
- self._wait_for_ready(page)
758
+ # If we are on the list view of the table, click on the "New" button
759
+ self._wait_for_ready(page, iframe_only=True)
503
760
  iframe = page.frame_locator(f'iframe[name="{self.js_prefix}"]')
504
-
505
- from tenacity import retry, stop_after_delay, retry_if_exception_type
506
-
507
- # Retry on TypeError since in very rare occasions, element evaluates to null, which raises a TypeError
508
- @retry(
509
- stop=stop_after_delay(SNOW_BROWSER_TIMEOUT // 1000),
510
- retry=retry_if_exception_type(TypeError),
511
- )
512
- def show_field_tab(field):
513
- """
514
- Finds the control that allows to show the section where a field is located
515
- and clicks on it.
516
-
517
- """
518
- section = page.evaluate(
519
- f"""() => {{
520
- const element = {self.form_js_selector}.getElement('{field}');
521
- const ancestors = element.ancestors();
522
- for (let ancestor of ancestors) {{
523
- // Ancestor IDs are of the form "section-<section name>"
524
- if (ancestor.id.startsWith('section-')) {{
525
- return ancestor.id;
526
- }}
527
- }}
528
- return null; // Return null if no matching ancestor is found
529
- }}"""
530
- )
531
- section_id = section.split("-")[-1]
532
- tab_sections = {
533
- s.split(".")[-1]: i
534
- for i, s in enumerate(page.evaluate(f"{self.js_prefix}.g_tabs2Sections.tabIDs"))
535
- }
536
-
537
- # If the section is not in the tabs do nothing (it's probably the main section)
538
- if section_id not in tab_sections:
539
- return
540
-
541
- page.evaluate_handle(
542
- f"""{self.js_prefix}.g_tabs2Sections.tabsTabs[
543
- {tab_sections[section_id]}
544
- ].element"""
545
- ).click(force=True)
546
-
547
- for field in self.task_fields:
548
- # Get the field's input control
549
- control = iframe.get_by_label(
550
- page.evaluate(f"{self.form_js_selector}.getLabelOf('{field}')"),
551
- exact=True,
552
- )
553
-
554
- # If the field is in a section, click on its header to make it visible
555
- show_field_tab(field)
556
-
557
- # Some fields are marked as string by the API but accept selection-based input
558
- # We use the select tag condition to match these fields. Others are marked as integers.
559
- if self.table_metadata[field]["type"] == "choice":
560
- control.select_option(str(self.template_record[field]))
561
-
562
- # Checkboxes
563
- elif self.table_metadata[field]["type"] == "boolean":
564
- control.set_checked(1 if self.template_record[field] == "true" else 0)
565
-
566
- # Any text-based input
567
- else:
568
- fill_text(
569
- page=page,
570
- iframe=iframe,
571
- input_field=control,
572
- value=self.template_record[field],
573
- )
574
-
575
- # Click on the submit button
576
- page.wait_for_timeout(1000)
577
- iframe.locator("#sysverb_insert").click()
761
+ url = parse.urlparse(parse.unquote(self.page.evaluate("() => window.location.href")))
762
+ if url.path.endswith("_list.do"):
763
+ # click on the sysverb_new button
764
+ with page.expect_navigation():
765
+ iframe.locator("#sysverb_new").click()
766
+ iframe = page.frame_locator(f'iframe[name="{self.js_prefix}"]')
767
+ # On the change request page, additional steps need to be taken to open the form
768
+ if self.table_label == "change request":
769
+ self._wait_for_ready(page, iframe_only=True)
770
+ iframe.get_by_label("All").click()
771
+ iframe.get_by_text("Normal").first.click()
772
+ self._fill_fields(page, iframe, self.task_fields)
578
773
 
579
774
  def validate(
580
775
  self, page: playwright.sync_api.Page, chat_messages: list[str]
@@ -587,8 +782,8 @@ class GenericNewRecordTask(ServiceNowFormTask):
587
782
  that are not part of the task.
588
783
 
589
784
  """
590
- # check that the page is at the right url
591
- right_url = check_url_suffix_match(page, expected_url=self.start_url, task=self)
785
+
786
+ right_url = self._page_on_right_url(page)
592
787
  if not right_url:
593
788
  return (
594
789
  0,
@@ -598,6 +793,24 @@ class GenericNewRecordTask(ServiceNowFormTask):
598
793
  "message": f"The page is not in the right URL to validate task {self.__class__.__name__}."
599
794
  },
600
795
  )
796
+ protected_field_changed = page.evaluate(
797
+ "() => window.gsft_main.WORKARENA_BAD_FIELD_CHANGED"
798
+ )
799
+ if protected_field_changed:
800
+ return (
801
+ 0,
802
+ True,
803
+ "",
804
+ {"message": "Some fields outside of the task scope have been changed."},
805
+ )
806
+ if self.table_metadata is None and self.page_is_form_view:
807
+ # XXX We need to ensure the table metadata as well as fields are set
808
+ # before we can proceed with the cheat function
809
+ self._wait_for_ready(page, iframe_only=True)
810
+ self._get_form(page)
811
+ if self.fields is None and self.page_is_form_view:
812
+ self._get_fields(page)
813
+
601
814
  # Retrieve the created record's sys_id from the session storage
602
815
  sys_id = page.evaluate("localStorage").get(self.session_sys_id_field, None)
603
816
 
@@ -615,11 +828,10 @@ class GenericNewRecordTask(ServiceNowFormTask):
615
828
  # This is used to clean up the database after the task is completed.
616
829
  self.created_sysids.append(sys_id)
617
830
 
618
- # Short sleep to make sure the data is saved in the DB
619
- # TODO: improve this (noted in issue 291)
620
- sleep(3)
621
-
622
831
  # Pull the record from the database
832
+ # XXX: It's possible that the record is not found, e.g., if form submission was rejected due to client-side
833
+ # validation errors. In this case, we should not raise an error and simply consider that no record was
834
+ # created. This is non-terminal for the task.
623
835
  record = table_api_call(
624
836
  instance=self.instance,
625
837
  table=self.table_name,
@@ -627,6 +839,9 @@ class GenericNewRecordTask(ServiceNowFormTask):
627
839
  "sysparm_query": f"sys_id={sys_id}",
628
840
  "sysparm_display_value": True,
629
841
  },
842
+ wait_for_record=True,
843
+ max_retries=20, # Wait up to 10 seconds
844
+ raise_on_wait_expired=False,
630
845
  )["result"]
631
846
 
632
847
  # This can happen if the form was submitted but was rejected due to invalid inputs (e.g., missing mandatory fields)
@@ -663,9 +878,31 @@ class GenericNewRecordTask(ServiceNowFormTask):
663
878
  {"message": error_msg},
664
879
  )
665
880
 
666
- return 1, True, "Nice work, thank you!", {"message": "The record was successfully created."}
881
+ return (
882
+ 1,
883
+ True,
884
+ "Nice work, thank you!",
885
+ {"message": "The record was successfully created."},
886
+ )
887
+
888
+ def _page_on_right_url(self, page: Page) -> bool:
889
+ """Checks if the page is on the right URL for validation + sets the page_on_form_view attribute"""
890
+ page.wait_for_load_state("domcontentloaded")
891
+ self._wait_for_ready(page, iframe_only=True)
892
+ # check that the page is at the right url
893
+ list_url = self.start_url.replace(".do", "_list.do") # list view of records
894
+ # Check whether we are in the form or list view
895
+ self.page_is_form_view = check_url_suffix_match(
896
+ page, expected_url=self.start_url, task=self
897
+ )
898
+ page_is_list_view = check_url_suffix_match(page, expected_url=list_url, task=self)
899
+
900
+ right_url = self.page_is_form_view or page_is_list_view
901
+
902
+ return right_url
667
903
 
668
904
  def teardown(self) -> None:
905
+ self._wait_for_ready(self.page, iframe_only=True)
669
906
 
670
907
  # Retrieve the current record's sys_id from the session storage
671
908
  sys_id = self.page.evaluate("localStorage").get(self.session_sys_id_field, None)
@@ -685,13 +922,313 @@ class GenericNewRecordTask(ServiceNowFormTask):
685
922
  pass
686
923
 
687
924
 
925
+ class EditRecordTask(ServiceNowFormTask, CompositionalBuildingBlockTask):
926
+ """
927
+ Generic task to edit an existing record in a table using a Glide form.
928
+ Class Attributes
929
+ ----------------
930
+ config_path: str
931
+ The path to the JSON file containing all configurations for the task. Defined by subclasses
932
+ expected_fields_path: str
933
+ The path to the JSON file containing all expected fields for the task. Defined by subclasses
934
+ Args
935
+ ----
936
+ form_url: str
937
+ The URL of the form to use to edit the record.
938
+ table_label: str
939
+ The pretty-printed name of the table.
940
+ instance: SNowInstance
941
+ The instance on which to edit the record.
942
+ extra_mandatory_fields: List
943
+ List of fields that should be marked as mandatory in the form (overrides the page specification).
944
+ prohibited_fields: List
945
+ List of fields that should not be edited.
946
+ unique_valued_fields: dict
947
+ Dictionary of fields that should have a unique value. Keys are the field names and values are functions
948
+ used to make the fields unique (e.g., appending self.unique).
949
+ fixed_config: dict
950
+ Configuration to use for the task. If provided, the task will use the provided configuration instead of
951
+ a randomly selected one
952
+ record_sys_id: str
953
+ The sys_id of the record to edit. If provided, the task will edit this record instead of creating a new one.
954
+ record_number: str
955
+ The number of the record to edit. If provided, the task's cheat will select records based on it rather than picking the first element of the list.
956
+ new_values: dict
957
+ Dictionary mapping fields to their new values. These are values that will be used to either replace the current
958
+ values in the record or add them to the record if they are not already present.
959
+ """
960
+
961
+ def __init__(
962
+ self,
963
+ form_url: str,
964
+ table_label: str,
965
+ instance: SNowInstance = None,
966
+ extra_mandatory_fields: List = [],
967
+ prohibited_fields: List = [],
968
+ unique_valued_fields: dict = {},
969
+ fixed_config: dict = None,
970
+ record_sys_id: str = None,
971
+ record_number: str = None,
972
+ new_values: dict = None,
973
+ seed: int = None,
974
+ ) -> None:
975
+ super().__init__(
976
+ seed=seed,
977
+ form_url=form_url,
978
+ table_label=table_label,
979
+ instance=instance,
980
+ extra_mandatory_fields=extra_mandatory_fields,
981
+ prohibited_fields=prohibited_fields,
982
+ unique_valued_fields=unique_valued_fields,
983
+ fixed_config=fixed_config,
984
+ )
985
+ # sys_id of the record that will be edited
986
+ self.record_sys_id = record_sys_id
987
+ self.record_number = record_number
988
+ self.delete_record_on_teardown = False
989
+ self.new_values = new_values # dict mapping fields to their new values
990
+ # If the record sys_id is provided, the task will fetch its template record and task fields
991
+ if self.record_sys_id is not None:
992
+ fixed_config = {}
993
+ template_record = table_api_call(
994
+ instance=self.instance,
995
+ table=self.table_name,
996
+ params={
997
+ "sysparm_query": f"sys_id={self.record_sys_id}",
998
+ },
999
+ )["result"][0]
1000
+ fixed_config["template_record"] = template_record
1001
+ fixed_config["task_fields"] = list(self.new_values.keys())
1002
+ table_info = table_column_info(instance=self.instance, table=self.table_name)
1003
+ fixed_config["fields"] = {f: table_info[f]["label"] for f in self.new_values.keys()}
1004
+
1005
+ self.fixed_config = fixed_config
1006
+
1007
+ def setup_goal(self, page: Page) -> tuple[str, dict]:
1008
+ super().setup_goal(page=page)
1009
+
1010
+ # Get the task configuration
1011
+ config = self.fixed_config if self.fixed_config else self.random.choice(self.all_configs)
1012
+
1013
+ # If fixed_config is not None we already set the required attributes in the constructor
1014
+ # If record_sys_id is not None, the required attributes are not set in the constructor either
1015
+ if self.fixed_config is None or self.record_sys_id is not None:
1016
+ self._set_required_config_attributes(config)
1017
+
1018
+ # Make the new values unique if needed
1019
+ for f, func in self.unique_valued_fields.items():
1020
+ if f in self.new_values:
1021
+ self.new_values[f] = func(self.new_values[f])
1022
+
1023
+ self.protected_fields = list(self.new_values.keys())
1024
+ if self.record_sys_id is None:
1025
+ self._create_record()
1026
+ self.delete_record_on_teardown = True
1027
+ # Replace the values in the template record
1028
+ for f, v in self.new_values.items():
1029
+ self.template_record[f] = v
1030
+ self.start_url = f"{self.start_url}%3Fsys_id%3D{self.record_sys_id}"
1031
+
1032
+ # Generate the goal
1033
+ goal = self.get_pretty_printed_description()
1034
+
1035
+ info = {}
1036
+
1037
+ return goal, info
1038
+
1039
+ def _create_record(self) -> None:
1040
+ """Create a record to edit."""
1041
+ # Data to create the record
1042
+ data = {}
1043
+ for field in self.template_record:
1044
+ value = self.template_record[field]
1045
+ if type(value) == dict:
1046
+ value = value["display_value"]
1047
+ # Skip sys fields as they are not editable
1048
+ if not value or "sys" in field:
1049
+ continue
1050
+ data[field] = value
1051
+
1052
+ result = table_api_call(
1053
+ instance=self.instance,
1054
+ table=self.table_name,
1055
+ data=json.dumps(data),
1056
+ method="POST",
1057
+ )
1058
+ self.record_sys_id = result["result"]["sys_id"]
1059
+
1060
+ def get_pretty_printed_description(self) -> str:
1061
+ """
1062
+ Get the task info for this task when used in a private task; Used in L3 compositional tasks.
1063
+ called by subclasses
1064
+ """
1065
+ class_name = self.__class__.__name__
1066
+ class_name = class_name.replace("Edit", "").replace("Task", "")
1067
+ # Split the words
1068
+ words = re.findall(r"[A-Z][^A-Z]*", class_name)
1069
+ table_metadata = table_column_info(instance=self.instance, table=self.table_name)
1070
+ task_fields = [
1071
+ table_metadata[field]["label"] for field in self.new_values
1072
+ ] # pretty field names that are displayed to the user
1073
+ field_values = [self.template_record[field] for field in self.new_values]
1074
+ current_task_info = dict(zip(task_fields, field_values))
1075
+ # In L3, this is part of an enumeration
1076
+ task_info = "- " if self.level == 3 else ""
1077
+
1078
+ task_info += (
1079
+ f"Edit the {self.table_label} record by replacing the value of "
1080
+ + prettyprint_enum(
1081
+ [
1082
+ f' field "{field}"' + f' with value "{value}"'
1083
+ for field, value in current_task_info.items()
1084
+ ]
1085
+ )
1086
+ + "."
1087
+ )
1088
+
1089
+ return task_info
1090
+
1091
+ def cheat(self, page: Page, chat_messages: list[str]) -> None:
1092
+ super().cheat(page=page, chat_messages=chat_messages)
1093
+ self._wait_for_ready(page, iframe_only=True)
1094
+ iframe = page.frame_locator(f'iframe[name="{self.js_prefix}"]')
1095
+ url = parse.urlparse(parse.unquote(self.page.evaluate("() => window.location.href")))
1096
+
1097
+ # Open the record preview, then the record
1098
+ if url.path.endswith("_list.do"):
1099
+ # If the record number is provided, click on the record with that number
1100
+ if self.record_number:
1101
+ iframe.locator(f"[aria-label='Preview record: {self.record_number}']").click()
1102
+ # ....otherwise, click on the first record
1103
+ else:
1104
+ iframe.locator("td").get_by_role("button").first.click()
1105
+ page.wait_for_timeout(500)
1106
+
1107
+ iframe.get_by_text("Open Record").click()
1108
+ page.wait_for_function(
1109
+ "typeof window.gsft_main !== 'undefined' && window.gsft_main.WORKARENA_LOAD_COMPLETE"
1110
+ )
1111
+ page.wait_for_timeout(1000)
1112
+ self._fill_fields(page, iframe, self.new_values.keys(), update=True)
1113
+
1114
+ def validate(
1115
+ self, page: playwright.sync_api.Page, chat_messages: list[str]
1116
+ ) -> Tuple[float, bool, str, dict]:
1117
+ """
1118
+ Caveat: we check only if the expected fields have the right value. We don't Check
1119
+ if there are extra fields that shouldn't be there. We could have issues
1120
+ matching other fields since calculation rules may have changed through time.
1121
+ Maybe we should assign a random value from our list of choices to the fields
1122
+ that are not part of the task.
1123
+
1124
+ """
1125
+ page.wait_for_load_state("domcontentloaded")
1126
+ # check that the page is at the right url
1127
+ list_url = self.start_url.replace(".do", "_list.do") # list view of records
1128
+ # Check whether we are in the form or list view
1129
+ page_is_form_view = check_url_suffix_match(page, expected_url=self.start_url, task=self)
1130
+ page_is_list_view = check_url_suffix_match(page, expected_url=list_url, task=self)
1131
+ right_url = page_is_form_view or page_is_list_view
1132
+ if not right_url:
1133
+ return (
1134
+ 0,
1135
+ False,
1136
+ "",
1137
+ {
1138
+ "message": f"The page is not in the right URL to validate task {self.__class__.__name__}."
1139
+ },
1140
+ )
1141
+ self._wait_for_ready(page, iframe_only=True)
1142
+ protected_field_changed = page.evaluate(
1143
+ "() => window.gsft_main.WORKARENA_BAD_FIELD_CHANGED"
1144
+ )
1145
+ if protected_field_changed:
1146
+ return (
1147
+ 0,
1148
+ True,
1149
+ "",
1150
+ {"message": "Some fields outside of the task scope have been changed."},
1151
+ )
1152
+ if self.table_metadata is None:
1153
+ # XXX We need to ensure the table metadata as well as fields are set
1154
+ # before we can proceed with the cheat function
1155
+ self._wait_for_ready(page, iframe_only=True)
1156
+ self._get_form(page)
1157
+ if self.fields is None and page_is_form_view:
1158
+ self._get_fields(page)
1159
+
1160
+ # Pull the record from the database
1161
+ record = table_api_call(
1162
+ instance=self.instance,
1163
+ table=self.table_name,
1164
+ params={
1165
+ "sysparm_query": f"sys_id={self.record_sys_id}",
1166
+ "sysparm_display_value": True,
1167
+ },
1168
+ wait_for_record=True,
1169
+ )["result"]
1170
+
1171
+ # This can happen if the form was submitted but was rejected due to invalid inputs (e.g., missing mandatory fields)
1172
+ if len(record) == 0:
1173
+ logging.info(
1174
+ "The record was not found in the database. Perhaps it was deleted."
1175
+ + self.record_sys_id,
1176
+ )
1177
+ return (
1178
+ 0,
1179
+ True,
1180
+ "",
1181
+ {"message": "The record was not found in the database. Perhaps it was deleted."},
1182
+ )
1183
+
1184
+ # Extract display values for reference fields
1185
+ record = {
1186
+ f: v if not isinstance(v, dict) else v["display_value"] for f, v in record[0].items()
1187
+ }
1188
+
1189
+ # Check that the record matches the expected values
1190
+ for f in self.new_values.keys():
1191
+ if "sys_" in f:
1192
+ continue
1193
+ if record[f] != self.template_record[f]:
1194
+ logging.info(
1195
+ f'The field "{self.table_metadata[f]["label"]}" has the wrong value. Expected: "{self.template_record[f]}", got: "{record[f]}".'
1196
+ )
1197
+ error_msg = f'The field "{self.table_metadata[f]["label"]}" has the wrong value.'
1198
+ return (
1199
+ 0,
1200
+ False,
1201
+ error_msg,
1202
+ {"message": error_msg},
1203
+ )
1204
+
1205
+ return (
1206
+ 1,
1207
+ True,
1208
+ "Nice work, thank you!",
1209
+ {"message": "The record was successfully edited."},
1210
+ )
1211
+
1212
+ def teardown(self) -> None:
1213
+ # Delete the record created for the task
1214
+ if self.delete_record_on_teardown:
1215
+ db_delete_from_table(
1216
+ instance=self.instance, sys_id=self.record_sys_id, table=self.table_name
1217
+ )
1218
+
1219
+
688
1220
  class CreateChangeRequestTask(GenericNewRecordTask):
689
1221
  """
690
1222
  Task to create a new change request in the system.
691
1223
 
692
1224
  """
693
1225
 
694
- def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
1226
+ config_path = CREATE_CHANGE_REQUEST_CONFIG_PATH
1227
+ expected_fields_path = EXPECTED_CHANGE_REQUEST_FORM_FIELDS_PATH
1228
+
1229
+ def __init__(
1230
+ self, seed: int = None, instance=None, fixed_config: dict = None, **kwargs
1231
+ ) -> None:
695
1232
  super().__init__(
696
1233
  seed=seed,
697
1234
  instance=instance,
@@ -699,18 +1236,43 @@ class CreateChangeRequestTask(GenericNewRecordTask):
699
1236
  table_label="change request",
700
1237
  prohibited_fields=["chg_model", "state"],
701
1238
  fixed_config=fixed_config,
702
- config_path=CREATE_CHANGE_REQUEST_CONFIG_PATH,
703
- expected_fields_path=EXPECTED_CHANGE_REQUEST_FORM_FIELDS_PATH,
1239
+ )
1240
+ self.__dict__.update(kwargs)
1241
+
1242
+ def _page_on_right_url(self, page: playwright.sync_api.Page) -> bool:
1243
+ """
1244
+ The change request form lands in a view different from the list view. We need to check for this as well.
1245
+ """
1246
+ right_url = super()._page_on_right_url(page)
1247
+ # Change request creation leads to a different page when in comp task; we need to check this case as well
1248
+ change_request_landing_page = "/now/nav/ui/classic/params/target/sn_chg_model_ui_landing.do"
1249
+ page_is_change_landing = (
1250
+ check_url_suffix_match(page, expected_url=change_request_landing_page, task=self)
1251
+ if self.table_label == "change request"
1252
+ else False
704
1253
  )
705
1254
 
1255
+ right_url = right_url or page_is_change_landing
1256
+
1257
+ return right_url
1258
+
706
1259
 
707
1260
  class CreateIncidentTask(GenericNewRecordTask):
708
1261
  """
709
1262
  Task to create a new incident in the system.
710
-
711
1263
  """
712
1264
 
713
- def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
1265
+ config_path = CREATE_INCIDENT_CONFIG_PATH
1266
+ expected_fields_path = EXPECTED_INCIDENT_FORM_FIELDS_PATH
1267
+
1268
+ def __init__(
1269
+ self,
1270
+ seed: int = None,
1271
+ instance=None,
1272
+ fixed_config: dict = None,
1273
+ check_record_created=True,
1274
+ **kwargs,
1275
+ ) -> None:
714
1276
  super().__init__(
715
1277
  seed=seed,
716
1278
  instance=instance,
@@ -718,9 +1280,9 @@ class CreateIncidentTask(GenericNewRecordTask):
718
1280
  table_label="incident",
719
1281
  prohibited_fields=["state"],
720
1282
  fixed_config=fixed_config,
721
- config_path=CREATE_INCIDENT_CONFIG_PATH,
722
- expected_fields_path=EXPECTED_INCIDENT_FORM_FIELDS_PATH,
1283
+ check_record_created=check_record_created,
723
1284
  )
1285
+ self.__dict__.update(kwargs)
724
1286
 
725
1287
 
726
1288
  class CreateHardwareAssetTask(GenericNewRecordTask):
@@ -729,7 +1291,12 @@ class CreateHardwareAssetTask(GenericNewRecordTask):
729
1291
 
730
1292
  """
731
1293
 
732
- def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
1294
+ config_path = CREATE_HARDWARE_CONFIG_PATH
1295
+ expected_fields_path = EXPECTED_HARDWARE_FORM_FIELDS_PATH
1296
+
1297
+ def __init__(
1298
+ self, seed: int = None, instance=None, fixed_config: dict = None, **kwargs
1299
+ ) -> None:
733
1300
  super().__init__(
734
1301
  seed=seed,
735
1302
  instance=instance,
@@ -744,9 +1311,8 @@ class CreateHardwareAssetTask(GenericNewRecordTask):
744
1311
  ],
745
1312
  unique_valued_fields={"serial_number": lambda x: f"SN-{self.unique_id}"},
746
1313
  fixed_config=fixed_config,
747
- config_path=CREATE_HARDWARE_CONFIG_PATH,
748
- expected_fields_path=EXPECTED_HARDWARE_FORM_FIELDS_PATH,
749
1314
  )
1315
+ self.__dict__.update(kwargs)
750
1316
 
751
1317
 
752
1318
  class CreateProblemTask(GenericNewRecordTask):
@@ -755,7 +1321,17 @@ class CreateProblemTask(GenericNewRecordTask):
755
1321
 
756
1322
  """
757
1323
 
758
- def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
1324
+ config_path = CREATE_PROBLEM_CONFIG_PATH
1325
+ expected_fields_path = EXPECTED_PROBLEM_FORM_FIELDS_PATH
1326
+
1327
+ def __init__(
1328
+ self,
1329
+ seed: int = None,
1330
+ instance=None,
1331
+ fixed_config: dict = None,
1332
+ check_record_created=True,
1333
+ **kwargs,
1334
+ ) -> None:
759
1335
  super().__init__(
760
1336
  seed=seed,
761
1337
  instance=instance,
@@ -763,13 +1339,13 @@ class CreateProblemTask(GenericNewRecordTask):
763
1339
  table_label="problem",
764
1340
  prohibited_fields=["state", "first_reported_by_task"],
765
1341
  fixed_config=fixed_config,
766
- config_path=CREATE_PROBLEM_CONFIG_PATH,
767
- expected_fields_path=EXPECTED_PROBLEM_FORM_FIELDS_PATH,
1342
+ check_record_created=check_record_created,
768
1343
  # TODO: The last field is disabled because somehow the value is not in the autocomplete
769
1344
  # list even though it's in the database. I'm not sure why. It doesn't matter much
770
1345
  # since in the future we'll pre-generate tasks and keep only the ones where the
771
1346
  # cheat function works.
772
1347
  )
1348
+ self.__dict__.update(kwargs)
773
1349
 
774
1350
 
775
1351
  class CreateUserTask(GenericNewRecordTask):
@@ -778,24 +1354,240 @@ class CreateUserTask(GenericNewRecordTask):
778
1354
 
779
1355
  """
780
1356
 
781
- def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
1357
+ config_path = CREATE_USER_CONFIG_PATH
1358
+ expected_fields_path = EXPECTED_USER_FORM_FIELDS_PATH
1359
+
1360
+ def __init__(
1361
+ self, seed: int = None, instance=None, fixed_config: dict = None, **kwargs
1362
+ ) -> None:
782
1363
  super().__init__(
783
1364
  seed=seed,
784
1365
  instance=instance,
785
1366
  form_url="/now/nav/ui/classic/params/target/sys_user.do",
786
1367
  table_label="user",
787
1368
  extra_mandatory_fields=["user_name", "first_name", "last_name", "email"],
788
- unique_valued_fields={"user_name": lambda x: str(hash(x + self.unique_id))},
1369
+ # XXX We use an OrderedDict to ensure that the fields are filled in the right order as the email requires the first and last name
1370
+ unique_valued_fields=OrderedDict(
1371
+ [
1372
+ ("first_name", lambda x: fake.first_name() + "-" + fake.first_name()),
1373
+ ("last_name", lambda x: fake.last_name() + "-" + fake.last_name()),
1374
+ ("user_name", lambda x: str(abs(hash(x + self.unique_id)))),
1375
+ (
1376
+ "email",
1377
+ lambda x: self.template_record["first_name"].lower()
1378
+ + "."
1379
+ + self.template_record["last_name"].lower()
1380
+ + "@workarena.com",
1381
+ ),
1382
+ ]
1383
+ ),
789
1384
  fixed_config=fixed_config,
790
- config_path=CREATE_USER_CONFIG_PATH,
791
- expected_fields_path=EXPECTED_USER_FORM_FIELDS_PATH,
792
1385
  )
1386
+ self.__dict__.update(kwargs)
1387
+
1388
+
1389
+ class EditHardwareAssetTask(EditRecordTask):
1390
+ """
1391
+ Task to create a new user in the system.
1392
+
1393
+ """
1394
+
1395
+ config_path = CREATE_HARDWARE_CONFIG_PATH
1396
+ expected_fields_path = EXPECTED_HARDWARE_FORM_FIELDS_PATH
1397
+
1398
+ def __init__(
1399
+ self,
1400
+ seed: int = None,
1401
+ instance=None,
1402
+ fixed_config: dict = None,
1403
+ record_sys_id: str = None,
1404
+ new_values: dict = None,
1405
+ **kwargs,
1406
+ ) -> None:
1407
+ super().__init__(
1408
+ seed=seed,
1409
+ instance=instance,
1410
+ form_url="/now/nav/ui/classic/params/target/alm_hardware.do",
1411
+ table_label="hardware asset",
1412
+ prohibited_fields=["install_status"],
1413
+ unique_valued_fields={"serial_number": lambda x: f"SN-{self.unique_id}"},
1414
+ new_values=new_values,
1415
+ fixed_config=fixed_config,
1416
+ record_sys_id=record_sys_id,
1417
+ )
1418
+ if self.new_values is None:
1419
+ self.new_values = {"department": "Finance"}
1420
+ self.__dict__.update(kwargs)
1421
+
1422
+
1423
+ class EditProblemTask(EditRecordTask):
1424
+ """
1425
+ Task to edit a problem in the system.
1426
+
1427
+ """
1428
+
1429
+ expected_fields_path = EXPECTED_PROBLEM_FORM_FIELDS_PATH
1430
+
1431
+ def __init__(
1432
+ self,
1433
+ seed: int = None,
1434
+ instance=None,
1435
+ fixed_config: dict = None,
1436
+ new_values: dict = None,
1437
+ record_sys_id: str = None,
1438
+ record_number: str = None,
1439
+ **kwargs,
1440
+ ) -> None:
1441
+ super().__init__(
1442
+ seed=seed,
1443
+ instance=instance,
1444
+ form_url="/now/nav/ui/classic/params/target/problem.do",
1445
+ table_label="problem",
1446
+ prohibited_fields=["state", "first_reported_by_task"],
1447
+ new_values=new_values,
1448
+ fixed_config=fixed_config,
1449
+ record_sys_id=record_sys_id,
1450
+ record_number=record_number,
1451
+ )
1452
+ if self.new_values is None:
1453
+ self.new_values = {"assigned_to": ""}
1454
+ self.__dict__.update(kwargs)
1455
+
1456
+ def get_pretty_printed_description(self) -> str:
1457
+ """
1458
+ Get the task info for this task when used in a private task; Used in L3 compositional tasks.
1459
+ called by subclasses
1460
+ """
1461
+ if self.level == 2:
1462
+ description = "Re-assign a lowest priority problem from the user with the most assigned problems to the user with the least assigned problems."
1463
+ return description
1464
+ else:
1465
+ return ""
1466
+
1467
+
1468
+ class EditChangeRequestScheduleTask(EditRecordTask):
1469
+ """Task to edit an existing change request's empty schedule (start and end dates)."""
1470
+
1471
+ expected_fields_path = EXPECTED_CHANGE_REQUEST_FORM_FIELDS_PATH
1472
+
1473
+ def __init__(
1474
+ self,
1475
+ seed: int = None,
1476
+ instance=None,
1477
+ fixed_config: dict = None,
1478
+ new_values: dict = None,
1479
+ record_sys_id: str = None,
1480
+ skip_description: bool = False,
1481
+ goal_type: str = "base",
1482
+ level: int = 2,
1483
+ **kwargs,
1484
+ ) -> None:
1485
+ """
1486
+ args:
1487
+ -----
1488
+ skip_description: bool
1489
+ Whether to skip the description field in the change request. Used in comp tasks when this class is used multiple times.
1490
+ goal_type: str
1491
+ Choice of "base", "priority", "tight", "tight priority". The type of goal to generate. Used in compositional tasks.
1492
+ level: int
1493
+ The level of the compositional task. Used in compositional tasks.
1494
+ """
1495
+ super().__init__(
1496
+ seed=seed,
1497
+ instance=instance,
1498
+ form_url="/now/nav/ui/classic/params/target/change_request.do",
1499
+ table_label="change request",
1500
+ prohibited_fields=["chg_model", "state"],
1501
+ new_values=new_values,
1502
+ fixed_config=fixed_config,
1503
+ record_sys_id=record_sys_id,
1504
+ )
1505
+ self.skip_description = skip_description
1506
+ self.goal_type = goal_type
1507
+ self.level = level
1508
+ self.__dict__.update(kwargs)
1509
+
1510
+ def get_pretty_printed_description(self) -> str:
1511
+ """
1512
+ Get the task info for this task when used in a private task; Used in compositional tasks.
1513
+ """
1514
+ if self.skip_description or self.level == 3:
1515
+ return ""
1516
+ elif self.goal_type == "base":
1517
+ task_info = "Edit the schedule of the change requests by setting the start and end dates so that the change requests do not overlap. There should not be more than one day between conescutive change requests in the schedule."
1518
+ elif self.goal_type == "priority":
1519
+ task_info = "Edit the schedule of the change requests by setting the start and end dates so that the change requests do not overlap. There should not be more than one day between conescutive change requests in the schedule and the higher impact change requests should be tackled first."
1520
+ elif self.goal_type == "tight":
1521
+ task_info = "Edit the schedule of the change requests by setting the start and end dates so that the change requests do not overlap. There should not be more than one hour between conescutive change requests in the schedule."
1522
+ elif self.goal_type == "tight priority":
1523
+ task_info = "Edit the schedule of the change requests by setting the start and end dates so that the change requests do not overlap. There should not be more than one hour between conescutive change requests in the schedule and the higher impact change requests should be tackled first."
1524
+
1525
+ task_info += " Finally, all change requests must respect the desired durations, which are determined by the risk level:\n"
1526
+ task_info += " - High risk: 3 days \n"
1527
+ task_info += " - Moderate risk: 2 days \n"
1528
+ task_info += " - Low risk: 1 day \n"
1529
+
1530
+ return task_info
1531
+
1532
+
1533
+ class EditIncidentTask(EditRecordTask):
1534
+ """
1535
+ Task to edit a new incident in the system.
1536
+
1537
+ """
1538
+
1539
+ expected_fields_path = EXPECTED_INCIDENT_FORM_FIELDS_PATH
1540
+
1541
+ def __init__(
1542
+ self,
1543
+ instance=None,
1544
+ fixed_config: dict = None,
1545
+ new_values: dict = None,
1546
+ record_sys_id: str = None,
1547
+ **kwargs,
1548
+ ) -> None:
1549
+ super().__init__(
1550
+ instance=instance,
1551
+ form_url="/now/nav/ui/classic/params/target/incident.do",
1552
+ table_label="incident",
1553
+ prohibited_fields=["state"],
1554
+ fixed_config=fixed_config,
1555
+ new_values=new_values,
1556
+ record_sys_id=record_sys_id,
1557
+ )
1558
+ if self.new_values is None:
1559
+ self.new_values = {"assigned_to": "fred.luddy"}
1560
+ self.__dict__.update(kwargs)
1561
+
1562
+
1563
+ class CreateItemRequestTask(GenericNewRecordTask, CompositionalBuildingBlockTask):
1564
+ """
1565
+ Task to create a new item request in the system.
1566
+ """
1567
+
1568
+ expected_fields_path = EXPECTED_REQUEST_ITEM_FORM_FIELDS_PATH
1569
+
1570
+ def __init__(
1571
+ self, instance=None, fixed_config: dict = None, check_record_created=True, **kwargs
1572
+ ) -> None:
1573
+ super().__init__(
1574
+ instance=instance,
1575
+ form_url="/now/nav/ui/classic/params/target/sc_req_item.do",
1576
+ table_label="sc_req_item",
1577
+ fixed_config=fixed_config,
1578
+ check_record_created=check_record_created,
1579
+ )
1580
+ self.__dict__.update(kwargs)
1581
+
793
1582
 
1583
+ local_vars = locals().copy()
794
1584
 
795
1585
  __TASKS__ = [
796
1586
  var
797
- for var in locals().values()
798
- if isinstance(var, type)
799
- and issubclass(var, GenericNewRecordTask)
1587
+ for var in local_vars.values()
1588
+ if inspect.isclass(var)
1589
+ and not issubclass(var, CompositionalBuildingBlockTask)
1590
+ and issubclass(var, ServiceNowFormTask)
800
1591
  and var is not GenericNewRecordTask
1592
+ and var is not ServiceNowFormTask
801
1593
  ]