browsergym-workarena 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browsergym/workarena/__init__.py +13 -1
- browsergym/workarena/api/category.py +74 -0
- browsergym/workarena/api/change_request.py +87 -0
- browsergym/workarena/api/computer_asset.py +90 -0
- browsergym/workarena/api/cost_center.py +19 -0
- browsergym/workarena/api/expense_line.py +89 -0
- browsergym/workarena/api/incident.py +45 -0
- browsergym/workarena/api/knowledge.py +29 -0
- browsergym/workarena/api/problem.py +90 -0
- browsergym/workarena/api/report.py +183 -0
- browsergym/workarena/api/requested_items.py +63 -0
- browsergym/workarena/api/user.py +11 -8
- browsergym/workarena/api/utils.py +47 -3
- browsergym/workarena/config.py +21 -1
- browsergym/workarena/data_files/setup_files/forms/expected_incident_form_fields.json +1 -1
- browsergym/workarena/data_files/setup_files/forms/expected_request_item_form_fields.json +1 -0
- browsergym/workarena/data_files/setup_files/knowledge/protocols.json +46 -0
- browsergym/workarena/data_files/setup_files/knowledge/test.html +1 -0
- browsergym/workarena/data_files/setup_files/lists/expected_asset_list_columns.json +2 -24
- browsergym/workarena/data_files/setup_files/lists/expected_change_request_list_columns.json +4 -40
- browsergym/workarena/data_files/setup_files/lists/expected_expense_line_list_columns.json +12 -0
- browsergym/workarena/data_files/setup_files/lists/expected_hardware_list_columns.json +1 -42
- browsergym/workarena/data_files/setup_files/lists/expected_incident_list_columns.json +2 -18
- browsergym/workarena/data_files/setup_files/lists/expected_problem_list_columns.json +12 -0
- browsergym/workarena/data_files/setup_files/lists/expected_requested_items_list_columns.json +12 -0
- browsergym/workarena/data_files/setup_files/lists/expected_service_catalog_list_columns.json +2 -19
- browsergym/workarena/data_files/setup_files/lists/expected_user_list_columns.json +3 -50
- browsergym/workarena/data_files/task_configs/all_menu.json +1 -1
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -1
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -1
- browsergym/workarena/data_files/task_configs/filter_service_catalog_item_list_task.json +1 -1
- browsergym/workarena/data_files/task_configs/impersonation_users.json +1 -1
- browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -1
- browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -1
- browsergym/workarena/human_eval/console.js +176 -0
- browsergym/workarena/human_eval/tool.py +366 -0
- browsergym/workarena/install.py +81 -20
- browsergym/workarena/tasks/base.py +55 -20
- browsergym/workarena/tasks/comp_building_block.py +4 -0
- browsergym/workarena/tasks/compositional/__init__.py +76 -0
- browsergym/workarena/tasks/compositional/base.py +364 -0
- browsergym/workarena/tasks/compositional/dash_do_base.py +1366 -0
- browsergym/workarena/tasks/compositional/dash_do_catalog.py +1127 -0
- browsergym/workarena/tasks/compositional/dash_do_catalog_infeasible.py +2047 -0
- browsergym/workarena/tasks/compositional/dash_do_create_incident.py +403 -0
- browsergym/workarena/tasks/compositional/dash_do_create_incident_infeasible.py +278 -0
- browsergym/workarena/tasks/compositional/dash_do_create_problem.py +336 -0
- browsergym/workarena/tasks/compositional/dash_do_create_problem_infeasible.py +235 -0
- browsergym/workarena/tasks/compositional/dash_do_filter.py +1600 -0
- browsergym/workarena/tasks/compositional/dash_do_request_item.py +1315 -0
- browsergym/workarena/tasks/compositional/dash_do_request_item_infeasible.py +693 -0
- browsergym/workarena/tasks/compositional/delete_record.py +341 -0
- browsergym/workarena/tasks/compositional/edit_knowledge_base.py +457 -0
- browsergym/workarena/tasks/compositional/expense_management.py +598 -0
- browsergym/workarena/tasks/compositional/filter_and_do.py +139 -0
- browsergym/workarena/tasks/compositional/find_and_order_item.py +345 -0
- browsergym/workarena/tasks/compositional/manage_change_request_schedule.py +1417 -0
- browsergym/workarena/tasks/compositional/mark_duplicate_problems.py +499 -0
- browsergym/workarena/tasks/compositional/maximize_investment_return.py +1763 -0
- browsergym/workarena/tasks/compositional/navigate_and_do.py +1151 -0
- browsergym/workarena/tasks/compositional/navigate_and_do_infeasible.py +2100 -0
- browsergym/workarena/tasks/compositional/offboard_user.py +207 -0
- browsergym/workarena/tasks/compositional/onboard_user.py +226 -0
- browsergym/workarena/tasks/compositional/update_task.py +145 -0
- browsergym/workarena/tasks/compositional/utils/curriculum.py +215 -0
- browsergym/workarena/tasks/compositional/utils/infeasible_configs.py +151 -0
- browsergym/workarena/tasks/compositional/utils/knapsack.py +192 -0
- browsergym/workarena/tasks/compositional/warranty_check.py +227 -0
- browsergym/workarena/tasks/compositional/work_assignment.py +804 -0
- browsergym/workarena/tasks/compositional/workload_balancing.py +396 -0
- browsergym/workarena/tasks/dashboard.py +188 -8
- browsergym/workarena/tasks/form.py +1024 -232
- browsergym/workarena/tasks/knowledge.py +216 -25
- browsergym/workarena/tasks/list.py +519 -102
- browsergym/workarena/tasks/mark_duplicate_problem.py +171 -0
- browsergym/workarena/tasks/navigation.py +55 -13
- browsergym/workarena/tasks/scripts/extract_all_menu_items.py +9 -2
- browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +6 -5
- browsergym/workarena/tasks/scripts/service_catalog.py +2 -1
- browsergym/workarena/tasks/scripts/validate.py +8 -2
- browsergym/workarena/tasks/send_chat_message.py +90 -0
- browsergym/workarena/tasks/service_catalog.py +94 -26
- browsergym/workarena/tasks/utils/form.py +1 -4
- browsergym/workarena/tasks/utils/private_tasks.py +63 -0
- browsergym/workarena/tasks/utils/utils.py +13 -0
- {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.0.dist-info}/METADATA +19 -18
- browsergym_workarena-0.3.0.dist-info/RECORD +138 -0
- {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.0.dist-info}/entry_points.txt +1 -0
- browsergym_workarena-0.2.1.dist-info/RECORD +0 -85
- {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.0.dist-info}/WHEEL +0 -0
- {browsergym_workarena-0.2.1.dist-info → browsergym_workarena-0.3.0.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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
self.
|
|
354
|
-
self.
|
|
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
|
-
+
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
591
|
-
right_url =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
798
|
-
if
|
|
799
|
-
and issubclass(var,
|
|
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
|
]
|