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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. browsergym/workarena/__init__.py +3 -2
  2. browsergym/workarena/api/ui_themes.py +35 -0
  3. browsergym/workarena/api/user.py +153 -0
  4. browsergym/workarena/api/utils.py +1 -1
  5. browsergym/workarena/config.py +43 -1
  6. browsergym/workarena/data_files/setup_files/ui_themes/workarena_themes.xml +2313 -0
  7. browsergym/workarena/data_files/task_configs/all_menu.json +94 -94
  8. browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -0
  9. browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -0
  10. browsergym/workarena/data_files/task_configs/filter_service_catalog_item_list_task.json +7985 -7981
  11. browsergym/workarena/data_files/task_configs/impersonation_users.json +2 -2
  12. browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -0
  13. browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -0
  14. browsergym/workarena/install.py +620 -155
  15. browsergym/workarena/tasks/base.py +85 -26
  16. browsergym/workarena/tasks/dashboard.py +620 -0
  17. browsergym/workarena/tasks/form.py +121 -85
  18. browsergym/workarena/tasks/knowledge.py +30 -14
  19. browsergym/workarena/tasks/list.py +121 -67
  20. browsergym/workarena/tasks/navigation.py +18 -16
  21. browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +272 -0
  22. browsergym/workarena/tasks/scripts/generate_forms.py +2 -2
  23. browsergym/workarena/tasks/scripts/list.py +2 -2
  24. browsergym/workarena/tasks/scripts/validate.py +2 -2
  25. browsergym/workarena/tasks/service_catalog.py +106 -74
  26. browsergym/workarena/tasks/utils/form.py +5 -3
  27. browsergym/workarena/tasks/utils/js_utils.js +123 -2
  28. browsergym/workarena/tasks/utils/string.py +15 -0
  29. browsergym/workarena/tasks/utils/utils.py +20 -0
  30. browsergym/workarena/utils.py +31 -2
  31. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/METADATA +15 -5
  32. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/RECORD +35 -24
  33. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/WHEEL +1 -1
  34. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/entry_points.txt +0 -0
  35. {browsergym_workarena-0.1.0rc7.dist-info → browsergym_workarena-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,620 @@
1
+ import json
2
+ import logging
3
+ import numpy as np
4
+ import playwright.sync_api
5
+ import re
6
+ import tenacity
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import List, Tuple
10
+ from urllib import parse
11
+
12
+ from .base import AbstractServiceNowTask
13
+ from ..api.utils import table_api_call, table_column_info
14
+ from ..config import (
15
+ DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH,
16
+ DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH,
17
+ REPORT_RETRIEVAL_MINMAX_CONFIG_PATH,
18
+ REPORT_RETRIEVAL_VALUE_CONFIG_PATH,
19
+ REPORT_DATE_FILTER,
20
+ REPORT_PATCH_FLAG,
21
+ )
22
+ from ..instance import SNowInstance
23
+ from .utils.string import share_tri_gram
24
+
25
+ # XXX: Some notes on plot types
26
+ # - We currently don't support maps because they are clickable and would require a more evolved cheat function
27
+ SUPPORTED_PLOT_TYPES = ["area", "bar", "column", "line", "pie", "spline"]
28
+
29
+
30
+ class DashboardRetrievalTask(AbstractServiceNowTask, ABC):
31
+ """
32
+ A task to retrieve information from a ServiceNow dashboard
33
+
34
+ """
35
+
36
+ def __init__(self, seed: int, instance: SNowInstance = None, fixed_config: dict = None) -> None:
37
+ super().__init__(seed=seed, instance=instance, start_rel_url="")
38
+ self.iframe_id = "gsft_main"
39
+ self.fixed_config = fixed_config
40
+
41
+ @abstractmethod
42
+ def all_configs(self) -> List[dict]:
43
+ pass
44
+
45
+ def _get_charts(self, page: playwright.sync_api.Page) -> None:
46
+ """
47
+ Extract all charts on the page
48
+
49
+ Parameters:
50
+ -----------
51
+ page: playwright.sync_api.Page
52
+ The playright page on which the charts are to be extracted
53
+
54
+ Returns:
55
+ --------
56
+ charts: list
57
+ A list of charts where the chart is represented as a tuple of the chart title and the id of the
58
+ element that contains the chart.
59
+
60
+ """
61
+ iframe = page.frame(name=self.iframe_id)
62
+
63
+ charts = page.evaluate(
64
+ f"{self.iframe_id}.Highcharts.charts.map((x) => {{if(x){{return [x.renderTo.ariaLabel, x.renderTo.id];}}}})"
65
+ )
66
+ charts = [
67
+ (title.replace("Highcharts interactive chart.", "").replace(".", "").strip(), id)
68
+ for title, id in charts
69
+ if title
70
+ and iframe.locator(f"#{id}").count()
71
+ > 0 # Check if the element is actually on page (sometime rendering breaks)
72
+ ]
73
+
74
+ return charts
75
+
76
+ def _read_chart(self, page: playwright.sync_api.Page, element_id: str) -> str:
77
+ """
78
+ Read the chart at the specified index
79
+
80
+ Parameters:
81
+ -----------
82
+ page: playwright.sync_api.Page
83
+ The playright page on which the charts are to be extracted
84
+ element_id: str
85
+ The ID of the element that contains the chart
86
+
87
+ Returns:
88
+ --------
89
+ chart_type: str
90
+ The type of the chart
91
+ chart_data: dict
92
+ The data of the chart
93
+
94
+ """
95
+ self._wait_for_ready(page)
96
+
97
+ # Validate plot type
98
+ types = page.evaluate(
99
+ f"{self.iframe_id}.Highcharts.charts.find(chart => chart && chart.renderTo.id === '{element_id}').types"
100
+ )
101
+ if len(set(types)) > 1:
102
+ raise NotImplementedError("Multiple chart types in the same chart not supported")
103
+ type = types[0]
104
+ if type not in SUPPORTED_PLOT_TYPES:
105
+ raise NotImplementedError(f"Chart type {type} not supported")
106
+
107
+ # Get data
108
+ data = page.evaluate(
109
+ f"""
110
+ {self.iframe_id}.Highcharts.charts.find(chart => chart && chart.renderTo.id === "{element_id}")
111
+ .series.map(series => ({{
112
+ name: series.name,
113
+ data: series.data.map(
114
+ point => ({{
115
+ label_cat: point.category,
116
+ label_name: point.name,
117
+ label_origx: point.origXValue,
118
+ count: point.y,
119
+ percent: point.percent
120
+ }}))
121
+ }}));
122
+ """
123
+ )
124
+
125
+ # Post-process each series
126
+ for i in range(len(data)):
127
+ # For each data point in the series
128
+ for j in range(len(data[i]["data"])):
129
+ data_point = data[i]["data"][j]
130
+
131
+ # Remove None percent values when count is 0
132
+ if data_point["count"] == 0:
133
+ data_point["percent"] = 0
134
+
135
+ # Strip trailing spaces from labels
136
+ data_point["label_cat"] = (
137
+ data_point["label_cat"].strip() if data_point["label_cat"] else ""
138
+ )
139
+ data_point["label_name"] = (
140
+ data_point["label_name"].strip() if data_point["label_name"] else ""
141
+ )
142
+ data_point["label_origx"] = (
143
+ data_point["label_origx"].strip() if data_point["label_origx"] else ""
144
+ )
145
+
146
+ # Determine which label to use (this is a heuristic)
147
+ # Usually, when the origx value is present, it is more detailed than the other labels.
148
+ # However, in some rare cases, it corresponds to some strange value that doesn't get rendered.
149
+ # As a heuristic for the last point, let's just make sure that origx has at least a one trigram
150
+ # overlap with any of the other labels.
151
+ if data_point["label_origx"] != "" and any(
152
+ share_tri_gram(data_point["label_origx"], data_point[x])
153
+ for x in ["label_cat", "label_name"]
154
+ ):
155
+ data_point["label"] = data_point["label_origx"]
156
+ else:
157
+ if type in ["bar", "column", "spline"]:
158
+ data_point["label"] = data_point["label_cat"]
159
+ else:
160
+ data_point["label"] = data_point["label_name"]
161
+ del data_point["label_cat"]
162
+ del data_point["label_name"]
163
+ del data_point["label_origx"]
164
+
165
+ assert len(set([dp["label"] for dp in data[i]["data"]])) == len(
166
+ data[i]["data"]
167
+ ), "Detected duplicate labels in the same series"
168
+
169
+ return type, data
170
+
171
+ def _get_chart_by_title(
172
+ self, page: playwright.sync_api.Page, title: str = None
173
+ ) -> Tuple[str, dict]:
174
+ """
175
+ Get the chart data by title
176
+
177
+ Parameters:
178
+ -----------
179
+ page: playwright.sync_api.Page
180
+ The playright page on which the charts are to be extracted
181
+ title: str
182
+ The title of the chart to be read. If None, returns the first chart.
183
+
184
+ Returns:
185
+ --------
186
+ chart_type: str
187
+ The type of the chart
188
+ chart_data: dict
189
+ The data of the chart
190
+ element_id: str
191
+ The ID of the element that contains the chart
192
+
193
+ """
194
+ # Get chart titles and element IDs
195
+ charts = self._get_charts(page)
196
+
197
+ if not title:
198
+ title = charts[0][0]
199
+
200
+ # Find chart index by title
201
+ chart_idx = [title.lower() for title, _ in charts].index(title.lower())
202
+
203
+ # Load chart data
204
+ return *self._read_chart(page, element_id=charts[chart_idx][1]), charts[chart_idx][1]
205
+
206
+ def _wait_for_ready(self, page: playwright.sync_api.Page) -> None:
207
+ """
208
+ Wait for the page to be ready for task execution
209
+
210
+ Parameters:
211
+ -----------
212
+ page: playwright.sync_api.Page
213
+ The page to wait on
214
+
215
+ """
216
+ logging.debug(f"Waiting for {self.iframe_id} to be fully loaded")
217
+ page.wait_for_function(
218
+ f"typeof window.{self.iframe_id} !== 'undefined' && window.{self.iframe_id}.WORKARENA_LOAD_COMPLETE",
219
+ )
220
+ logging.debug(f"Detected {self.iframe_id} ready")
221
+
222
+ logging.debug("Waiting for Highcharts API to be available")
223
+ page.wait_for_function(f"window.{self.iframe_id}.Highcharts")
224
+ logging.debug("Detected Highcharts API ready")
225
+
226
+ logging.debug("Waiting for all plots to be loaded available")
227
+ page.wait_for_function(f"window.{self.iframe_id}.WORKARENA_HIGHCHARTS_ALL_LOADED")
228
+ logging.debug("All plots loaded")
229
+
230
+ def get_init_scripts(self) -> List[str]:
231
+ # Configure to page type
232
+ # ... extract URL suffix
233
+ url_suffix = parse.unquote(
234
+ parse.urlparse(self.config["url"].replace("%3F", "?")).path.split("/")[-1]
235
+ )
236
+
237
+ return super().get_init_scripts() + [
238
+ "registerGsftMainLoaded();",
239
+ f"""
240
+ async function renderAllCharts() {{
241
+ waLog('Forcing load of all charts', 'loadAllCharts');
242
+
243
+ await waitForCondition(() => window.WORKARENA_LOAD_COMPLETE, 100);
244
+
245
+ const canvas = window.SNC.canvas;
246
+ if (canvas) {{
247
+ waLog('This is a dashboard page.', 'loadAllCharts');
248
+ // Trigger the rendering of each widget
249
+ canvas.layoutJson.panes.forEach((p) => canvas.canvasUtils.renderSlowWidget(canvas.canvasUtils.getWidgetContainer(p.uuid)));
250
+ // Wait for all widgets to be rendered
251
+ await waitForCondition(() => window.SNC.canvas.layoutJson.panes.map((p) => p.isRendered).every(value => value == true), 100);
252
+ }}
253
+ else {{
254
+ waLog('This is a report page.', 'loadAllCharts');
255
+ // Wait for axes to be visible (we need to use this approach since there is no canvas to help us)
256
+ await waitForCondition(() => document.body.innerText.toLowerCase().includes("no data to display") || document.querySelectorAll(".highcharts-point").length > 0, 100);
257
+ }}
258
+
259
+ // Wait for Highcharts to say that the charts are rendered
260
+ waitForCondition(() => Highcharts.charts.all((c) => c.hasLoaded), 100)
261
+ .then(() => {{
262
+ window.WORKARENA_HIGHCHARTS_ALL_LOADED = true;
263
+ waLog('All charts loaded', 'loadAllCharts');
264
+ }});
265
+ }}
266
+ runInGsftMainOnlyAndProtectByURL(renderAllCharts, '{url_suffix}');
267
+ """,
268
+ ]
269
+
270
+ def setup_goal(self, page: playwright.sync_api.Page) -> Tuple[str | dict]:
271
+ super().setup_goal(page=page)
272
+
273
+ # Configure task
274
+ # ... sample a configuration
275
+ self.config = (
276
+ self.fixed_config if self.fixed_config else self.random.choice(self.all_configs())
277
+ )
278
+ # ... set start URL based on config
279
+ self.start_url = self.instance.snow_url + self.config["url"]
280
+
281
+ # Produce goal string based on question type
282
+ chart_locator = (
283
+ f"the \"{self.config['chart_series']}\" series of "
284
+ if self.config["chart_series"]
285
+ else ""
286
+ ) + (
287
+ f"the \"{self.config['chart_title']}\" chart"
288
+ if self.config["chart_title"]
289
+ else "the chart"
290
+ )
291
+ if self.config["question"].startswith("value"):
292
+ q_info = [x.strip() for x in self.config["question"].split(";")]
293
+ goal = f'What is the value of "{q_info[2]}" in {chart_locator} (in {q_info[1]})?'
294
+ elif self.config["question"] == "max":
295
+ goal = f"What is the maximum value in {chart_locator}? Give me both the label and the count. If there are many, pick one."
296
+ elif self.config["question"] == "min":
297
+ goal = f"What is the minimum value in {chart_locator}? Give me both the label and the count. If there are many, pick one."
298
+ else:
299
+ raise NotImplementedError(f"Question type {self.config['question']} not supported")
300
+
301
+ return goal, {}
302
+
303
+ def cheat(self, page: playwright.sync_api.Page, chat_messages: list[str]) -> None:
304
+ super().cheat(page, chat_messages)
305
+ self._wait_for_ready(page)
306
+
307
+ # Get the chart data
308
+ chart_type, chart_data, chart_element_id = self._get_chart_by_title(
309
+ page, self.config["chart_title"]
310
+ )
311
+
312
+ # Extract the series
313
+ if len(chart_data) == 1:
314
+ chart_data = chart_data[0]["data"]
315
+ else:
316
+ chart_data = [
317
+ series["data"]
318
+ for series in chart_data
319
+ if series["name"] == self.config["chart_series"]
320
+ ][0]
321
+
322
+ # Scroll to the chart
323
+ iframe = page.frame(name=self.iframe_id)
324
+ iframe.evaluate_handle(
325
+ f"findElementInShadowDOM('#{chart_element_id}')"
326
+ ).scroll_into_view_if_needed()
327
+
328
+ # Extract the value and add it to the chat
329
+ if self.config["question"].startswith("value"):
330
+ format = self.config["question"].split(";")[1].strip()
331
+ label = self.config["question"].split(";")[2].strip()
332
+ value = [
333
+ point["count" if format == "count" else "percent"]
334
+ for point in chart_data
335
+ if point["label"] == label
336
+ ][0]
337
+ chat_messages.append({"message": str(value), "role": "assistant"})
338
+ elif self.config["question"] == "max":
339
+ max_point = max(chart_data, key=lambda x: x["count"])
340
+ chat_messages.append(
341
+ {"message": f"{max_point['label']}, {max_point['count']}", "role": "assistant"}
342
+ )
343
+ elif self.config["question"] == "min":
344
+ min_point = min(chart_data, key=lambda x: x["count"])
345
+ chat_messages.append(
346
+ {"message": f"{min_point['label']}, {min_point['count']}", "role": "assistant"}
347
+ )
348
+ else:
349
+ raise NotImplementedError(f"Question type \"{self.config['question']}\" not supported")
350
+
351
+ def validate(
352
+ self, page: playwright.sync_api.Page, chat_messages: list[str]
353
+ ) -> Tuple[float, bool, str, dict]:
354
+ super().validate(page, chat_messages)
355
+ self._wait_for_ready(page)
356
+
357
+ # Get the chart data
358
+ _, chart_data, _ = self._get_chart_by_title(page, self.config["chart_title"])
359
+
360
+ # Extract the series
361
+ if len(chart_data) == 1:
362
+ chart_data = chart_data[0]["data"]
363
+ else:
364
+ chart_data = [
365
+ series["data"]
366
+ for series in chart_data
367
+ if series["name"] == self.config["chart_series"]
368
+ ][0]
369
+
370
+ # Extract the agent's response
371
+ if chat_messages and chat_messages[-1]["role"] == "assistant":
372
+ response = chat_messages[-1]["message"]
373
+ else:
374
+ return (
375
+ 0,
376
+ False,
377
+ "",
378
+ {"message": "The assistant did not provide an answer."},
379
+ )
380
+
381
+ # Extract all numbers mentioned by the agent
382
+ # ... some value labels may contain numbers so we need to remove the labels from the response first
383
+ labels = set([point["label"] for point in chart_data])
384
+ response_ = str(response)
385
+ for label in labels:
386
+ response_ = response_.replace(label, "")
387
+ # ... then we extract numbers
388
+ response_floats = np.unique(
389
+ [float(x) for x in re.findall(r"[\d]+(?:[.,]\d+)?", response_.replace(",", ""))]
390
+ )
391
+ del response_
392
+
393
+ # Validate the response
394
+ if self.config["question"].startswith("value"):
395
+ # if more than one number is in the prompt, there is necessarily a false positive
396
+ if len(response_floats) > 1:
397
+ error_msg = "Incorrect answer. More than one number detected in the response."
398
+ return 0.0, True, error_msg, {"message": error_msg}
399
+
400
+ format = self.config["question"].split(";")[1].strip()
401
+ label = self.config["question"].split(";")[2].strip()
402
+
403
+ expected_value = float(
404
+ [
405
+ point["count" if format == "count" else "percent"]
406
+ for point in chart_data
407
+ if point["label"] == label
408
+ ][0]
409
+ )
410
+ if np.isclose(expected_value, response_floats[0]):
411
+ return 1.0, True, "Nice work, thank you!", {"message": "Correct answer."}
412
+ else:
413
+ return 0.0, True, f"Incorrect answer.", {"message": "Incorrect answer."}
414
+
415
+ # ... validate max/min responses
416
+ elif "max" in self.config["question"] or "min" in self.config["question"]:
417
+ # Determine whether to find max or min based on configuration
418
+ target_func = max if self.config["question"] == "max" else min
419
+
420
+ # Get the target count value (max or min)
421
+ target_count = float(target_func(chart_data, key=lambda x: x["count"])["count"])
422
+
423
+ # Find all points with the target count value
424
+ target_points = [point for point in chart_data if point["count"] == target_count]
425
+
426
+ # if more than one number is in the prompt, there is necessarily a false positive
427
+ if len(response_floats) > 1:
428
+ error_msg = "Incorrect answer. More than one number detected in the response."
429
+ return 0.0, True, error_msg, {"message": error_msg}
430
+
431
+ # Check if any of these points are mentioned in the response
432
+ for point in target_points:
433
+ if point["label"].lower() in response.lower() and np.isclose(
434
+ target_count, response_floats[0]
435
+ ):
436
+ return 1.0, True, "Nice work, thank you!", {"message": "Correct answer."}
437
+
438
+ # If no correct point is mentioned in the response
439
+ return 0.0, True, "Incorrect answer.", {"message": "Incorrect answer."}
440
+
441
+ else:
442
+ raise NotImplementedError(f"Question type \"{self.config['question']}\" not supported")
443
+
444
+ def teardown(self) -> None:
445
+ return super().teardown()
446
+
447
+ def _generate_random_config(
448
+ self, page: playwright.sync_api.Page, is_report=True, question_types=["value"]
449
+ ) -> dict:
450
+ """
451
+ Generate a random configuration for the task
452
+
453
+ Parameters:
454
+ -----------
455
+ page: playwright.sync_api.Page
456
+ The page on which the task is to be executed
457
+ is_report: bool
458
+ Whether to sample a report or a dashboard task configuration
459
+ question_types: list
460
+ The types of questions to sample from (uniformely)
461
+
462
+ """
463
+ # Generate a bunch of reports based on valid table fields
464
+ ON_THE_FLY_REPORTS = []
465
+ for table in [
466
+ "alm_asset",
467
+ "alm_hardware",
468
+ "asmt_assessment_instance_question",
469
+ "asmt_m2m_stakeholder",
470
+ "ast_contract",
471
+ "change_request",
472
+ "cmdb_ci_computer",
473
+ "incident",
474
+ "sc_cat_item",
475
+ "sys_user",
476
+ ]:
477
+ cols = [
478
+ x
479
+ for x, y in table_column_info(instance=self.instance, table=table).items()
480
+ if y.get("cangroup", False)
481
+ and y.get("type", None) == "choice"
482
+ and "upon" not in x.lower()
483
+ ]
484
+ for col in cols:
485
+ ON_THE_FLY_REPORTS.append({"table": table, "field": col, "type": "pie"})
486
+ ON_THE_FLY_REPORTS.append({"table": table, "field": col, "type": "bar"})
487
+
488
+ # Reports that are already in the instance
489
+ system_report_tables = "alm_asset,alm_hardware,asmt_assessment_instance_question,asmt_m2m_stakeholder,ast_contract,change_request,cmdb_ci_computer"
490
+ SYSTEM_REPORTS = table_api_call(
491
+ instance=self.instance,
492
+ table="sys_report",
493
+ params={
494
+ "sysparm_query": f"sys_class_name=sys_report^active=true^typeINtrend,donut,vertical_bar,line,horizontal_bar,pie,bar,spline,area^descriptionLIKE{REPORT_PATCH_FLAG}^tableIN{system_report_tables}",
495
+ "sysparm_fields": "sys_id",
496
+ },
497
+ )["result"]
498
+
499
+ REPORTS = ON_THE_FLY_REPORTS + SYSTEM_REPORTS
500
+
501
+ # XXX: It's not ideal to use sys_ids but I couldn't find a better way
502
+ DASHBOARDS = [
503
+ "812fa4400f1130101527008c07767e1a", # Assessment overview
504
+ "fa5fe3e1773130107384c087cc5a99d5", # Asset overview
505
+ "68ee1f30770230107384c087cc5a992e", # Asset contract overview
506
+ "05b0a8b7c3123010a282a539e540dd69", # Change overview
507
+ "18b1f472533130104c90ddeeff7b12a6", # Incident overview
508
+ "287d07d1ff3130106c1ef9a7cddcbd5d", # Request overview
509
+ "7ab78953eb32011008f2951ff15228e6", # Service catalog overview
510
+ "2d297c880f1130101527008c07767e27", # Survey overview
511
+ "6b706f448f231110953ddffc9071a4f3", # Telemetry - Table growth
512
+ "15c5d2d377213010a435478c4f5a993c", # Usage overview
513
+ "85a57f9677100110ba155631dc5a9905", # Web api usage overview
514
+ "c38ca3a273031010ae8dd21efaf6a747", # Data classification
515
+ "3d48f669538223008329ddeeff7b1253", # Problem overview
516
+ ]
517
+
518
+ # Select between a full dashboard and a report
519
+ if is_report:
520
+ report = REPORTS[self.random.randint(0, len(REPORTS))]
521
+
522
+ # On the fly generated report
523
+ if not report.get("sys_id", None):
524
+ url = f"/now/nav/ui/classic/params/target/sys_report_template.do%3Fsysparm_field%3D{report['field']}%26sysparm_type%3D{report['type']}%26sysparm_table%3D{report['table']}%26sysparm_from_list%3Dtrue%26sysparm_chart_size%3Dlarge%26sysparm_manual_labor%3Dtrue%26sysparm_query=sys_created_on<javascript:gs.dateGenerate('{REPORT_DATE_FILTER}','00:00:00')^EQ"
525
+ # Report from the database
526
+ else:
527
+ url = f"/now/nav/ui/classic/params/target/sys_report_template.do%3Fjvar_report_id={report['sys_id']}"
528
+ else:
529
+ # Dashboard from the database
530
+ dashboard = self.random.choice(DASHBOARDS)
531
+ url = f"/now/nav/ui/classic/params/target/%24pa_dashboard.do%3Fsysparm_dashboard%3D{dashboard}"
532
+
533
+ # We need to do this to bypass the init script protection by URL
534
+ self.fixed_config = {
535
+ "url": url,
536
+ "chart_title": "",
537
+ "chart_series": "",
538
+ "question": "max",
539
+ } # Dummy config
540
+ self.setup(page=page)
541
+
542
+ # Handle the case where a dashboard is not found
543
+ page.wait_for_load_state("networkidle")
544
+ iframe = page.frame(name=self.iframe_id)
545
+ assert iframe.get_by_text("not found").count() == 0, "Report or dashboard not found"
546
+
547
+ # Find all the charts
548
+ self._wait_for_ready(page)
549
+ charts = self._get_charts(page)
550
+
551
+ # Randomly select a chart
552
+ assert len(charts) > 0, f"No charts found on the page {self.instance.snow_url}{url}"
553
+ chart_idx = self.random.randint(0, len(charts))
554
+ chart_title = charts[chart_idx][0] if not is_report else "" # No title for reports
555
+ _, chart_data, _ = self._get_chart_by_title(page, chart_title)
556
+
557
+ # Select a series randomly
558
+ series_idx = self.random.randint(len(chart_data))
559
+ chart_series = chart_data[series_idx]["name"] if len(chart_data) > 1 else ""
560
+ chart_data = chart_data[series_idx]["data"]
561
+
562
+ # Check if the data is interesting
563
+ labels = [point["label"] for point in chart_data]
564
+ assert len(labels) > 1, f"Not enough data in the chart (only {len(labels)} label)"
565
+ assert not any(
566
+ l.isdigit() for l in labels
567
+ ), "Some chart labels are digits, which would cause errors in validation. Skipping."
568
+
569
+ # Sample a type of question
570
+ question = self.random.choice(question_types)
571
+
572
+ if question == "value":
573
+ # Sample a random type of value to ask for
574
+ format = self.random.choice(["count", "percent"])
575
+
576
+ # Select a random label from the chart data
577
+ label = self.random.choice(labels)
578
+
579
+ return {
580
+ "url": url,
581
+ "chart_title": chart_title,
582
+ "chart_series": chart_series,
583
+ "question": f"{question}; {format}; {label}",
584
+ }
585
+ else:
586
+ return {
587
+ "url": url,
588
+ "chart_title": chart_title,
589
+ "chart_series": chart_series,
590
+ "question": question,
591
+ }
592
+
593
+
594
+ class DashboardValueRetrievalTask(DashboardRetrievalTask):
595
+ def all_configs(self):
596
+ return json.load(open(DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH, "r"))
597
+
598
+
599
+ class DashboardMinMaxRetrievalTask(DashboardRetrievalTask):
600
+ def all_configs(self):
601
+ return json.load(open(DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH, "r"))
602
+
603
+
604
+ class ReportValueRetrievalTask(DashboardRetrievalTask):
605
+ def all_configs(self):
606
+ return json.load(open(REPORT_RETRIEVAL_VALUE_CONFIG_PATH, "r"))
607
+
608
+
609
+ class ReportMinMaxRetrievalTask(DashboardRetrievalTask):
610
+ def all_configs(self):
611
+ return json.load(open(REPORT_RETRIEVAL_MINMAX_CONFIG_PATH, "r"))
612
+
613
+
614
+ __TASKS__ = [
615
+ var
616
+ for var in locals().values()
617
+ if isinstance(var, type)
618
+ and issubclass(var, DashboardRetrievalTask)
619
+ and var is not DashboardRetrievalTask
620
+ ]