browsergym-workarena 0.1.0rc7__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. browsergym/workarena/__init__.py +3 -2
  2. browsergym/workarena/api/ui_themes.py +35 -0
  3. browsergym/workarena/api/user.py +153 -0
  4. browsergym/workarena/api/utils.py +1 -1
  5. browsergym/workarena/config.py +43 -1
  6. browsergym/workarena/data_files/setup_files/ui_themes/workarena_themes.xml +2313 -0
  7. browsergym/workarena/data_files/task_configs/all_menu.json +94 -94
  8. browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -0
  9. browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -0
  10. browsergym/workarena/data_files/task_configs/filter_service_catalog_item_list_task.json +7985 -7981
  11. browsergym/workarena/data_files/task_configs/impersonation_users.json +2 -2
  12. browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -0
  13. browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -0
  14. browsergym/workarena/install.py +620 -155
  15. browsergym/workarena/tasks/base.py +85 -26
  16. browsergym/workarena/tasks/dashboard.py +620 -0
  17. browsergym/workarena/tasks/form.py +121 -85
  18. browsergym/workarena/tasks/knowledge.py +30 -14
  19. browsergym/workarena/tasks/list.py +121 -67
  20. browsergym/workarena/tasks/navigation.py +18 -16
  21. browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +272 -0
  22. browsergym/workarena/tasks/scripts/generate_forms.py +2 -2
  23. browsergym/workarena/tasks/scripts/list.py +2 -2
  24. browsergym/workarena/tasks/scripts/validate.py +2 -2
  25. browsergym/workarena/tasks/service_catalog.py +106 -74
  26. browsergym/workarena/tasks/utils/form.py +5 -3
  27. browsergym/workarena/tasks/utils/js_utils.js +123 -2
  28. browsergym/workarena/tasks/utils/string.py +15 -0
  29. browsergym/workarena/tasks/utils/utils.py +20 -0
  30. browsergym/workarena/utils.py +31 -2
  31. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/METADATA +15 -5
  32. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/RECORD +35 -24
  33. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/WHEEL +1 -1
  34. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/entry_points.txt +0 -0
  35. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -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,110 +426,106 @@ 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
- columns_to_remove = []
321
- selected_columns_required = set()
322
-
323
- # Check for required columns that are already added, and extra columns that must be removed
324
-
325
- for option in selected_columns.get_by_role("option").all():
326
- value = option.get_attribute("value")
327
- if value:
328
- if value in expected_columns:
329
- selected_columns_required.add(value)
330
- else:
331
- columns_to_remove.append(value)
332
-
333
- # Remove extra columns
334
- selected_columns.select_option(value=columns_to_remove)
335
- frame.get_by_text("Remove", exact=True).click()
336
-
337
- # Add missing columns
338
- available_columns = frame.get_by_label("Available", exact=True)
339
- columns_to_add = list(set(expected_columns) - selected_columns_required)
340
- available_columns.select_option(value=columns_to_add)
341
- frame.get_by_text("Add", exact=True).click()
342
-
343
- # Confirm
344
- frame.locator("#ok_button").click()
345
-
346
-
347
- def check_all_columns_displayed(url: str, expected_columns: set[str]) -> bool:
348
- """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
+ """
349
525
  with sync_playwright() as playwright:
350
- instance = SNowInstance()
351
526
  browser = playwright.chromium.launch(headless=True, slow_mo=1000)
352
527
  page = browser.new_page()
353
- ui_login(instance, page)
528
+ url_login(instance, page)
354
529
  page.goto(instance.snow_url + url)
355
530
  iframe = page.wait_for_selector("iframe#gsft_main").content_frame()
356
531
  # Wait for gsft_main.GlideList2 to be available
@@ -363,7 +538,7 @@ def check_all_columns_displayed(url: str, expected_columns: set[str]) -> bool:
363
538
  visible_columns = set(page.evaluate(f"{js_selector}.fields").split(","))
364
539
 
365
540
  # check if expected columns is contained in the visible columns
366
- if not expected_columns.issubset(visible_columns):
541
+ if not set(expected_columns).issubset(visible_columns):
367
542
  logging.info(
368
543
  f"Error setting up list at {url} \n Expected {expected_columns} columns, but got {visible_columns}."
369
544
  )
@@ -373,42 +548,60 @@ def check_all_columns_displayed(url: str, expected_columns: set[str]) -> bool:
373
548
 
374
549
 
375
550
  def setup_list_columns():
