diff --git a/.github/actions/failed-artifacts-and-slack-notifications/action.yml b/.github/actions/failed-artifacts-and-slack-notifications/action.yml index 716a8d686..16bfe4dd3 100644 --- a/.github/actions/failed-artifacts-and-slack-notifications/action.yml +++ b/.github/actions/failed-artifacts-and-slack-notifications/action.yml @@ -7,16 +7,17 @@ runs: - name: Copy failed screenshots shell: bash run: | - mkdir ${{ env.PROJECT_PATH }}failed_screenshots/ + mkdir ${{ env.PROJECT_PATH }}failed_screenshots cd ${{ env.PROJECT_PATH }} - cp *.png failed_screenshots + cp -r /tmp/dash_artifacts failed_screenshots + cp *.png failed_screenshots || true - name: Archive production artifacts uses: actions/upload-artifact@v4 with: name: Failed screenshots path: | - ${{ env.PROJECT_PATH }}failed_screenshots/*.png + ${{ env.PROJECT_PATH }}failed_screenshots - name: Send custom JSON data to Slack id: slack diff --git a/.github/workflows/test-e2e-vizro-dom-elements.yml b/.github/workflows/test-e2e-vizro-dom-elements.yml new file mode 100644 index 000000000..22ec97c3c --- /dev/null +++ b/.github/workflows/test-e2e-vizro-dom-elements.yml @@ -0,0 +1,47 @@ +name: e2e vizro dom elements tests for Vizro + +defaults: + run: + working-directory: vizro-core + +on: + push: + branches: [main] + pull_request: + branches: + - main + +env: + PYTHONUNBUFFERED: 1 + FORCE_COLOR: 1 + PYTHON_VERSION: "3.12" + +jobs: + test-e2e-vizro-dom-elements: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Hatch + run: pip install hatch + + - name: Show dependency tree + run: hatch run pip tree + + - name: Run e2e vizro-dom-elements tests + run: | + hatch run test-e2e-vizro-dom-elements + + - name: Create artifacts and slack notifications + if: failure() + uses: ./.github/actions/failed-artifacts-and-slack-notifications + env: + TESTS_NAME: Vizro e2e vizro dom elements tests + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + PROJECT_PATH: /home/runner/work/vizro/vizro/vizro-core/ diff --git a/vizro-core/hatch.toml b/vizro-core/hatch.toml index 3ea51049f..b31535182 100644 --- a/vizro-core/hatch.toml +++ b/vizro-core/hatch.toml @@ -22,6 +22,7 @@ dependencies = [ "coverage[toml]>=6.5", "pytest", "pytest-mock", + "pytest-rerunfailures", "freezegun>=1.5.0", "dash[testing]", "chromedriver-autoinstaller>=0.6.4", @@ -33,10 +34,14 @@ dependencies = [ "PyGithub", "imutils", "opencv-python", - "pyhamcrest" + "pyhamcrest", + "gunicorn" ] installer = "uv" +[envs.default.env-vars] +PYTHONPATH = "tests/e2e/vizro/dashboards/default:tests/tests_utils" + [envs.default.scripts] example = "hatch run examples:example {args:scratch_dev}" # shortcut script to underlying example environment script. lint = "pre-commit run {args} --all-files" @@ -63,7 +68,12 @@ templates-check = ["python src/vizro/_themes/generate_plotly_templates.py --chec # fix this, but we don't actually use `hatch run test` anywhere right now. # See comments added in https://github.com/mckinsey/vizro/pull/444. test = "pytest tests --headless {args}" -test-e2e-component-library = "pytest tests/e2e/test_component_library.py --headless {args}" +test-e2e-component-library = "pytest tests/e2e/component_library/test_component_library.py --headless {args}" +test-e2e-vizro-dom-elements = [ + "gunicorn dashboard:app -b 0.0.0.0:5002 -w 1 --timeout 90 &", + "tests/tests_utils/e2e/vizro/dashboards/wait-for-it.sh 127.0.0.1:5002 -t 30", + "pytest -vs --reruns 1 tests/e2e/vizro/test_dom_elements --headless {args}" +] test-integration = "pytest tests/integration --headless {args}" test-js = "./tools/run_jest.sh {args}" test-unit = "pytest tests/unit {args}" diff --git a/vizro-core/tests/e2e/test_component_library.py b/vizro-core/tests/e2e/component_library/test_component_library.py similarity index 98% rename from vizro-core/tests/e2e/test_component_library.py rename to vizro-core/tests/e2e/component_library/test_component_library.py index 2eb2ce538..6d0a36153 100644 --- a/vizro-core/tests/e2e/test_component_library.py +++ b/vizro-core/tests/e2e/component_library/test_component_library.py @@ -1,7 +1,7 @@ import dash_bootstrap_components as dbc import pandas as pd from dash import Dash, html -from e2e_asserts import assert_image_equal, make_screenshot_and_paths +from e2e.asserts import assert_image_equal, make_screenshot_and_paths from vizro.figures.library import kpi_card, kpi_card_reference diff --git a/vizro-core/tests/e2e/vizro/custom_components/custom_actions/custom_actions.py b/vizro-core/tests/e2e/vizro/custom_components/custom_actions/custom_actions.py new file mode 100644 index 000000000..a3b71d510 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/custom_components/custom_actions/custom_actions.py @@ -0,0 +1,11 @@ +from typing import Optional + +from vizro.models.types import capture + + +@capture("action") +def my_custom_action(click_data: Optional[dict] = None): + """Custom action.""" + if click_data: + return f'Scatter chart clicked data:\n### Species: "{click_data["points"][0]["customdata"][0]}"' + return "### No data clicked." diff --git a/vizro-core/tests/e2e/vizro/custom_components/custom_charts/bar_custom.py b/vizro-core/tests/e2e/vizro/custom_components/custom_charts/bar_custom.py new file mode 100644 index 000000000..39312f21e --- /dev/null +++ b/vizro-core/tests/e2e/vizro/custom_components/custom_charts/bar_custom.py @@ -0,0 +1,10 @@ +import vizro.plotly.express as px +from vizro.models.types import capture + + +@capture("graph") +def bar_with_highlight(data_frame, x, highlight_bar=None): + """Custom chart to test using DatePicker with Parameter.""" + fig = px.bar(data_frame=data_frame, x=x) + fig["data"][0]["marker"]["color"] = ["orange" if c == highlight_bar else "blue" for c in fig["data"][0]["x"]] + return fig diff --git a/vizro-core/tests/e2e/vizro/custom_components/custom_components/new_dropdown.py b/vizro-core/tests/e2e/vizro/custom_components/custom_components/new_dropdown.py new file mode 100644 index 000000000..7d4c21721 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/custom_components/custom_components/new_dropdown.py @@ -0,0 +1,57 @@ +from typing import Annotated, Literal, Optional, Union + +from dash import dcc, html +from pydantic import AfterValidator, Field, PlainSerializer, PrivateAttr + +import vizro.models as vm +from vizro.models import Action +from vizro.models._action._actions_chain import _action_validator_factory +from vizro.models._base import VizroBaseModel, _log_call + +# Case 2: Entirely new component (actually exists, but for ease of explanation chosen) +SingleOptionType = Union[bool, float, str] +MultiOptionType = Union[list[bool], list[float], list[str]] + + +class NewDropdown(VizroBaseModel): + """Categorical single/multi-selector `Dropdown` to be provided to `Filter`.""" + + type: Literal["new-dropdown"] = "new-dropdown" + options: Optional[MultiOptionType] = Field(default=None, description="Possible options the user can select from") + value: Optional[Union[SingleOptionType, MultiOptionType]] = Field( + default=None, description="Options that are selected by default" + ) + multi: bool = Field(default=True, description="Whether to allow selection of multiple values") + actions: Annotated[ + list[Action], + AfterValidator(_action_validator_factory("value")), + PlainSerializer(lambda x: x[0].actions), + Field(default=[]), + ] + title: Optional[str] = Field(None, description="Title to be displayed") + + # Component properties for actions and interactions + _input_property: str = PrivateAttr("value") + + @_log_call + def build(self): + full_options = self.options + + return html.Div( + [ + html.P(self.title) if self.title else None, + dcc.Dropdown( + id=self.id, + options=full_options, + value=self.value or full_options[0], + multi=self.multi, + persistence=True, + clearable=False, + ), + ], + ) + + +# Important: Add new components to expected type - here the selector of the parent components +vm.Filter.add_type("selector", NewDropdown) +vm.Parameter.add_type("selector", NewDropdown) diff --git a/vizro-core/tests/e2e/vizro/custom_components/custom_components/range_slider_non_cross.py b/vizro-core/tests/e2e/vizro/custom_components/custom_components/range_slider_non_cross.py new file mode 100644 index 000000000..5faf32880 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/custom_components/custom_components/range_slider_non_cross.py @@ -0,0 +1,24 @@ +from typing import Literal + +import vizro.models as vm + + +# Case 1: Based on existing component +class RangeSliderNonCross(vm.RangeSlider): + """Custom numeric multi-selector `RangeSliderNonCross` to be provided to `Filter`.""" + + type: Literal["other_range_slider"] = "other_range_slider" + + def build(self): + range_slider_build_obj = super().build() + range_slider_build_obj[self.id].allowCross = False + range_slider_build_obj[self.id].tooltip = { + "always_visible": True, + "placement": "bottom", + } + return range_slider_build_obj + + +# Important: Add new components to expected type - here the selector of the parent components +vm.Filter.add_type("selector", RangeSliderNonCross) +vm.Parameter.add_type("selector", RangeSliderNonCross) diff --git a/vizro-core/tests/e2e/vizro/dashboards/assets/css/typography.css b/vizro-core/tests/e2e/vizro/dashboards/assets/css/typography.css new file mode 100644 index 000000000..93358e790 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/assets/css/typography.css @@ -0,0 +1,6 @@ +h1, +h2, +.heading-1-400, +.heading-2-400 { + color: hotpink; +} diff --git a/vizro-core/tests/e2e/vizro/dashboards/assets/favicon.ico b/vizro-core/tests/e2e/vizro/dashboards/assets/favicon.ico new file mode 100644 index 000000000..e61cdb6d9 Binary files /dev/null and b/vizro-core/tests/e2e/vizro/dashboards/assets/favicon.ico differ diff --git a/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/collections.svg b/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/collections.svg new file mode 100644 index 000000000..62333018e --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/collections.svg @@ -0,0 +1,3 @@ + + + diff --git a/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/features.svg b/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/features.svg new file mode 100644 index 000000000..645c58733 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/features.svg @@ -0,0 +1,3 @@ + + + diff --git a/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/hypotheses.svg b/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/hypotheses.svg new file mode 100644 index 000000000..505d7c7cd --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/assets/images/icons/content/hypotheses.svg @@ -0,0 +1,3 @@ + + + diff --git a/vizro-core/tests/e2e/vizro/dashboards/assets/logo.jpeg b/vizro-core/tests/e2e/vizro/dashboards/assets/logo.jpeg new file mode 100644 index 000000000..3a22f8f51 Binary files /dev/null and b/vizro-core/tests/e2e/vizro/dashboards/assets/logo.jpeg differ diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/dashboard.py b/vizro-core/tests/e2e/vizro/dashboards/default/dashboard.py new file mode 100644 index 000000000..00f76b2f0 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/dashboard.py @@ -0,0 +1,33 @@ +import e2e.vizro.constants as cnst +from pages.ag_grid_page import ag_grid_page +from pages.datepicker_page import datepicker_page +from pages.filters_page import filters_page +from pages.homepage import homepage +from pages.kpi_indicators_page import kpi_indicators_page +from pages.parameters_page import parameters_page + +import vizro.models as vm +from vizro import Vizro + +dashboard = vm.Dashboard( + title="Vizro dashboard for integration testing", + pages=[homepage, filters_page, parameters_page, kpi_indicators_page, datepicker_page, ag_grid_page], + navigation=vm.Navigation( + pages={ + cnst.GENERAL_ACCORDION: [ + cnst.HOME_PAGE_ID, + cnst.FILTERS_PAGE, + cnst.PARAMETERS_PAGE, + cnst.KPI_INDICATORS_PAGE, + ], + cnst.DATEPICKER_ACCORDION: [cnst.DATEPICKER_PAGE], + cnst.AG_GRID_ACCORDION: [cnst.TABLE_AG_GRID_PAGE], + } + ), + theme="vizro_light", +) + +app = Vizro(assets_folder="../assets").build(dashboard) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/pages/ag_grid_page.py b/vizro-core/tests/e2e/vizro/dashboards/default/pages/ag_grid_page.py new file mode 100644 index 000000000..118aab5b1 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/pages/ag_grid_page.py @@ -0,0 +1,51 @@ +import e2e.vizro.constants as cnst + +import vizro.models as vm +import vizro.plotly.express as px +from vizro.tables import dash_ag_grid + +gapminder = px.data.gapminder() + +ag_grid_page = vm.Page( + title=cnst.TABLE_AG_GRID_PAGE, + components=[ + vm.Container( + id=cnst.TABLE_AG_GRID_CONTAINER, + title=cnst.TABLE_AG_GRID_CONTAINER, + layout=vm.Layout(grid=[[0, 1]], col_gap="0px"), + components=[ + vm.AgGrid( + id=cnst.TABLE_AG_GRID_ID, + title="Equal Title One", + figure=dash_ag_grid(data_frame=gapminder, dashGridOptions={"pagination": True}), + ), + vm.Graph( + id=cnst.BOX_AG_GRID_PAGE_ID, + figure=px.box(gapminder, x="continent", y="lifeExp", title="Equal Title One"), + ), + ], + ) + ], + controls=[ + vm.Filter( + column="year", + targets=[cnst.TABLE_AG_GRID_ID], + selector=vm.Dropdown(value=2007), + ), + vm.Filter( + column="continent", + targets=[cnst.TABLE_AG_GRID_ID], + selector=vm.RadioItems(options=["Europe", "Africa", "Americas"]), + ), + vm.Filter( + column="continent", + targets=[cnst.TABLE_AG_GRID_ID], + selector=vm.Checklist(options=["Asia", "Oceania"]), + ), + vm.Filter( + column="pop", + targets=[cnst.TABLE_AG_GRID_ID], + selector=vm.RangeSlider(step=1000000.0, min=1000000, max=10000000), + ), + ], +) diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/pages/datepicker_page.py b/vizro-core/tests/e2e/vizro/dashboards/default/pages/datepicker_page.py new file mode 100644 index 000000000..743026ecd --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/pages/datepicker_page.py @@ -0,0 +1,141 @@ +import e2e.vizro.constants as cnst +import pandas as pd +from flask_caching import Cache + +import vizro.models as vm +import vizro.plotly.express as px +from vizro.managers import data_manager +from vizro.tables import dash_data_table + +datepicker_df = pd.DataFrame( + [ + [ + "2016-05-16 20:42:31", + "Male", + 35, + "$30,000 to $39,999", + "Employed for wages", + "mechanical drafter", + "Associate degree", + None, + ], + [ + "2016-05-16", + "Male", + 21, + "$1 to $10,000", + "Out of work and looking for work", + "-", + "Some college, no degree", + "join clubs/socual clubs/meet ups", + ], + [ + "2016-05-17", + "Male", + 22, + "$0", + "Out of work but not currently looking for work", + "unemployed, Some college", + "no degree", + "Other exercise", + ], + [ + "2016-05-18", + "Male", + 19, + "$1 to $10,000", + "A student", + "student", + "Some college, no degree", + "Joined a gym/go to the gym", + ], + [ + "2016-05-18", + "Male", + 23, + "$30,000 to $39,999", + "Employed for wages", + "Factory worker", + "High school graduate, diploma or the equivalent (for example: GED)", + None, + ], + [ + "2016-05-19", + "Male", + 23, + "$30,000 to $39,999", + "Employed for wages", + "Factory worker", + "High school graduate, diploma or the equivalent (for example: GED)", + None, + ], + ], + columns=["time", "gender", "age", "income", "employment", "job_title", "edu_level", "improve_yourself_how"], +) + + +def load_datepicker_data(): + datepicker_df["time"] = pd.to_datetime(datepicker_df["time"], format="mixed") + return datepicker_df + + +data_manager.cache = Cache(config={"CACHE_TYPE": "FileSystemCache", "CACHE_DIR": "cache"}) +data_manager["datepicker_df"] = load_datepicker_data +data_manager["datepicker_df"].timeout = 10 + + +datepicker_page = vm.Page( + title=cnst.DATEPICKER_PAGE, + id=cnst.DATEPICKER_PAGE, + layout=vm.Layout(grid=[[0, 1], [0, 1], [2, 3], [2, 3]]), + components=[ + vm.Graph( + id=cnst.BAR_POP_RANGE_ID, + figure=px.bar( + "datepicker_df", + x="time", + y="age", + color="age", + ), + ), + vm.Graph( + id=cnst.BAR_POP_DATE_ID, + figure=px.bar( + "datepicker_df", + x="time", + y="age", + color="age", + ), + ), + vm.Table( + id=cnst.TABLE_POP_RANGE_ID, + title="Table Pop Range", + figure=dash_data_table( + data_frame="datepicker_df", + ), + ), + vm.Table( + id=cnst.TABLE_POP_DATE_ID, + title="Table Pop Date", + figure=dash_data_table( + data_frame="datepicker_df", + ), + ), + ], + controls=[ + vm.Filter( + column="time", + selector=vm.DatePicker( + title="Pick a date range", + value=["2016-05-16", "2016-05-19"], + max="2016-06-01", + ), + targets=[cnst.TABLE_POP_RANGE_ID, cnst.BAR_POP_RANGE_ID], + ), + vm.Filter( + column="time", + selector=vm.DatePicker(title="Pick a date", range=False), + targets=[cnst.TABLE_POP_DATE_ID, cnst.BAR_POP_DATE_ID], + ), + ], +) diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/pages/filters_page.py b/vizro-core/tests/e2e/vizro/dashboards/default/pages/filters_page.py new file mode 100644 index 000000000..702f787e4 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/pages/filters_page.py @@ -0,0 +1,103 @@ +import e2e.vizro.constants as cnst + +import vizro.models as vm +import vizro.plotly.express as px +from vizro.actions import export_data + +iris = px.data.iris() + +filters_page = vm.Page( + title=cnst.FILTERS_PAGE, + components=[ + vm.Tabs( + tabs=[ + vm.Container( + id=cnst.FILTERS_TAB_CONTAINER, + title=cnst.FILTERS_TAB_CONTAINER, + components=[ + vm.Container( + id=cnst.FILTERS_COMPONENTS_CONTAINER, + title=cnst.FILTERS_COMPONENTS_CONTAINER, + layout=vm.Layout(grid=[[0, 1], [0, 1], [0, 2]]), + components=[ + vm.Graph( + id=cnst.SCATTER_GRAPH_ID, + figure=px.scatter( + iris, + x="sepal_length", + y="petal_width", + color="sepal_width", + ), + ), + vm.Card( + text=""" + ![icon-top](assets/images/icons/content/features.svg) + + Leads to the home page on click. + """, + href="/", + ), + vm.Button( + text="Export data", + actions=[ + vm.Action( + function=export_data( + targets=[cnst.SCATTER_GRAPH_ID], + file_format="csv", + ) + ), + vm.Action( + function=export_data( + targets=[cnst.SCATTER_GRAPH_ID], + file_format="xlsx", + ) + ), + ], + ), + ], + ) + ], + ) + ] + ), + vm.Graph( + id=cnst.BOX_GRAPH_ID, + figure=px.box( + iris, + x="sepal_length", + y="petal_width", + color="sepal_width", + ), + ), + ], + controls=[ + vm.Filter( + column="species", + targets=[cnst.SCATTER_GRAPH_ID, cnst.BOX_GRAPH_ID], + ), + vm.Filter( + column="species", + targets=[cnst.SCATTER_GRAPH_ID, cnst.BOX_GRAPH_ID], + selector=vm.RadioItems( + id=cnst.RADIO_ITEMS_FILTER_FILTERS_PAGE, options=["setosa", "versicolor", "virginica"] + ), + ), + vm.Filter( + column="species", + targets=[cnst.SCATTER_GRAPH_ID], + selector=vm.Checklist( + id=cnst.CHECK_LIST_FILTER_FILTERS_PAGE, options=["setosa", "versicolor", "virginica"] + ), + ), + vm.Filter( + column="petal_width", + targets=[cnst.SCATTER_GRAPH_ID], + selector=vm.Slider(id=cnst.SLIDER_FILTER_FILTERS_PAGE, step=0.5), + ), + vm.Filter( + column="sepal_length", + targets=[cnst.SCATTER_GRAPH_ID, cnst.BOX_GRAPH_ID], + selector=vm.RangeSlider(id=cnst.RANGE_SLIDER_FILTER_FILTERS_PAGE, step=1.0), + ), + ], +) diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/pages/homepage.py b/vizro-core/tests/e2e/vizro/dashboards/default/pages/homepage.py new file mode 100644 index 000000000..dadbcebad --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/pages/homepage.py @@ -0,0 +1,69 @@ +import e2e.vizro.constants as cnst + +import vizro.models as vm +import vizro.plotly.express as px + +iris = px.data.iris() + +homepage = vm.Page( + title=cnst.HOME_PAGE, + id=cnst.HOME_PAGE_ID, + layout=vm.Layout(grid=[[0, 4], [1, 4], [2, 4], [3, 4]]), + components=[ + vm.Card( + text=""" + # This is an

tag + ## This is an

tag + ###### This is an

tag + \n + > + > Block quotes are used to highlight text. + > + \n + * Item 1 + * Item 2 + \n + *This text will be italic* + _This will also be italic_ + **This text will be bold** + _You **can** combine them_ + """, + ), + vm.Card( + text=""" + ![icon-top](assets/images/icons/content/hypotheses.svg) + + Leads to the filters page on click. + """, + href=cnst.FILTERS_PAGE_PATH, + ), + vm.Card( + text=""" + ![icon-top](assets/images/icons/content/features.svg) + + Leads to the datepicker page on click. + """, + href=cnst.DATEPICKER_PAGE_PATH, + ), + vm.Card( + text=""" + ![icon-top](assets/images/icons/content/hypotheses.svg) + + Leads to the 404 page on click. + """, + href=cnst.PAGE_404_PATH, + ), + vm.Graph( + id=cnst.AREA_GRAPH_ID, + figure=px.area( + iris, + x="sepal_length", + y="petal_width", + color="sepal_width", + ), + ), + ], + controls=[ + vm.Filter(column="species", targets=[cnst.AREA_GRAPH_ID]), + ], +) diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/pages/kpi_indicators_page.py b/vizro-core/tests/e2e/vizro/dashboards/default/pages/kpi_indicators_page.py new file mode 100644 index 000000000..e5de177bf --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/pages/kpi_indicators_page.py @@ -0,0 +1,103 @@ +import e2e.vizro.constants as cnst +import pandas as pd + +import vizro.models as vm +from vizro.figures import kpi_card, kpi_card_reference + +kpi_df = pd.DataFrame( + [[67434, 65553, "A"], [6434, 6553, "B"], [34, 53, "C"]], + columns=["Actual", "Reference", "Category"], +) + +kpi_indicators_page = vm.Page( + title=cnst.KPI_INDICATORS_PAGE, + layout=vm.Layout(grid=[[0, 1, 2], [3, 4, 5], [6, 7, 8]]), + components=[ + # Style 1: Value Only + vm.Figure( + figure=kpi_card( + data_frame=kpi_df, + value_column="Actual", + title="Value I", + agg_func="sum", + ) + ), + vm.Figure( + figure=kpi_card( + data_frame=kpi_df, + value_column="Actual", + title="Value II", + agg_func="mean", + ) + ), + vm.Figure( + figure=kpi_card( + data_frame=kpi_df, + value_column="Actual", + title="Value III", + agg_func="median", + ) + ), + # Style 2: Value and reference value + vm.Figure( + figure=kpi_card_reference( + data_frame=kpi_df, + value_column="Reference", + reference_column="Actual", + title="Ref. Value II", + agg_func="sum", + ) + ), + vm.Figure( + figure=kpi_card_reference( + data_frame=kpi_df, + value_column="Actual", + reference_column="Reference", + title="Ref. Value I", + agg_func="sum", + ) + ), + vm.Figure( + id="kpi-card-reverse-coloring", + figure=kpi_card_reference( + data_frame=kpi_df, + value_column="Actual", + reference_column="Reference", + title="Ref. Value III", + agg_func="median", + icon="shopping_cart", + ), + ), + # Style 3: Value and icon + vm.Figure( + figure=kpi_card( + data_frame=kpi_df, + value_column="Actual", + icon="shopping_cart", + title="Icon I", + agg_func="sum", + value_format="${value:.2f}", + ) + ), + vm.Figure( + figure=kpi_card( + data_frame=kpi_df, + value_column="Actual", + icon="payment", + title="Icon II", + agg_func="mean", + value_format="{value:.0f}€", + ) + ), + vm.Figure( + figure=kpi_card( + data_frame=kpi_df, + value_column="Actual", + icon="monitoring", + title="Icon III", + agg_func="median", + ) + ), + ], + controls=[vm.Filter(column="Category")], +) diff --git a/vizro-core/tests/e2e/vizro/dashboards/default/pages/parameters_page.py b/vizro-core/tests/e2e/vizro/dashboards/default/pages/parameters_page.py new file mode 100644 index 000000000..995e61bc5 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/dashboards/default/pages/parameters_page.py @@ -0,0 +1,111 @@ +import e2e.vizro.constants as cnst + +import vizro.models as vm +import vizro.plotly.express as px + +iris = px.data.iris() + +parameters_page = vm.Page( + title=cnst.PARAMETERS_PAGE, + path=cnst.PARAMETERS_PAGE_PATH, + components=[ + vm.Tabs( + tabs=[ + vm.Container( + id=cnst.PARAMETERS_TAB_CONTAINER, + title=cnst.PARAMETERS_TAB_CONTAINER, + components=[ + vm.Tabs( + id=cnst.PARAMETERS_SUB_TAB_ID, + tabs=[ + vm.Container( + id=cnst.PARAMETERS_SUB_TAB_CONTAINER_ONE, + title=cnst.PARAMETERS_SUB_TAB_CONTAINER_ONE, + components=[ + vm.Graph( + id=cnst.BAR_GRAPH_ID, + figure=px.bar( + iris, + x="sepal_length", + y="petal_width", + color="species", + color_discrete_map={ + "setosa": "black", + "versicolor": "pink", + }, + ), + ) + ], + ), + vm.Container( + id=cnst.PARAMETERS_SUB_TAB_CONTAINER_TWO, + title=cnst.PARAMETERS_SUB_TAB_CONTAINER_TWO, + components=[ + vm.Graph( + id=cnst.HISTOGRAM_GRAPH_ID, + figure=px.histogram( + iris, + x="sepal_length", + y="petal_width", + color="species", + color_discrete_map={ + "setosa": "black", + "versicolor": "pink", + }, + ), + ), + ], + ), + ], + ) + ], + ) + ] + ), + vm.Container( + id=cnst.PARAMETERS_CONTAINER, + title=cnst.PARAMETERS_CONTAINER, + components=[ + vm.Graph( + id=cnst.BAR_GRAPH_ID_CONTAINER, + figure=px.bar( + iris, + x="sepal_length", + y="petal_width", + color="species", + color_discrete_map={ + "setosa": "black", + "versicolor": "pink", + }, + ), + ), + ], + ), + ], + controls=[ + vm.Parameter( + targets=[f"{cnst.HISTOGRAM_GRAPH_ID}.color_discrete_map.setosa"], + selector=vm.Dropdown(options=["NONE", "red", "blue"], multi=False, value="blue"), + ), + vm.Parameter( + targets=[f"{cnst.BAR_GRAPH_ID}.color_discrete_map.virginica"], + selector=vm.Dropdown(options=["NONE", "red", "blue"], multi=False, value="blue"), + ), + vm.Parameter( + targets=[f"{cnst.BAR_GRAPH_ID}.title", f"{cnst.HISTOGRAM_GRAPH_ID}.title"], + selector=vm.RadioItems(options=["red", "blue"], value="blue"), + ), + vm.Parameter( + targets=[f"{cnst.BAR_GRAPH_ID}.y"], + selector=vm.RadioItems(options=["petal_width", "petal_length"], value="petal_width"), + ), + vm.Parameter( + targets=[f"{cnst.BAR_GRAPH_ID}.opacity"], + selector=vm.Slider(min=0, max=1, value=0.2, step=0.2, title="Bubble opacity"), + ), + vm.Parameter( + targets=[f"{cnst.HISTOGRAM_GRAPH_ID}.range_x"], + selector=vm.RangeSlider(min=4, max=8, step=1.0, title="Range X Histogram"), + ), + ], +) diff --git a/vizro-core/tests/e2e/vizro/test_dom_elements/conftest.py b/vizro-core/tests/e2e/vizro/test_dom_elements/conftest.py new file mode 100644 index 000000000..42588e888 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/test_dom_elements/conftest.py @@ -0,0 +1,46 @@ +import e2e.vizro.constants as cnst +import pytest +from e2e.vizro.checkers import browser_console_warnings_checker +from selenium.common import WebDriverException +from selenium.webdriver.chrome.options import Options as ChromeOptions + + +# dash_br_driver options hook +def pytest_setup_options(): + options = ChromeOptions() + options.add_argument("--headless") + options.add_argument("--window-size=1920,1080") + return options + + +def make_teardown(dash_br): + # checking for browser console errors + try: + log_levels = [level for level in dash_br.get_logs() if level["level"] == "SEVERE" or "WARNING"] + if log_levels: + for log_level in log_levels: + browser_console_warnings_checker(log_level, log_levels) + except WebDriverException: + pass + + +@pytest.fixture(autouse=True) +def dash_br_driver(dash_br, request): + """Built-in driver from the dash library.""" + port = request.param.get("port", cnst.DEFAULT_PORT) if hasattr(request, "param") else cnst.DEFAULT_PORT + path = request.param.get("path", "") if hasattr(request, "param") else "" + dash_br.server_url = f"http://127.0.0.1:{port}/{path}" + + +@pytest.fixture(autouse=True) +def teardown_method(request): + """Fixture checks log errors and quits the driver after each test.""" + yield + for driver_name in [ + "dash_br", + # TODO: uncomment for the full scope of tests + # "driver", + # "chromedriver_second_browser", + ]: + if (driver := request.node.funcargs.get(driver_name)) is not None: + make_teardown(driver) diff --git a/vizro-core/tests/e2e/vizro/test_dom_elements/test_charts.py b/vizro-core/tests/e2e/vizro/test_dom_elements/test_charts.py new file mode 100644 index 000000000..229281d1f --- /dev/null +++ b/vizro-core/tests/e2e/vizro/test_dom_elements/test_charts.py @@ -0,0 +1,7 @@ +import e2e.vizro.constants as cnst + + +def test_modebar(dash_br): + """Check that modebar element exist for the chart.""" + dash_br.multiple_click(f"a[href='{cnst.FILTERS_PAGE_PATH}']", 1) + dash_br.wait_for_element(".modebar-container") diff --git a/vizro-core/tests/e2e/vizro/test_dom_elements/test_filters.py b/vizro-core/tests/e2e/vizro/test_dom_elements/test_filters.py new file mode 100644 index 000000000..72483c16a --- /dev/null +++ b/vizro-core/tests/e2e/vizro/test_dom_elements/test_filters.py @@ -0,0 +1,92 @@ +import e2e.vizro.constants as cnst +import pytest +from e2e.vizro.checkers import check_graph_is_loading, check_slider_value +from e2e.vizro.navigation import page_select, select_dropdown_value +from e2e.vizro.paths import categorical_components_value_path, slider_value_path +from e2e.vizro.waiters import graph_load_waiter +from hamcrest import assert_that, equal_to + + +def test_dropdown(dash_br): + page_select(dash_br, page_path=cnst.FILTERS_PAGE_PATH, page_name=cnst.FILTERS_PAGE, graph_id=cnst.SCATTER_GRAPH_ID) + select_dropdown_value(dash_br, value=2) + check_graph_is_loading(dash_br, graph_id=cnst.SCATTER_GRAPH_ID) + + +@pytest.mark.parametrize( + "filter_id", + [cnst.CHECK_LIST_FILTER_FILTERS_PAGE, cnst.RADIO_ITEMS_FILTER_FILTERS_PAGE], + ids=["checklist", "radio_items"], +) +def test_categorical_filters(dash_br, filter_id): + page_select(dash_br, page_path=cnst.FILTERS_PAGE_PATH, page_name=cnst.FILTERS_PAGE, graph_id=cnst.SCATTER_GRAPH_ID) + dash_br.multiple_click(categorical_components_value_path(elem_id=filter_id, value=2), 1) + check_graph_is_loading(dash_br, graph_id=cnst.SCATTER_GRAPH_ID) + + +def test_slider(dash_br): + page_select(dash_br, page_path=cnst.FILTERS_PAGE_PATH, page_name=cnst.FILTERS_PAGE, graph_id=cnst.SCATTER_GRAPH_ID) + dash_br.multiple_click(slider_value_path(elem_id=cnst.SLIDER_FILTER_FILTERS_PAGE, value=2), 1) + check_graph_is_loading(dash_br, graph_id=cnst.SCATTER_GRAPH_ID) + check_slider_value(dash_br, expected_end_value="0.6", elem_id=cnst.SLIDER_FILTER_FILTERS_PAGE) + + +@pytest.mark.xfail(reason="Should be fixed later in vizro by Petar") +# Right now is failing with the next error: +# AssertionError: Element number is '4', but expected number is '4.3' +def test_range_slider(dash_br): + page_select(dash_br, page_path=cnst.FILTERS_PAGE_PATH, page_name=cnst.FILTERS_PAGE, graph_id=cnst.SCATTER_GRAPH_ID) + dash_br.multiple_click(slider_value_path(elem_id=cnst.RANGE_SLIDER_FILTER_FILTERS_PAGE, value=4), 1) + check_graph_is_loading(dash_br, graph_id=cnst.SCATTER_GRAPH_ID) + check_slider_value( + dash_br, elem_id=cnst.RANGE_SLIDER_FILTER_FILTERS_PAGE, expected_start_value="4.3", expected_end_value="7" + ) + + +def test_dropdown_homepage(dash_br): + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) + select_dropdown_value(dash_br, value=2) + check_graph_is_loading(dash_br, cnst.AREA_GRAPH_ID) + + +def test_dropdown_kpi_indicators_page(dash_br): + page_select(dash_br, page_path=cnst.KPI_INDICATORS_PAGE_PATH, page_name=cnst.KPI_INDICATORS_PAGE) + dash_br.wait_for_text_to_equal(".card-body", "73902") + values = dash_br.find_elements(".card-body") + values_text = [value.text for value in values] + assert_that( + actual_or_assertion=values_text, + matcher=equal_to( + [ + "73902", + "24634.0", + "6434.0", + "72159", + "73902", + "6434.0", + "$73902.00", + "24634€", + "6434.0", + ] + ), + ) + select_dropdown_value(dash_br, value=2) + dash_br.wait_for_text_to_equal(".card-body", "67434") + values = dash_br.find_elements(".card-body") + values_text = [value.text for value in values] + assert_that( + actual_or_assertion=values_text, + matcher=equal_to( + [ + "67434", + "67434.0", + "67434.0", + "65553", + "67434", + "67434.0", + "$67434.00", + "67434€", + "67434.0", + ] + ), + ) diff --git a/vizro-core/tests/e2e/vizro/test_dom_elements/test_pages.py b/vizro-core/tests/e2e/vizro/test_dom_elements/test_pages.py new file mode 100644 index 000000000..4395f0b4d --- /dev/null +++ b/vizro-core/tests/e2e/vizro/test_dom_elements/test_pages.py @@ -0,0 +1,42 @@ +import e2e.vizro.constants as cnst +import pytest +from e2e.vizro.checkers import check_accordion_active +from e2e.vizro.paths import nav_card_link_path, page_title_path +from e2e.vizro.waiters import graph_load_waiter + + +def test_pages(dash_br): + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) + dash_br.wait_for_text_to_equal(page_title_path(), cnst.HOME_PAGE) + dash_br.multiple_click(nav_card_link_path(href=cnst.FILTERS_PAGE_PATH), 1) + graph_load_waiter(dash_br, graph_id=cnst.SCATTER_GRAPH_ID) + dash_br.wait_for_text_to_equal(page_title_path(), text=cnst.FILTERS_PAGE) + dash_br.multiple_click(nav_card_link_path(href=cnst.HOME_PAGE_PATH), 1) + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) + dash_br.wait_for_text_to_equal(page_title_path(), cnst.HOME_PAGE) + + +def test_active_accordion(dash_br): + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) + dash_br.multiple_click(nav_card_link_path(href=cnst.DATEPICKER_PAGE_PATH), 1) + graph_load_waiter(dash_br, graph_id=cnst.BAR_POP_DATE_ID) + dash_br.wait_for_text_to_equal(page_title_path(), cnst.DATEPICKER_PAGE) + check_accordion_active(dash_br, accordion_name=cnst.DATEPICKER_ACCORDION.upper()) + + +def test_404_page(dash_br): + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) + dash_br.multiple_click(nav_card_link_path(href=cnst.PAGE_404_PATH), 1) + dash_br.wait_for_text_to_equal("a[class='mt-4 btn btn-primary']", "Take me home") + dash_br.multiple_click("a[class='mt-4 btn btn-primary']", 1) + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) + dash_br.wait_for_text_to_equal(page_title_path(), cnst.HOME_PAGE) + + +@pytest.mark.parametrize( + "dash_br", + [{"port": cnst.DEFAULT_PORT, "path": "?unexpercted_param=parampampam"}], + indirect=["dash_br"], +) +def test_unexpected_query_parameters_page(dash_br): + graph_load_waiter(dash_br, graph_id=cnst.AREA_GRAPH_ID) diff --git a/vizro-core/tests/e2e/vizro/test_dom_elements/test_themes.py b/vizro-core/tests/e2e/vizro/test_dom_elements/test_themes.py new file mode 100644 index 000000000..2282321c0 --- /dev/null +++ b/vizro-core/tests/e2e/vizro/test_dom_elements/test_themes.py @@ -0,0 +1,106 @@ +import e2e.vizro.constants as cnst +import pytest +from e2e.vizro.checkers import ( + check_ag_grid_theme_color, + check_graph_color, + check_theme_color, +) +from e2e.vizro.navigation import accordion_select, page_select +from e2e.vizro.paths import tab_path, theme_toggle_path + + +@pytest.mark.parametrize( + "dash_br, dashboard_id", + [({"port": cnst.DEFAULT_PORT}, cnst.DASHBOARD_DEFAULT)], + ids=[cnst.DASHBOARD_DEFAULT], + indirect=["dash_br"], +) +def test_themes(dash_br, dashboard_id): + page_select(dash_br, page_path=cnst.FILTERS_PAGE_PATH, page_name=cnst.FILTERS_PAGE, graph_id=cnst.SCATTER_GRAPH_ID) + if dashboard_id == cnst.DASHBOARD_DEFAULT: + check_graph_color(dash_br, style_background=cnst.STYLE_GREY, color=cnst.HEX_GREY) + check_theme_color(dash_br, color=cnst.THEME_LIGHT) + dash_br.multiple_click(theme_toggle_path(), 1) + check_graph_color(dash_br, style_background=cnst.STYLE_BLACK, color=cnst.HEX_BLACK) + check_theme_color(dash_br, color=cnst.THEME_DARK) + dash_br.multiple_click(theme_toggle_path(), 1) + check_graph_color(dash_br, style_background=cnst.STYLE_GREY, color=cnst.HEX_GREY) + check_theme_color(dash_br, color=cnst.THEME_LIGHT) + else: + check_graph_color(dash_br, style_background=cnst.STYLE_BLACK, color=cnst.HEX_BLACK) + check_theme_color(dash_br, color=cnst.THEME_DARK) + dash_br.multiple_click(theme_toggle_path(), 1) + check_graph_color(dash_br, style_background=cnst.STYLE_GREY, color=cnst.HEX_GREY) + check_theme_color(dash_br, color=cnst.THEME_LIGHT) + dash_br.multiple_click(theme_toggle_path(), 1) + check_graph_color(dash_br, style_background=cnst.STYLE_BLACK, color=cnst.HEX_BLACK) + check_theme_color(dash_br, color=cnst.THEME_DARK) + + +@pytest.mark.parametrize( + "dash_br, dashboard_id", + [({"port": cnst.DEFAULT_PORT}, cnst.DASHBOARD_DEFAULT)], + ids=[cnst.DASHBOARD_DEFAULT], + indirect=["dash_br"], +) +def test_ag_grid_themes(dash_br, dashboard_id): + accordion_select(dash_br, accordion_name=cnst.AG_GRID_ACCORDION.upper(), accordion_number=3) + page_select( + dash_br, + page_path=cnst.TABLE_AG_GRID_PAGE_PATH, + page_name=cnst.TABLE_AG_GRID_PAGE, + graph_id=cnst.BOX_AG_GRID_PAGE_ID, + ) + if dashboard_id == cnst.DASHBOARD_DEFAULT: + check_ag_grid_theme_color(dash_br, ag_grid_id=cnst.TABLE_AG_GRID_ID, color=cnst.AG_GRID_LIGHT) + dash_br.multiple_click(theme_toggle_path(), 1) + check_ag_grid_theme_color(dash_br, ag_grid_id=cnst.TABLE_AG_GRID_ID, color=cnst.AG_GRID_DARK) + else: + check_ag_grid_theme_color(dash_br, ag_grid_id=cnst.TABLE_AG_GRID_ID, color=cnst.AG_GRID_DARK) + dash_br.multiple_click(theme_toggle_path(), 1) + check_ag_grid_theme_color(dash_br, ag_grid_id=cnst.TABLE_AG_GRID_ID, color=cnst.AG_GRID_LIGHT) + + +@pytest.mark.parametrize( + "dash_br, dashboard_id", + [({"port": cnst.DEFAULT_PORT}, cnst.DASHBOARD_DEFAULT)], + ids=[cnst.DASHBOARD_DEFAULT], + indirect=["dash_br"], +) +def test_themes_page_change(dash_br, dashboard_id): + page_select( + dash_br, + page_path=cnst.PARAMETERS_PAGE_PATH, + page_name=cnst.PARAMETERS_PAGE, + graph_id=cnst.BAR_GRAPH_ID, + ) + dash_br.multiple_click(theme_toggle_path(), 1) + + def _logic(style_background, graph_color, theme_color): + check_graph_color(dash_br, style_background=style_background, color=graph_color) + check_theme_color(dash_br, color=theme_color) + dash_br.multiple_click(tab_path(tab_id=cnst.PARAMETERS_SUB_TAB_ID, classname="nav-link"), 1) + check_graph_color(dash_br, style_background=style_background, color=graph_color) + page_select( + dash_br, + page_path=cnst.FILTERS_PAGE_PATH, + page_name=cnst.FILTERS_PAGE, + graph_id=cnst.SCATTER_GRAPH_ID, + ) + page_select( + dash_br, + page_path=cnst.PARAMETERS_PAGE_PATH, + page_name=cnst.PARAMETERS_PAGE, + graph_id=cnst.BAR_GRAPH_ID, + ) + dash_br.wait_for_text_to_equal( + tab_path(tab_id=cnst.PARAMETERS_SUB_TAB_ID, classname="active nav-link"), + cnst.PARAMETERS_SUB_TAB_CONTAINER_TWO, + ) + check_graph_color(dash_br, style_background=style_background, color=graph_color) + check_theme_color(dash_br, color=theme_color) + + if dashboard_id == cnst.DASHBOARD_DEFAULT: + _logic(style_background=cnst.STYLE_BLACK, graph_color=cnst.HEX_BLACK, theme_color=cnst.THEME_DARK) + else: + _logic(style_background=cnst.STYLE_BLACK, graph_color=cnst.HEX_GREY, theme_color=cnst.THEME_LIGHT) diff --git a/vizro-core/tests/tests_utils/e2e_asserts.py b/vizro-core/tests/tests_utils/e2e/asserts.py similarity index 96% rename from vizro-core/tests/tests_utils/e2e_asserts.py rename to vizro-core/tests/tests_utils/e2e/asserts.py index cc977b69f..acdf9b94a 100644 --- a/vizro-core/tests/tests_utils/e2e_asserts.py +++ b/vizro-core/tests/tests_utils/e2e/asserts.py @@ -40,11 +40,11 @@ def _create_image_difference(expected_image, result_image): return expected_image -def make_screenshot_and_paths(browserdriver, request_node_name): +def make_screenshot_and_paths(driver, request_node_name): """Creates image paths and makes screenshot during the test run.""" result_image_path = f"{request_node_name}_branch.png" expected_image_path = f"tests/e2e/screenshots/{request_node_name.replace('test', 'main')}.png" - browserdriver.save_screenshot(result_image_path) + driver.save_screenshot(result_image_path) return result_image_path, expected_image_path diff --git a/vizro-core/tests/tests_utils/e2e/vizro/checkers.py b/vizro-core/tests/tests_utils/e2e/vizro/checkers.py new file mode 100644 index 000000000..ef0ab4e5c --- /dev/null +++ b/vizro-core/tests/tests_utils/e2e/vizro/checkers.py @@ -0,0 +1,67 @@ +import e2e.vizro.constants as cnst +from e2e.vizro.waiters import graph_load_waiter +from hamcrest import any_of, assert_that, contains_string, equal_to +from selenium.webdriver.support.color import Color + + +def browser_console_warnings_checker(log_level, log_levels): + """Whitelist for browser console errors and its assert.""" + assert_that( + log_level["message"], + any_of( + contains_string(cnst.INVALID_PROP_ERROR), + contains_string(cnst.REACT_NOT_RECOGNIZE_ERROR), + contains_string(cnst.SCROLL_ZOOM_ERROR), + contains_string(cnst.REACT_RENDERING_ERROR), + contains_string(cnst.UNMOUNT_COMPONENTS_ERROR), + contains_string(cnst.WILLMOUNT_RENAMED_WARNING), + contains_string(cnst.WILLRECEIVEPROPS_RENAMED_WARNING), + contains_string(cnst.READPIXELS_WARNING), + contains_string(cnst.WEBGL_WARNING), + ), + reason=f"Error outoput: {log_levels}", + ) + + +def check_graph_is_loading(driver, graph_id): + """Waiting for graph to start reloading.""" + driver.wait_for_element(f"div[id='{graph_id}'][data-dash-is-loading='true']") + graph_load_waiter(driver, graph_id) + + +def check_slider_value(driver, elem_id, expected_end_value, expected_start_value=None): + end_value = driver.find_element(f"input[id='{elem_id}_end_value']").get_attribute("value") + assert_that( + end_value, + equal_to(expected_end_value), + reason=f"Element number is '{end_value}', but expected number is '{expected_end_value}'", + ) + if expected_start_value: + start_value = driver.find_element(f"input[id='{elem_id}_start_value']").get_attribute("value") + assert_that( + start_value, + equal_to(expected_start_value), + reason=f"Element number is '{start_value}', but expected number is '{expected_start_value}'", + ) + + +def check_accordion_active(driver, accordion_name): + driver.wait_for_text_to_equal("button[class='accordion-button']", accordion_name) + + +def check_theme_color(driver, color): + driver.wait_for_element(f"html[data-bs-theme='{color}']") + + +def check_ag_grid_theme_color(driver, ag_grid_id, color): + driver.wait_for_element(f"div[id='__input_{ag_grid_id}'][class='{color}']") + + +def check_graph_color(driver, style_background, color): + rgb = driver.wait_for_element(f"svg[style='{style_background}']").value_of_css_property("background-color") + graph_color = Color.from_string(rgb).hex + assert_that( + graph_color, + equal_to(color), + reason=f"Graph color is '{graph_color}', but expected color is '{color}'", + ) diff --git a/vizro-core/tests/tests_utils/e2e/vizro/constants.py b/vizro-core/tests/tests_utils/e2e/vizro/constants.py new file mode 100644 index 000000000..6aad7a55f --- /dev/null +++ b/vizro-core/tests/tests_utils/e2e/vizro/constants.py @@ -0,0 +1,86 @@ +# Console errors and warnings + +INVALID_PROP_ERROR = "Invalid prop `persisted_props[0]` of value `on` supplied to `t`" +REACT_NOT_RECOGNIZE_ERROR = "React does not recognize the `%s` prop on a DOM element" +SCROLL_ZOOM_ERROR = "_scrollZoom" +REACT_RENDERING_ERROR = "unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering" +UNMOUNT_COMPONENTS_ERROR = "React state update on an unmounted component" +WILLMOUNT_RENAMED_WARNING = "componentWillMount has been renamed" +WILLRECEIVEPROPS_RENAMED_WARNING = "componentWillReceiveProps has been renamed" +READPIXELS_WARNING = "GPU stall due to ReadPixels" +WEBGL_WARNING = "WebGL" # https://issues.chromium.org/issues/40277080 + +# Pages and its components + +HOME_PAGE = "homepage (cards + graph)" +HOME_PAGE_PATH = "/" +HOME_PAGE_ID = "hp" +AREA_GRAPH_ID = "ArEa" + +FILTERS_PAGE = "filters page (tabs + containers)" +FILTERS_PAGE_PATH = "/filters-page-tabs--containers" +FILTERS_TAB_CONTAINER = "filters tab_container" +FILTERS_COMPONENTS_CONTAINER = "filters components container" +SCATTER_GRAPH_ID = "Scatter" +BOX_GRAPH_ID = "box graph" +RADIO_ITEMS_FILTER_FILTERS_PAGE = "radio-filters-page" +CHECK_LIST_FILTER_FILTERS_PAGE = "check filters-page" +SLIDER_FILTER_FILTERS_PAGE = "slider-filters-page" +RANGE_SLIDER_FILTER_FILTERS_PAGE = "range slider-filters-page" + +PARAMETERS_PAGE = "parameters_p@ge! (tabs + containers)" +PARAMETERS_PAGE_PATH = "/parameters_page" +PARAMETERS_TAB_CONTAINER = "parameters-tab_container" +PARAMETERS_SUB_TAB_ID = "sub tab params" +PARAMETERS_SUB_TAB_CONTAINER_ONE = "parameters sub t@b container 1" +PARAMETERS_SUB_TAB_CONTAINER_TWO = "parameters sub t@b container 2" +PARAMETERS_CONTAINER = "parameters container" +BAR_GRAPH_ID = "b@R-graph" +BAR_GRAPH_ID_CONTAINER = "b@R-graph__container" +HISTOGRAM_GRAPH_ID = "-histogram-graph--" + +KPI_INDICATORS_PAGE = "kpi indicators page" +KPI_INDICATORS_PAGE_PATH = "/kpi-indicators-page" + +EXPORT_PAGE = "export page" +EXPORT_PAGE_PATH = "/exportp" + +DATEPICKER_PAGE = "DATEpicker page" +DATEPICKER_PAGE_PATH = "/datepicker-page" +BAR_POP_RANGE_ID = "bar pop range" +BAR_POP_DATE_ID = "bar pop date" +TABLE_POP_RANGE_ID = "table pop range" +TABLE_POP_DATE_ID = "table pop date" + +TABLE_AG_GRID_PAGE = "table AG grid page" +TABLE_AG_GRID_PAGE_PATH = "/table-ag-grid-page" +TABLE_AG_GRID_ID = "123_ag_grid_table" +BOX_AG_GRID_PAGE_ID = "B@x on ag grid page" +TABLE_AG_GRID_CONTAINER = "table_ag_grid_container" + +PAGE_404_PATH = "/404-page" + +# Accordion names + +GENERAL_ACCORDION = "generAl" +DATEPICKER_ACCORDION = "DATEpicker" +AG_GRID_ACCORDION = "AGgrid" + +# Ports + +DEFAULT_PORT = 5002 + +# Dashboards + +DASHBOARD_DEFAULT = "dashboard_default_gunicorn" + +# Themes + +THEME_DARK = "dark" +THEME_LIGHT = "light" +HEX_BLACK = "#141721" +HEX_GREY = "#ffffff" +STYLE_BLACK = "background: rgb(20, 23, 33);" +STYLE_GREY = "background: white;" +AG_GRID_DARK = "ag-theme-quartz-dark ag-theme-vizro" +AG_GRID_LIGHT = "ag-theme-quartz ag-theme-vizro" diff --git a/vizro-core/tests/tests_utils/e2e/vizro/dashboards/wait-for-it.sh b/vizro-core/tests/tests_utils/e2e/vizro/dashboards/wait-for-it.sh new file mode 100755 index 000000000..c661e34c9 --- /dev/null +++ b/vizro-core/tests/tests_utils/e2e/vizro/dashboards/wait-for-it.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash + +#The MIT License (MIT) +#Copyright (c) 2016 Giles Hall +# +#Permission is hereby granted, free of charge, to any person obtaining a copy of +#this software and associated documentation files (the "Software"), to deal in +#the Software without restriction, including without limitation the rights to +#use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +#of the Software, and to permit persons to whom the Software is furnished to do +#so, subject to the following conditions: +# +#The above copyright notice and this permission notice shall be included in all +#copies or substantial portions of the Software. +# +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +#SOFTWARE. + +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/vizro-core/tests/tests_utils/e2e/vizro/navigation.py b/vizro-core/tests/tests_utils/e2e/vizro/navigation.py new file mode 100644 index 000000000..a47541e32 --- /dev/null +++ b/vizro-core/tests/tests_utils/e2e/vizro/navigation.py @@ -0,0 +1,29 @@ +import time + +from e2e.vizro.checkers import check_accordion_active +from e2e.vizro.paths import page_title_path +from e2e.vizro.waiters import graph_load_waiter + + +def accordion_select(driver, accordion_name, accordion_number): + """Selecting accordion and checking if it is active.""" + driver.multiple_click(f"div[class='accordion accordion'] div:nth-of-type({accordion_number})", 1) + check_accordion_active(driver, accordion_name) + # to let accordion open + time.sleep(1) + + +def page_select(driver, page_path, page_name, graph_id=None): + """Selecting page and checking if it has proper title.""" + driver.wait_for_page() + driver.multiple_click(f"a[href='{page_path}']", 1) + driver.wait_for_text_to_equal(page_title_path(), page_name) + if graph_id: + graph_load_waiter(driver, graph_id) + + +def select_dropdown_value(driver, value): + """Steps to select value in dropdown.""" + driver.multiple_click(".Select-clear", 1) + driver.multiple_click(".Select-arrow", 1) + driver.multiple_click(f".ReactVirtualized__Grid__innerScrollContainer div:nth-of-type({value})", 1) diff --git a/vizro-core/tests/tests_utils/e2e/vizro/paths.py b/vizro-core/tests/tests_utils/e2e/vizro/paths.py new file mode 100644 index 000000000..5f9f8a724 --- /dev/null +++ b/vizro-core/tests/tests_utils/e2e/vizro/paths.py @@ -0,0 +1,28 @@ +def theme_toggle_path(): + return "#theme-selector" + + +# Navigation + + +def tab_path(tab_id, classname): + return f"ul[id='{tab_id}'] a[class='{classname}']" + + +# Components + + +def page_title_path(): + return "#page-title" + + +def nav_card_link_path(href): + return f"a[href='{href}'][class='nav-link']" + + +def slider_value_path(elem_id, value): + return f"div[id='{elem_id}'] div div span:nth-of-type({value})" + + +def categorical_components_value_path(elem_id, value): + return f"div[id='{elem_id}'] div:nth-of-type({value}) input" diff --git a/vizro-core/tests/tests_utils/e2e/vizro/waiters.py b/vizro-core/tests/tests_utils/e2e/vizro/waiters.py new file mode 100644 index 000000000..8ac64d64a --- /dev/null +++ b/vizro-core/tests/tests_utils/e2e/vizro/waiters.py @@ -0,0 +1,3 @@ +def graph_load_waiter(driver, graph_id): + """Waiting for graph's x-axis to appear.""" + driver.wait_for_element(f"div[id='{graph_id}'] path[class='xtick ticks crisp']")