browsergym-workarena 0.1.0rc6__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. browsergym/workarena/__init__.py +7 -2
  2. browsergym/workarena/api/ui_themes.py +35 -0
  3. browsergym/workarena/api/user.py +153 -0
  4. browsergym/workarena/api/utils.py +1 -1
  5. browsergym/workarena/config.py +43 -1
  6. browsergym/workarena/data_files/setup_files/lists/expected_asset_list_columns.json +34 -1
  7. browsergym/workarena/data_files/setup_files/lists/expected_change_request_list_columns.json +48 -1
  8. browsergym/workarena/data_files/setup_files/lists/expected_hardware_list_columns.json +53 -1
  9. browsergym/workarena/data_files/setup_files/lists/expected_incident_list_columns.json +28 -1
  10. browsergym/workarena/data_files/setup_files/lists/expected_service_catalog_list_columns.json +29 -1
  11. browsergym/workarena/data_files/setup_files/ui_themes/workarena_themes.xml +2313 -0
  12. browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -0
  13. browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -0
  14. browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -0
  15. browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -0
  16. browsergym/workarena/data_files/task_configs/sort_asset_list_task.json +547 -11391
  17. browsergym/workarena/data_files/task_configs/sort_change_request_list_task.json +558 -11090
  18. browsergym/workarena/data_files/task_configs/sort_hardware_list_task.json +576 -11162
  19. browsergym/workarena/data_files/task_configs/sort_incident_list_task.json +528 -11172
  20. browsergym/workarena/data_files/task_configs/sort_service_catalog_item_list_task.json +533 -11491
  21. browsergym/workarena/data_files/task_configs/sort_user_list_task.json +568 -10582
  22. browsergym/workarena/install.py +625 -153
  23. browsergym/workarena/tasks/base.py +85 -26
  24. browsergym/workarena/tasks/dashboard.py +620 -0
  25. browsergym/workarena/tasks/form.py +127 -90
  26. browsergym/workarena/tasks/knowledge.py +30 -14
  27. browsergym/workarena/tasks/list.py +157 -65
  28. browsergym/workarena/tasks/navigation.py +18 -16
  29. browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +272 -0
  30. browsergym/workarena/tasks/scripts/generate_forms.py +2 -2
  31. browsergym/workarena/tasks/scripts/list.py +33 -9
  32. browsergym/workarena/tasks/scripts/validate.py +2 -2
  33. browsergym/workarena/tasks/service_catalog.py +106 -74
  34. browsergym/workarena/tasks/utils/form.py +5 -3
  35. browsergym/workarena/tasks/utils/js_utils.js +123 -2
  36. browsergym/workarena/tasks/utils/string.py +15 -0
  37. browsergym/workarena/tasks/utils/utils.py +20 -0
  38. browsergym/workarena/utils.py +31 -2
  39. {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/METADATA +7 -3
  40. {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/RECORD +43 -32
  41. {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/WHEEL +1 -1
  42. {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/entry_points.txt +0 -0
  43. {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,11 +2,17 @@ import html
2
2
  import json
3
3
  import logging
4
4
  import re
5
+ import tenacity
5
6
 
7
+ from datetime import datetime
6
8
  from playwright.sync_api import sync_playwright
7
9
  from tenacity import retry, stop_after_attempt, retry_if_exception_type
10
+ from requests import HTTPError
11
+ from time import sleep
8
12
 
9
- from .api.utils import table_api_call
13
+ from .api.ui_themes import get_workarena_theme_variants
14
+ from .api.user import create_user
15
+ from .api.utils import table_api_call, table_column_info
10
16
  from .config import (
11
17
  # for knowledge base setup
12
18
  KB_FILEPATH,
@@ -24,16 +30,140 @@ from .config import (
24
30
  EXPECTED_INCIDENT_FORM_FIELDS_PATH,
25
31
  EXPECTED_PROBLEM_FORM_FIELDS_PATH,
26
32
  EXPECTED_USER_FORM_FIELDS_PATH,
33
+ # Patch flag for reports
34
+ REPORT_PATCH_FLAG,
35
+ REPORT_DATE_FILTER,
36
+ # Supported ServiceNow releases
37
+ SNOW_SUPPORTED_RELEASES,
27
38
  # For workflows setup
28
39
  WORKFLOWS,
40
+ # For UI themes setup
41
+ UI_THEMES_UPDATE_SET,
29
42
  )
43
+ from .api.user import set_user_preference
30
44
  from .instance import SNowInstance
31
- from .utils import ui_login
45
+ from .utils import url_login
32
46
 
33
47
 
34
- def check_knowledge_base(instance: SNowInstance, kb_data: dict):
48
+ def _set_sys_property(property_name: str, value: str):
49
+ """
50
+ Set a sys_property in the instance.
51
+
52
+ """
53
+ instance = SNowInstance()
54
+
55
+ property = table_api_call(
56
+ instance=instance,
57
+ table="sys_properties",
58
+ params={"sysparm_query": f"name={property_name}", "sysparm_fields": "sys_id"},
59
+ )["result"]
60
+
61
+ if not property:
62
+ property_sysid = ""
63
+ method = "POST"
64
+ else:
65
+ property_sysid = "/" + property[0]["sys_id"]
66
+ method = "PUT"
67
+
68
+ property = table_api_call(
69
+ instance=instance,
70
+ table=f"sys_properties{property_sysid}",
71
+ method=method,
72
+ json={"name": property_name, "value": value},
73
+ )
74
+
75
+ # Verify that the property was updated
76
+ assert property["result"]["value"] == value, f"Error setting {property_name}."
77
+
78
+
79
+ def _get_sys_property(property_name: str) -> str:
80
+ """
81
+ Get a sys_property from the instance.
82
+
83
+ """
84
+ instance = SNowInstance()
85
+
86
+ property_value = table_api_call(
87
+ instance=instance,
88
+ table="sys_properties",
89
+ params={"sysparm_query": f"name={property_name}", "sysparm_fields": "value"},
90
+ )["result"][0]["value"]
91
+
92
+ return property_value
93
+
94
+
95
+ def _install_update_set(path: str, name: str):
96
+ """
97
+ Install a ServiceNow update set
98
+
99
+ Parameters:
100
+ -----------
101
+ path: str
102
+ The path to the update set file.
103
+ name: str
104
+ The name of the update set as it should appear in the UI.
105
+
106
+ Notes: requires interacting with the UI, so we use playwright instead of the API
107
+
108
+ """
109
+ with sync_playwright() as playwright:
110
+ instance = SNowInstance()
111
+ browser = playwright.chromium.launch(headless=True, slow_mo=1000)
112
+ page = browser.new_page()
113
+ url_login(instance, page)
114
+
115
+ # Navigate to the update set upload page and upload all update sets
116
+ logging.info("Uploading update set...")
117
+ page.goto(
118
+ instance.snow_url
119
+ + "/now/nav/ui/classic/params/target/upload.do%3Fsysparm_referring_url%3Dsys_remote_update_set_list.do%253Fsysparm_fixed_query%253Dsys_class_name%253Dsys_remote_update_set%26sysparm_target%3Dsys_remote_update_set"
120
+ )
121
+ iframe = page.wait_for_selector('iframe[name="gsft_main"]').content_frame()
122
+ with page.expect_file_chooser() as fc_info:
123
+ iframe.locator("#attachFile").click()
124
+ file_chooser = fc_info.value
125
+ file_chooser.set_files(path)
126
+ iframe.locator("input:text('Upload')").click()
127
+ sleep(5)
128
+
129
+ # Apply all update sets
130
+ logging.info("Applying update set...")
131
+ # ... retrieve all update sets that are ready to be applied
132
+ update_set = table_api_call(
133
+ instance=instance,
134
+ table="sys_remote_update_set",
135
+ params={
136
+ "sysparm_query": f"name={name}^state=loaded",
137
+ },
138
+ )["result"][0]
139
+ # ... apply them
140
+ logging.info(f"... {update_set['name']}")
141
+ page.goto(instance.snow_url + "/sys_remote_update_set.do?sys_id=" + update_set["sys_id"])
142
+ page.locator("button:has-text('Preview Update Set')").first.click()
143
+ page.wait_for_selector("text=Succeeded")
144
+ # click escape to close popup
145
+ page.keyboard.press("Escape")
146
+ page.locator("button:has-text('Commit Update Set')").first.click()
147
+ page.wait_for_selector("text=Succeeded")
148
+
149
+ browser.close()
150
+
151
+
152
+ def check_knowledge_base(
153
+ instance: SNowInstance, kb_name: str, kb_data: dict, disable_commenting: bool = True
154
+ ):
35
155
  """
36
156
  Verify the integrity of the knowledge base in the instance.
157
+ Args:
158
+ -----
159
+ instance: SNowInstance
160
+ The ServiceNow instance to check the knowledge base in
161
+ kb_name: str
162
+ The name of the knowledge base to check
163
+ kb_data: dict
164
+ The knowledge base data to check
165
+ disable_commenting: bool
166
+ Whether to disable commenting on the knowledge base
37
167
 
38
168
  """
39
169
 
@@ -47,7 +177,7 @@ def check_knowledge_base(instance: SNowInstance, kb_data: dict):
47
177
  kb = table_api_call(
48
178
  instance=instance,
49
179
  table="kb_knowledge_base",
50
- params={"sysparm_query": f"title={KB_NAME}"},
180
+ params={"sysparm_query": f"title={kb_name}"},
51
181
  )["result"]
52
182
 
53
183
  # The KB exists
@@ -55,6 +185,17 @@ def check_knowledge_base(instance: SNowInstance, kb_data: dict):
55
185
  requires_install = False
56
186
  requires_delete = False
57
187
 
188
+ # Check that the KB has the correct settings
189
+ if disable_commenting and (
190
+ kb[0]["disable_commenting"] != "true"
191
+ or kb[0]["disable_mark_as_helpful"] != "true"
192
+ or kb[0]["disable_rating"] != "true"
193
+ or kb[0]["disable_suggesting"] != "true"
194
+ or kb[0]["disable_category_editing"] != "true"
195
+ ):
196
+ requires_install = True
197
+ requires_delete = True
198
+
58
199
  # Get all articles in the KB
59
200
  articles = table_api_call(
60
201
  instance=instance,
@@ -73,6 +214,7 @@ def check_knowledge_base(instance: SNowInstance, kb_data: dict):
73
214
  # Invalid article title, the KB is corrupt and must be reinstalled
74
215
  requires_install = True
75
216
  requires_delete = True
217
+ break
76
218
 
77
219
  # Check that the articles match (preprocess the text because ServiceNow adds some HTML tags)
78
220
  if _extract_text(kb_data[idx]["article"]) != _extract_text(a["text"]):
@@ -98,7 +240,7 @@ def check_knowledge_base(instance: SNowInstance, kb_data: dict):
98
240
  )
99
241
 
100
242
 
101
- def delete_knowledge_base(instance: SNowInstance, kb_id: str):
243
+ def delete_knowledge_base(instance: SNowInstance, kb_id: str, kb_name: str):
102
244
  """
103
245
  Delete a knowledge base from the instance.
104
246
 
@@ -112,12 +254,12 @@ def delete_knowledge_base(instance: SNowInstance, kb_id: str):
112
254
  )["result"]
113
255
 
114
256
  # Delete the knowledge base
115
- logging.info("... deleting knowledge base content")
257
+ logging.info(f"Knowledge base {kb_name}: deleting knowledge base content")
116
258
  for a_ in articles:
117
259
  table_api_call(instance=instance, table=f"kb_knowledge/{a_['sys_id']}", method="DELETE")
118
260
 
119
261
  # Rename the KB and set active=False (ServiceNow prevents deletion)
120
- logging.info("... archiving knowledge base")
262
+ logging.info(f"Knowledge base {kb_name}: archiving knowledge base")
121
263
  table_api_call(
122
264
  instance=instance,
123
265
  table=f"kb_knowledge_base/{kb_id}",
@@ -126,25 +268,48 @@ def delete_knowledge_base(instance: SNowInstance, kb_id: str):
126
268
  )
127
269
 
128
270
 
129
- def create_knowledge_base(instance: SNowInstance, kb_data: dict):
271
+ def create_knowledge_base(
272
+ instance: SNowInstance, kb_name: str, kb_data: dict, disable_commenting: bool = True
273
+ ):
130
274
  """
131
- Create knowledge base and upload all articles
275
+ Create knowledge base and upload all articles.
276
+ Params:
277
+ -------
278
+ instance: SNowInstance
279
+ The ServiceNow instance to install the knowledge base in
280
+ kb_name: str
281
+ The name of the knowledge base that will be created
282
+ kb_data: dict
283
+ The knowledge base data to upload
284
+ disable_commenting: bool
285
+ Whether to disable commenting on the knowledge base
132
286
 
133
287
  """
134
- logging.info("Installing knowledge base...")
288
+ logging.info(f"Installing knowledge base {kb_name}...")
135
289
 
136
290
  # Create the knowledge base
137
- logging.info("... creating knowledge base")
291
+ logging.info(f"... creating knowledge base {kb_name}")
292
+ disable_commenting = "true" if disable_commenting else "false"
293
+
138
294
  kb = table_api_call(
139
295
  instance=instance,
140
296
  table="kb_knowledge_base",
141
297
  method="POST",
142
- data=json.dumps({"title": KB_NAME}),
298
+ data=json.dumps(
299
+ {
300
+ "title": kb_name,
301
+ "disable_commenting": disable_commenting,
302
+ "disable_mark_as_helpful": disable_commenting,
303
+ "disable_rating": disable_commenting,
304
+ "disable_suggesting": disable_commenting,
305
+ "disable_category_editing": disable_commenting,
306
+ }
307
+ ),
143
308
  )["result"]
144
309
  kb_id = kb["sys_id"]
145
310
 
146
311
  for i, kb_entry in enumerate(kb_data):
147
- logging.info(f"... uploading article {i + 1}/{len(kb_data)}")
312
+ logging.info(f"... Knowledge Base {kb_name} uploading article {i + 1}/{len(kb_data)}")
148
313
  article = kb_entry["article"]
149
314
 
150
315
  # Plant a new article in kb_knowledge table
@@ -164,7 +329,7 @@ def create_knowledge_base(instance: SNowInstance, kb_data: dict):
164
329
  )
165
330
 
166
331
 
167
- def setup_knowledge_base():
332
+ def setup_knowledge_bases():
168
333
  """
169
334
  Verify that the knowledge base is installed correctly in the instance.
170
335
  If it is not, it will be installed.
@@ -172,33 +337,47 @@ def setup_knowledge_base():
172
337
  """
173
338
  # Get the ServiceNow instance
174
339
  instance = SNowInstance()
340
+ # Mapping between knowledge base name and filepath + whether or not to disable comments
341
+ knowledge_bases = {
342
+ KB_NAME: (KB_FILEPATH, True),
343
+ }
344
+ for kb_name, (kb_filepath, disable_commenting) in knowledge_bases.items():
345
+ # Load the knowledge base
346
+ with open(kb_filepath, "r") as f:
347
+ kb_data = json.load(f)
175
348
 
176
- # Load the knowledge base
177
- with open(KB_FILEPATH, "r") as f:
178
- kb_data = json.load(f)
179
-
180
- kb_id, requires_install, requires_delete = check_knowledge_base(
181
- instance=instance, kb_data=kb_data
182
- )
183
-
184
- # Delete knowledge base if needed
185
- if requires_delete:
186
- logging.info("Knowledge base is corrupt. Reinstalling...")
187
- delete_knowledge_base(instance=instance, kb_id=kb_id)
188
-
189
- # Install the knowledge base
190
- if requires_install:
191
- create_knowledge_base(instance=instance, kb_data=kb_data)
192
-
193
- # Confirm that the knowledge base was installed correctly
194
349
  kb_id, requires_install, requires_delete = check_knowledge_base(
195
- instance=instance, kb_data=kb_data
350
+ instance=instance,
351
+ kb_name=kb_name,
352
+ kb_data=kb_data,
353
+ disable_commenting=disable_commenting,
196
354
  )
197
- assert not requires_install or requires_delete, "Knowledge base installation failed."
198
- logging.info("Knowledge base installation succeeded.")
199
355
 
200
- if not requires_delete and not requires_install:
201
- logging.info("Knowledge base is already installed.")
356
+ # Delete knowledge base if needed
357
+ if requires_delete:
358
+ logging.info(f"Knowledge base {kb_name} is corrupt. Reinstalling...")
359
+ delete_knowledge_base(instance=instance, kb_id=kb_id, kb_name=kb_name)
360
+
361
+ # Install the knowledge base
362
+ if requires_install:
363
+ create_knowledge_base(
364
+ instance=instance,
365
+ kb_name=kb_name,
366
+ kb_data=kb_data,
367
+ disable_commenting=disable_commenting,
368
+ )
369
+
370
+ # Confirm that the knowledge base was installed correctly
371
+ kb_id, requires_install, requires_delete = check_knowledge_base(
372
+ instance=instance, kb_name=kb_name, kb_data=kb_data
373
+ )
374
+ assert (
375
+ not requires_install or requires_delete
376
+ ), f"Knowledge base {kb_name} installation failed."
377
+ logging.info(f"Knowledge base {kb_name} installation succeeded.")
378
+
379
+ if not requires_delete and not requires_install:
380
+ logging.info(f"Knowledge base {kb_name} is already installed.")
202
381
 
203
382
 
204
383
  def setup_workflows():
@@ -247,118 +426,119 @@ def install_workflows():
247
426
  Notes: requires interacting with the UI, so we use playwright instead of the API
248
427
 
249
428
  """
250
- with sync_playwright() as playwright:
251
- instance = SNowInstance()
252
- browser = playwright.chromium.launch(headless=True)
253
- page = browser.new_page()
254
- ui_login(instance, page)
429
+ logging.info("Installing workflow update sets...")
430
+ for wf in WORKFLOWS.values():
431
+ _install_update_set(path=wf["update_set"], name=wf["name"])
255
432
 
256
- # Navigate to the update set upload page and upload all update sets
257
- logging.info("Uploading workflow update sets...")
258
- for wf in WORKFLOWS.values():
259
- logging.info(f"... {wf['name']}")
260
- page.goto(
261
- instance.snow_url
262
- + "/now/nav/ui/classic/params/target/upload.do%3Fsysparm_referring_url%3Dsys_remote_update_set_list.do%253Fsysparm_fixed_query%253Dsys_class_name%253Dsys_remote_update_set%26sysparm_target%3Dsys_remote_update_set"
433
+
434
+ def display_all_expected_columns(
435
+ instance: SNowInstance, list_name: str, expected_columns: list[str]
436
+ ):
437
+ """
438
+ Display all expected columns in a given list view.
439
+
440
+ Parameters:
441
+ -----------
442
+ instance: SNowInstance
443
+ The ServiceNow instance to configure.
444
+ list_name: str
445
+ The name of the list to display columns for.
446
+ expected_columns: list[str]
447
+ The list of columns to display.
448
+
449
+ """
450
+ logging.info(f"... Setting up default view for list {list_name}")
451
+
452
+ # Get the default view (for all users)
453
+ logging.info(f"...... Fetching default view for list {list_name}...")
454
+ default_view = table_api_call(
455
+ instance=instance,
456
+ table="sys_ui_list",
457
+ params={
458
+ "sysparm_query": f"name={list_name}^view.title=Default View^sys_userISEMPTY^parentISEMPTY",
459
+ "sysparm_fields": "sys_id,name,view.title,sys_user",
460
+ },
461
+ )["result"]
462
+
463
+ # If there is more than one, delete all but the one with the most recently updated
464
+ if len(default_view) > 1:
465
+ logging.info(
466
+ f"......... Multiple default views found for list {list_name}. Deleting all but the most recent one."
467
+ )
468
+ default_view = sorted(default_view, key=lambda x: x["sys_updated_on"], reverse=True)
469
+ # Delete all but the first one
470
+ for view in default_view[1:]:
471
+ logging.info(f"............ Deleting view {view['sys_id']}")
472
+ table_api_call(
473
+ instance=instance, table=f"sys_ui_list/{view['sys_id']}", method="DELETE"
263
474
  )
264
- iframe = page.wait_for_selector('iframe[name="gsft_main"]').content_frame()
265
- with page.expect_file_chooser() as fc_info:
266
- iframe.locator("#attachFile").click()
267
- file_chooser = fc_info.value
268
- file_chooser.set_files(wf["update_set"])
269
- iframe.locator("input:text('Upload')").click()
475
+ default_view = default_view[0]
270
476
 
271
- # Apply all update sets
272
- logging.info("Applying workflow update sets...")
273
- # ... retrieve all update sets that are ready to be applied
274
- sys_remote_update_set = table_api_call(
477
+ # Find all columns in the view (get their sysid)
478
+ logging.info(f"...... Fetching existing columns for default view of list {list_name}...")
479
+ columns = table_api_call(
480
+ instance=instance,
481
+ table="sys_ui_list_element",
482
+ params={"sysparm_query": f"list_id={default_view['sys_id']}", "sysparm_fields": "sys_id"},
483
+ )["result"]
484
+
485
+ # Delete all columns in the default view
486
+ logging.info(f"...... Deleting existing columns for default view of list {list_name}...")
487
+ for column in columns:
488
+ table_api_call(
489
+ instance=instance, table=f"sys_ui_list_element/{column['sys_id']}", method="DELETE"
490
+ )
491
+
492
+ # Add all expected columns to the default view
493
+ logging.info(f"...... Adding expected columns to default view of list {list_name}...")
494
+ for i, column in enumerate(expected_columns):
495
+ logging.info(f"......... {column}")
496
+ table_api_call(
275
497
  instance=instance,
276
- table="sys_remote_update_set",
277
- params={
278
- # Name matches workflows and update set status is loaded
279
- "sysparm_query": "nameIN"
280
- + ",".join([x["name"] for x in WORKFLOWS.values()])
281
- + "^state=loaded",
282
- },
283
- )["result"]
284
- # ... apply them
285
- for update_set in sys_remote_update_set:
286
- logging.info(f"... {update_set['name']}")
287
- page.goto(
288
- instance.snow_url + "/sys_remote_update_set.do?sys_id=" + update_set["sys_id"]
289
- )
290
- page.locator("button:has-text('Preview Update Set')").first.click()
291
- page.wait_for_selector("text=success")
292
- # click escape to close popup
293
- page.keyboard.press("Escape")
294
- page.locator("button:has-text('Commit Update Set')").first.click()
295
- page.wait_for_selector("text=Succeeded")
498
+ table="sys_ui_list_element",
499
+ method="POST",
500
+ data=json.dumps({"list_id": default_view["sys_id"], "element": column, "position": i}),
501
+ )
502
+ logging.info(f"...... Done.")
296
503
 
297
- browser.close()
298
504
 
505
+ def check_all_columns_displayed(
506
+ instance: SNowInstance, url: str, expected_columns: list[str]
507
+ ) -> bool:
508
+ """
509
+ Get the visible columns and checks that all expected columns are displayed.
299
510
 
300
- @retry(
301
- stop=stop_after_attempt(3),
302
- retry=retry_if_exception_type(TimeoutError),
303
- reraise=True,
304
- before_sleep=lambda _: logging.info("Retrying due to a TimeoutError..."),
305
- )
306
- def display_all_expected_columns(url: str, expected_columns: set[str]):
307
- """Display all expected columns in a given list view."""
308
- with sync_playwright() as playwright:
309
- instance = SNowInstance()
310
- browser = playwright.chromium.launch(headless=True, slow_mo=1000)
311
- page = browser.new_page()
312
- ui_login(instance, page)
313
- page.goto(instance.snow_url + url)
314
- frame = page.wait_for_selector("iframe#gsft_main").content_frame()
315
- # Open list personalization view
316
- frame.click(
317
- 'i[data-title="Personalize List Columns"]'
318
- ) # CSS selector to support both unmodified and modified list views
319
- selected_columns = frame.get_by_label("Selected", exact=True)
320
- selected_columns_required = set()
321
- # Required columns that are already added
322
- for option in selected_columns.get_by_role("option").all():
323
- value = option.get_attribute("value")
324
- if value:
325
- if value in expected_columns:
326
- selected_columns_required.add(value)
327
- # Remove extra columns
328
- else:
329
- option.click()
330
- frame.get_by_text("Remove", exact=True).click()
331
- columns_to_add = set(expected_columns) - selected_columns_required
332
-
333
- # Add required columns
334
- for column in columns_to_add:
335
- # Using CSS selector because some elements can't be selected otherwise (e.g. "sys_class_name")
336
- frame.click(f'option[value="{column}"]')
337
- frame.get_by_text("Add").click()
338
- frame.click("#ok_button")
339
-
340
-
341
- def check_all_columns_displayed(url: str, expected_columns: set[str]) -> bool:
342
- """Get the visible columns and checks that all expected columns are displayed."""
511
+ Parameters:
512
+ -----------
513
+ instance: SNowInstance
514
+ The ServiceNow instance to configure.
515
+ url: str
516
+ The URL of the list view to check.
517
+ expected_columns: list[str]
518
+ The set of columns to check for.
519
+
520
+ Returns:
521
+ --------
522
+ bool: True if all expected columns are displayed, False otherwise.
523
+
524
+ """
343
525
  with sync_playwright() as playwright:
344
- instance = SNowInstance()
345
526
  browser = playwright.chromium.launch(headless=True, slow_mo=1000)
346
527
  page = browser.new_page()
347
- ui_login(instance, page)
528
+ url_login(instance, page)
348
529
  page.goto(instance.snow_url + url)
349
- iframe = page.frame("gsft_main")
530
+ iframe = page.wait_for_selector("iframe#gsft_main").content_frame()
531
+ # Wait for gsft_main.GlideList2 to be available
532
+ page.wait_for_function("typeof gsft_main.GlideList2 !== 'undefined'")
350
533
  lst = iframe.locator("table.data_list_table")
351
- lst.wait_for()
352
534
 
353
535
  # Validate the number of lists on the page
354
536
  lst = lst.nth(0)
355
537
  js_selector = f"gsft_main.GlideList2.get('{lst.get_attribute('data-list_id')}')"
356
- # Wait for gsft_main.GlideList2 to be available
357
- page.wait_for_function("typeof gsft_main.GlideList2 !== 'undefined'")
358
538
  visible_columns = set(page.evaluate(f"{js_selector}.fields").split(","))
359
539
 
360
540
  # check if expected columns is contained in the visible columns
361
- if not expected_columns.issubset(visible_columns):
541
+ if not set(expected_columns).issubset(visible_columns):
362
542
  logging.info(
363
543
  f"Error setting up list at {url} \n Expected {expected_columns} columns, but got {visible_columns}."
364
544
  )
@@ -368,42 +548,60 @@ def check_all_columns_displayed(url: str, expected_columns: set[str]) -> bool:
368
548
 
369
549
 
370
550
  def setup_list_columns():
371
- """Setup the list view to display the expected number of columns."""
551
+ """
552
+ Setup the list view to display the expected number of columns.
553
+
554
+ """
555
+ logging.info("Setting up visible list columns...")
372
556
  list_mappings = {
373
557
  "alm_asset": {
374
- "url": "/now/nav/ui/classic/params/target/alm_asset_list.do%3Fsysparm_view%3Ditam_workspace%26sysparm_userpref.alm_asset_list.view%3Ditam_workspace%26sysparm_userpref.alm_asset.view%3Ditam_workspace%26sysparm_query%3D%26sysparm_fixed_query%3D",
558
+ "url": "/now/nav/ui/classic/params/target/alm_asset_list.do",
375
559
  "expected_columns_path": EXPECTED_ASSET_LIST_COLUMNS_PATH,
376
560
  },
377
561
  "alm_hardware": {
378
- "url": "/now/nav/ui/classic/params/target/alm_hardware_list.do%3Fsysparm_view%3Ditam_workspace%26sysparm_userpref.alm_hardware_list.view%3Ditam_workspace%26sysparm_userpref.alm_hardware.view%3Ditam_workspace%3D%26sysparm_query%3Dinstall_status%253D6%255Esubstatus%253Dpre_allocated",
562
+ "url": "/now/nav/ui/classic/params/target/alm_hardware_list.do",
379
563
  "expected_columns_path": EXPECTED_HARDWARE_COLUMNS_PATH,
380
564
  },
381
565
  "change_request": {
382
- "url": "/now/nav/ui/classic/params/target/change_request_list.do%3Fsysparm_view%3Dsow%26sysparm_userpref.change_request_list.view%3Dsow%26sysparm_userpref.change_request.view%3Dsow%26sysparm_query%3D%26sysparm_fixed_query%3D",
566
+ "url": "/now/nav/ui/classic/params/target/change_request_list.do",
383
567
  "expected_columns_path": EXPECTED_CHANGE_REQUEST_COLUMNS_PATH,
384
568
  },
385
569
  "incident": {
386
- "url": "/now/nav/ui/classic/params/target/incident_list.do%3Fsysparm_query%3Dactive%253Dtrue%26sysparm_first_row%3D1%26sysparm_view%3DMajor%2520Incidents",
570
+ "url": "/now/nav/ui/classic/params/target/incident_list.do",
387
571
  "expected_columns_path": EXPECTED_INCIDENT_COLUMNS_PATH,
388
572
  },
389
573
  "sys_user": {
390
- "url": "/now/nav/ui/classic/params/target/sys_user_list.do%3Fsysparm_view%3D%26sysparm_userpref.sys_user_list.view%3D%26sysparm_userpref.sys_user.view%3D%26sysparm_query%3Dactive%253Dtrue%255Ecompany%253D81fd65ecac1d55eb42a426568fc87a63",
574
+ "url": "/now/nav/ui/classic/params/target/sys_user_list.do",
391
575
  "expected_columns_path": EXPECTED_USER_COLUMNS_PATH,
392
576
  },
393
577
  "sc_cat_item": {
394
- "url": "/now/nav/ui/classic/params/target/sc_cat_item_list.do%3Fsysparm_view%3D%26sysparm_userpref.sc_cat_item_list.view%3D%26sysparm_userpref.sc_cat_item.view%3D%26sysparm_query%3D%26sysparm_fixed_query%3D",
578
+ "url": "/now/nav/ui/classic/params/target/sc_cat_item_list.do",
395
579
  "expected_columns_path": EXPECTED_SERVICE_CATALOG_COLUMNS_PATH,
396
580
  },
397
581
  }
582
+
583
+ logging.info("... Creating a new user account to validate list columns")
584
+ admin_instance = SNowInstance()
585
+ username, password, usysid = create_user(instance=admin_instance)
586
+ user_instance = SNowInstance(snow_credentials=(username, password))
587
+
398
588
  for task, task_info in list_mappings.items():
399
589
  expected_columns_path = task_info["expected_columns_path"]
400
590
  with open(expected_columns_path, "r") as f:
401
- expected_columns = set(json.load(f))
402
- display_all_expected_columns(task_info["url"], expected_columns=expected_columns)
591
+ expected_columns = list(json.load(f))
592
+
593
+ # Configuration is done via API (with admin credentials)
594
+ display_all_expected_columns(admin_instance, task, expected_columns=expected_columns)
595
+
596
+ # Validation is done via UI (with normal user credentials to see if changes have propagated)
403
597
  assert check_all_columns_displayed(
404
- task_info["url"], expected_columns=expected_columns
598
+ user_instance, task_info["url"], expected_columns=expected_columns
405
599
  ), f"Error setting up list columns at {task_info['url']}"
406
600
 
601
+ # Delete the user account
602
+ logging.info("... Deleting the test user account")
603
+ table_api_call(instance=admin_instance, table=f"sys_user/{usysid}", method="DELETE")
604
+
407
605
 
408
606
  @retry(
409
607
  stop=stop_after_attempt(3),
@@ -411,15 +609,15 @@ def setup_list_columns():
411
609
  reraise=True,
412
610
  before_sleep=lambda _: logging.info("Retrying due to a TimeoutError..."),
413
611
  )
414
- def process_form_fields(url: str, expected_fields: list[str], action: str):
612
+ def process_form_fields(instance: SNowInstance, url: str, expected_fields: list[str], action: str):
415
613
  """Process form fields based on the given action."""
416
614
  with sync_playwright() as playwright:
417
- instance = SNowInstance()
418
615
  browser = playwright.chromium.launch(headless=True, slow_mo=1000)
419
616
  page = browser.new_page()
420
- ui_login(instance, page)
617
+ url_login(instance, page)
421
618
  page.goto(instance.snow_url + url)
422
619
  frame = page.wait_for_selector("iframe#gsft_main").content_frame()
620
+ page.wait_for_function("typeof gsft_main.GlideList2 !== 'undefined'")
423
621
  # Open form personalization view if not expanded
424
622
  form_personalization_expanded = frame.locator(
425
623
  'button:has-text("Personalize Form")'
@@ -427,7 +625,7 @@ def process_form_fields(url: str, expected_fields: list[str], action: str):
427
625
  if form_personalization_expanded == "false":
428
626
  frame.click('button:has-text("Personalize Form")')
429
627
  available_options = (
430
- frame.get_by_label("Personalize Form").locator('li[role="presentation"] input').all()
628
+ frame.get_by_label("Personalize Form").locator('li[role="presentation"] >> input').all()
431
629
  )
432
630
 
433
631
  for option in available_options:
@@ -438,9 +636,9 @@ def process_form_fields(url: str, expected_fields: list[str], action: str):
438
636
  checked = option.get_attribute("aria-checked")
439
637
  if action == "display":
440
638
  if id in expected_fields and checked == "false":
441
- option.click()
639
+ option.evaluate("e => e.click()") # playwright clicking doesn't work
442
640
  elif id not in expected_fields and checked == "true":
443
- option.click()
641
+ option.evaluate("e => e.click()") # playwright clicking doesn't work
444
642
  elif action == "check":
445
643
  if id in expected_fields and checked == "false":
446
644
  logging.info(
@@ -454,6 +652,7 @@ def process_form_fields(url: str, expected_fields: list[str], action: str):
454
652
  return False
455
653
  if action == "check":
456
654
  logging.info(f"All fields properly displayed for {url}.")
655
+
457
656
  # Close the form personalization view
458
657
  frame.click('button:has-text("Personalize Form")')
459
658
  return True
@@ -482,19 +681,59 @@ def setup_form_fields():
482
681
  "url": "/now/nav/ui/classic/params/target/sys_user.do",
483
682
  },
484
683
  }
684
+
685
+ logging.info("... Creating a new user account to validate form fields")
686
+ admin_instance = SNowInstance()
687
+ username, password, usysid = create_user(instance=admin_instance)
688
+ user_instance = SNowInstance(snow_credentials=(username, password))
689
+
485
690
  for task, task_info in task_mapping.items():
486
691
  expected_fields_path = task_info["expected_fields_path"]
487
692
  with open(expected_fields_path, "r") as f:
488
693
  expected_fields = json.load(f)
489
- process_form_fields(task_info["url"], expected_fields=expected_fields, action="display")
694
+
695
+ logging.info(f"Setting up form fields for {task}...")
696
+ process_form_fields(
697
+ admin_instance, task_info["url"], expected_fields=expected_fields, action="display"
698
+ )
699
+ sleep(5)
700
+
701
+ # If the view was edited, a new user preference was created for the admin user
702
+ # We want to apply it to all users so we need to edit the record to set sys_user to empty
703
+ # and system to true.
704
+ logging.info(f"Checking for new user preferences for {task} form fields")
705
+ user_preferences = table_api_call(
706
+ instance=admin_instance,
707
+ table="sys_user_preference",
708
+ params={
709
+ "sysparm_query": f"name=personalize_{task_info['url'].split('/')[-1].strip().replace('.do', '')}_default"
710
+ },
711
+ )["result"]
712
+ if len(user_preferences) > 0:
713
+ logging.info(f"Generalizing new settings to all users for {task} form fields")
714
+ # Get the most recent user preference
715
+ user_preference = sorted(
716
+ user_preferences, key=lambda x: x["sys_updated_on"], reverse=True
717
+ )[0]
718
+ # Update the user preference
719
+ table_api_call(
720
+ instance=admin_instance,
721
+ table=f"sys_user_preference/{user_preference['sys_id']}",
722
+ method="PATCH",
723
+ json={"user": "", "system": "true"},
724
+ )
725
+
726
+ # Validation is done with a new user to make sure the changes have propagated
727
+ logging.info(f"Validating form fields for {task}...")
490
728
  assert process_form_fields(
491
- task_info["url"], expected_fields=expected_fields, action="check"
729
+ user_instance, task_info["url"], expected_fields=expected_fields, action="check"
492
730
  ), f"Error setting up form fields at {task_info['url']}"
493
- logging.info("All form fields properly displayed.")
494
- print("all columns displayed")
495
731
 
732
+ # Delete the user account
733
+ logging.info("... Deleting the test user account")
734
+ table_api_call(instance=admin_instance, table=f"sys_user/{usysid}", method="DELETE")
496
735
 
497
- from .config import SNOW_SUPPORTED_RELEASES
736
+ logging.info("All form fields properly displayed.")
498
737
 
499
738
 
500
739
  def check_instance_release_support():
@@ -515,8 +754,190 @@ def check_instance_release_support():
515
754
  f"You are running {version_info['build name']} {version_info}."
516
755
  )
517
756
  return False
757
+ return True
758
+
759
+
760
+ def enable_url_login():
761
+ """
762
+ Configure the instance to allow login via URL.
763
+
764
+ """
765
+ _set_sys_property(property_name="glide.security.restrict.get.login", value="false")
766
+ logging.info("URL login enabled.")
767
+
768
+
769
+ def disable_guided_tours():
770
+ """
771
+ Hide guided tour popups
772
+
773
+ """
774
+ _set_sys_property(property_name="com.snc.guided_tours.sp.enable", value="false")
775
+ _set_sys_property(property_name="com.snc.guided_tours.standard_ui.enable", value="false")
776
+ logging.info("Guided tours disabled.")
777
+
778
+
779
+ def disable_welcome_help_popup():
780
+ """
781
+ Disable the welcome help popup
782
+
783
+ """
784
+ set_user_preference(instance=SNowInstance(), key="overview_help.visited.navui", value="true")
785
+ logging.info("Welcome help popup disabled.")
786
+
787
+
788
+ def disable_analytics_popups():
789
+ """
790
+ Disable analytics popups (needs to be done through UI since Vancouver release)
791
+
792
+ """
793
+ _set_sys_property(property_name="glide.analytics.enabled", value="false")
794
+ logging.info("Analytics popups disabled.")
795
+
796
+
797
+ def setup_ui_themes():
798
+ """
799
+ Install custom UI themes and set it as default
800
+
801
+ """
802
+ logging.info("Installing custom UI themes...")
803
+ _install_update_set(path=UI_THEMES_UPDATE_SET["update_set"], name=UI_THEMES_UPDATE_SET["name"])
804
+ check_ui_themes_installed()
805
+
806
+ logging.info("Setting default UI theme")
807
+ _set_sys_property(
808
+ property_name="glide.ui.polaris.theme.custom",
809
+ value=get_workarena_theme_variants(SNowInstance())[0]["theme.sys_id"],
810
+ )
811
+
812
+ # Set admin user's theme variant
813
+ # ... get user's sysid
814
+ admin_user = table_api_call(
815
+ instance=SNowInstance(),
816
+ table="sys_user",
817
+ params={"sysparm_query": "user_name=admin", "sysparm_fields": "sys_id"},
818
+ )["result"][0]
819
+ # ... set user preference
820
+ set_user_preference(
821
+ instance=SNowInstance(),
822
+ user=admin_user["sys_id"],
823
+ key="glide.ui.polaris.theme.variant",
824
+ value=[
825
+ x["style.sys_id"]
826
+ for x in get_workarena_theme_variants(SNowInstance())
827
+ if x["style.name"] == "Workarena"
828
+ ][0],
829
+ )
830
+
831
+
832
+ def check_ui_themes_installed():
833
+ """
834
+ Check if the UI themes are installed in the instance.
835
+
836
+ """
837
+ expected_variants = set([v.lower() for v in UI_THEMES_UPDATE_SET["variants"]])
838
+ installed_themes = get_workarena_theme_variants(SNowInstance())
839
+ installed_themes = set([t["style.name"].lower() for t in installed_themes])
840
+
841
+ assert (
842
+ installed_themes == expected_variants
843
+ ), f"""UI theme installation failed.
844
+ Expected: {expected_variants}
845
+ Installed: {installed_themes}
846
+ """
847
+
848
+
849
+ def set_home_page():
850
+ logging.info("Setting default home page")
851
+ _set_sys_property(property_name="glide.login.home", value="/now/nav/ui/home")
852
+
853
+
854
+ def wipe_system_admin_preferences():
855
+ """
856
+ Wipe all system admin preferences
857
+
858
+ """
859
+ logging.info("Wiping all system admin preferences")
860
+ sys_admin_prefs = table_api_call(
861
+ instance=SNowInstance(),
862
+ table="sys_user_preference",
863
+ params={"sysparm_query": "user.user_name=admin", "sysparm_fields": "sys_id,name"},
864
+ )["result"]
865
+
866
+ # Delete all sysadmin preferences
867
+ logging.info("... Deleting all preferences")
868
+ for pref in sys_admin_prefs:
869
+ logging.info(f"...... deleting {pref['name']}")
870
+ table_api_call(
871
+ instance=SNowInstance(), table=f"sys_user_preference/{pref['sys_id']}", method="DELETE"
872
+ )
873
+
874
+
875
+ def patch_report_filters():
876
+ """
877
+ Add filters to reports to make sure they stay frozen in time and don't show new data
878
+ as then instance's life cycle progresses.
879
+
880
+ """
881
+ logging.info("Patching reports with date filter...")
882
+
883
+ cutoff_date = REPORT_DATE_FILTER
884
+
885
+ instance = SNowInstance()
886
+
887
+ # Get all reports that are not already patched
888
+ reports = table_api_call(
889
+ instance=instance,
890
+ table="sys_report",
891
+ params={
892
+ "sysparm_query": f"sys_class_name=sys_report^active=true^descriptionNOT LIKE{REPORT_PATCH_FLAG}^ORdescriptionISEMPTY"
893
+ },
894
+ )["result"]
518
895
 
896
+ incompatible_reports = []
897
+ for report in reports:
898
+ # Find all sys_created_on columns of this record. Some have many.
899
+ sys_created_on_cols = [
900
+ c for c in table_column_info(instance, report["table"]).keys() if "sys_created_on" in c
901
+ ]
902
+
903
+ try:
904
+ # XXX: We purposely do not support reports with multiple filter conditions for simplicity
905
+ if len(sys_created_on_cols) == 0 or "^NQ" in report["filter"]:
906
+ raise NotImplementedError()
907
+
908
+ # Add the filter
909
+ filter = "".join(
910
+ [
911
+ f"^{col}<javascript:gs.dateGenerate('{cutoff_date}','00:00:00')"
912
+ for col in sys_created_on_cols
913
+ ]
914
+ ) + ("^" if len(report["filter"]) > 0 and not report["filter"].startswith("^") else "")
915
+ table_api_call(
916
+ instance=instance,
917
+ table=f"sys_report/{report['sys_id']}",
918
+ method="PATCH",
919
+ json={
920
+ "filter": filter + report["filter"],
921
+ "description": report["description"] + " " + REPORT_PATCH_FLAG,
922
+ },
923
+ )
924
+ logging.info(
925
+ f"Patched report {report['title']} {report['sys_id']} (columns: {sys_created_on_cols})..."
926
+ )
519
927
 
928
+ except (NotImplementedError, HTTPError):
929
+ # HTTPError occurs when some reports simply cannot be patched because they are critical and protected
930
+ incompatible_reports.append(report["sys_id"])
931
+ logging.info(
932
+ f"Did not patch report {report['title']} {report['title']} (columns: {sys_created_on_cols})..."
933
+ )
934
+
935
+
936
+ @tenacity.retry(
937
+ stop=tenacity.stop_after_attempt(3),
938
+ reraise=True,
939
+ before_sleep=lambda _: logging.info("An error occurred. Retrying..."),
940
+ )
520
941
  def setup():
521
942
  """
522
943
  Check that WorkArena is installed correctly in the instance.
@@ -525,12 +946,42 @@ def setup():
525
946
  if not check_instance_release_support():
526
947
  return # Don't continue if the instance is not supported
527
948
 
949
+ # Enable URL login (XXX: Do this first since other functions can use URL login)
950
+ enable_url_login()
951
+
952
+ # Set default landing page
953
+ set_home_page()
954
+
955
+ # Disable popups for new users
956
+ # ... guided tours
957
+ disable_guided_tours()
958
+ # ... analytics
959
+ disable_analytics_popups()
960
+ # ... help
961
+ disable_welcome_help_popup()
962
+
963
+ # Install custom UI themes (needs to be after disabling popups)
964
+ setup_ui_themes()
965
+
966
+ # Clear all predefined system admin preferences (e.g., default list views, etc.)
967
+ wipe_system_admin_preferences()
968
+
969
+ # Patch all reports to only show data <= April 1, 2024
970
+ patch_report_filters()
971
+
528
972
  # XXX: Install workflows first because they may automate some downstream installations
529
973
  setup_workflows()
530
- setup_knowledge_base()
974
+ setup_knowledge_bases()
975
+
531
976
  # Setup the user list columns by displaying all columns and checking that the expected number are displayed
532
- setup_list_columns()
533
977
  setup_form_fields()
978
+ setup_list_columns()
979
+
980
+ # Save installation date
981
+ logging.info("Saving installation date")
982
+ _set_sys_property(property_name="workarena.installation.date", value=datetime.now().isoformat())
983
+
984
+ logging.info("WorkArena setup complete.")
534
985
 
535
986
 
536
987
  def main():
@@ -539,4 +990,25 @@ def main():
539
990
 
540
991
  """
541
992
  logging.basicConfig(level=logging.INFO)
993
+
994
+ try:
995
+ past_install_date = _get_sys_property("workarena.installation.date")
996
+ logging.info(f"Detected previous installation on {past_install_date}. Reinstalling...")
997
+ except:
998
+ past_install_date = "never"
999
+
1000
+ logging.info(
1001
+ f"""
1002
+
1003
+ ██ ██ ██████ ██████ ██ ██ █████ ██████ ███████ ███ ██ █████
1004
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██
1005
+ ██ █ ██ ██ ██ ██████ █████ ███████ ██████ █████ ██ ██ ██ ███████
1006
+ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
1007
+ ███ ███ ██████ ██ ██ ██ ██ ██ ██ ██ ██ ███████ ██ ████ ██ ██
1008
+
1009
+ Instance: {SNowInstance().snow_url}
1010
+ Previous installation: {past_install_date}
1011
+
1012
+ """
1013
+ )
542
1014
  setup()