376
- """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...")
377
556
  list_mappings = {
378
557
  "alm_asset": {
379
- "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",
380
559
  "expected_columns_path": EXPECTED_ASSET_LIST_COLUMNS_PATH,
381
560
  },
382
561
  "alm_hardware": {
383
- "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",
384
563
  "expected_columns_path": EXPECTED_HARDWARE_COLUMNS_PATH,
385
564
  },
386
565
  "change_request": {
387
- "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",
388
567
  "expected_columns_path": EXPECTED_CHANGE_REQUEST_COLUMNS_PATH,
389
568
  },
390
569
  "incident": {
391
- "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",
392
571
  "expected_columns_path": EXPECTED_INCIDENT_COLUMNS_PATH,
393
572
  },
394
573
  "sys_user": {
395
- "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",
396
575
  "expected_columns_path": EXPECTED_USER_COLUMNS_PATH,
397
576
  },
398
577
  "sc_cat_item": {
399
- "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",
400
579
  "expected_columns_path": EXPECTED_SERVICE_CATALOG_COLUMNS_PATH,
401
580
  },
402
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
+
403
588
  for task, task_info in list_mappings.items():
404
589
  expected_columns_path = task_info["expected_columns_path"]
405
590
  with open(expected_columns_path, "r") as f:
406
- expected_columns = set(json.load(f))
407
- 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)
408
597
  assert check_all_columns_displayed(
409
- task_info["url"], expected_columns=expected_columns
598
+ user_instance, task_info["url"], expected_columns=expected_columns
410
599
  ), f"Error setting up list columns at {task_info['url']}"
411
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
+
412
605
 
413
606
  @retry(
414
607
  stop=stop_after_attempt(3),
@@ -416,13 +609,12 @@ def setup_list_columns():
416
609
  reraise=True,
417
610
  before_sleep=lambda _: logging.info("Retrying due to a TimeoutError..."),
418
611
  )
419
- 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):
420
613
  """Process form fields based on the given action."""
421
614
  with sync_playwright() as playwright:
422
- instance = SNowInstance()
423
615
  browser = playwright.chromium.launch(headless=True, slow_mo=1000)
424
616
  page = browser.new_page()
425
- ui_login(instance, page)
617
+ url_login(instance, page)
426
618
  page.goto(instance.snow_url + url)
427
619
  frame = page.wait_for_selector("iframe#gsft_main").content_frame()
428
620
  page.wait_for_function("typeof gsft_main.GlideList2 !== 'undefined'")
@@ -433,7 +625,7 @@ def process_form_fields(url: str, expected_fields: list[str], action: str):
433
625
  if form_personalization_expanded == "false":
434
626
  frame.click('button:has-text("Personalize Form")')
435
627
  available_options = (
436
- 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()
437
629
  )
438
630
 
439
631
  for option in available_options:
@@ -444,9 +636,9 @@ def process_form_fields(url: str, expected_fields: list[str], action: str):
444
636
  checked = option.get_attribute("aria-checked")
445
637
  if action == "display":
446
638
  if id in expected_fields and checked == "false":
447
- option.click()
639
+ option.evaluate("e => e.click()") # playwright clicking doesn't work
448
640
  elif id not in expected_fields and checked == "true":
449
- option.click()
641
+ option.evaluate("e => e.click()") # playwright clicking doesn't work
450
642
  elif action == "check":
451
643
  if id in expected_fields and checked == "false":
452
644
  logging.info(
@@ -460,6 +652,7 @@ def process_form_fields(url: str, expected_fields: list[str], action: str):
460
652
  return False
461
653
  if action == "check":
462
654
  logging.info(f"All fields properly displayed for {url}.")
655
+
463
656
  # Close the form personalization view
464
657
  frame.click('button:has-text("Personalize Form")')
465
658
  return True
@@ -488,19 +681,59 @@ def setup_form_fields():
488
681
  "url": "/now/nav/ui/classic/params/target/sys_user.do",
489
682
  },
490
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
+
491
690
  for task, task_info in task_mapping.items():
492
691
  expected_fields_path = task_info["expected_fields_path"]
493
692
  with open(expected_fields_path, "r") as f:
494
693
  expected_fields = json.load(f)
495
- 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}...")
496
728
  assert process_form_fields(
497
- task_info["url"], expected_fields=expected_fields, action="check"
729
+ user_instance, task_info["url"], expected_fields=expected_fields, action="check"
498
730
  ), f"Error setting up form fields at {task_info['url']}"
499
- logging.info("All form fields properly displayed.")
500
- print("all columns displayed")
501
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")
502
735
 
503
- from .config import SNOW_SUPPORTED_RELEASES
736
+ logging.info("All form fields properly displayed.")
504
737
 
505
738
 
506
739
  def check_instance_release_support():
@@ -524,6 +757,187 @@ def check_instance_release_support():
524
757
  return True
525
758
 
526
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"]
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
+ )
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
+ )
527
941
  def setup():
528
942
  """
529
943
  Check that WorkArena is installed correctly in the instance.
@@ -532,12 +946,42 @@ def setup():
532
946
  if not check_instance_release_support():
533
947
  return # Don't continue if the instance is not supported
534
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
+
535
972
  # XXX: Install workflows first because they may automate some downstream installations
536
973
  setup_workflows()
537
- setup_knowledge_base()
974
+ setup_knowledge_bases()
975
+
538
976
  # Setup the user list columns by displaying all columns and checking that the expected number are displayed
539
- setup_list_columns()
540
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.")
541
985
 
542
986
 
543
987
  def main():
@@ -546,4 +990,25 @@ def main():
546
990
 
547
991
  """
548
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
+ )
549
1014
  setup()