diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..3eb13143c --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 96c967f5a..445e476fb 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,9 @@ data/calculated_parser/parsetab.py node_modules/ oceannavigator/frontend/public/ +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/Front-end_tests/Readme.md b/Front-end_tests/Readme.md new file mode 100644 index 000000000..a0735ff94 --- /dev/null +++ b/Front-end_tests/Readme.md @@ -0,0 +1,176 @@ +# Dataset Configuration (test_datasets.json) + +The test_datasets.json file contains the full configuration for every dataset used in automated Playwright tests. +Each dataset entry defines how tests should behave for variable testing, area plots, line plots, subset exports, map tiles, and compare-mode testing. + +This configuration ensures that tests adapt to differences between datasets, such as UI variations, available variables, plot types, and tile coordinates. + +- What Each Dataset Entry Contains + +Each dataset object includes the following fields: + +1. Basic Identification + +name — Name of the dataset as shown in the UI. + +id — Internal ID used to validate API tile responses. + +Including the ID in tile API checks ensures the tile belongs to the correct dataset after switching variables. + +2. Dataset Selector Steps + +steps — The sequence of dropdown selections required to reach this dataset. + +compare_dataset — The dataset used when running compare_test.spec. + +compare_steps (optional) — Exact steps required to switch to the compare dataset. + +3. Plot Testing Settings + +csvPath — Path to the CSV file containing coordinates for area-plot testing. + +subset_var — Variable used for subset-download (NetCDF) tests. + +variables_to_test — Object mapping backend variable names → UI labels for variable switching tests. + +4. Arrow & Contour Plot Handling + +Some datasets display: + +Arrows (e.g., Water Velocity) + +Contours (e.g., Sea Surface Height) + +Because the UI order of these checkboxes varies per dataset, we include: + +arrow_box_position + +contour_box_position + +These numbers ensure tests click the correct UI element. + +5. Line & Point Plot Tests + +line_transect — "yes" or "no" depending on whether the dataset supports line transect plots. + +line_coordinates — Prese(flemish_cap) or custom coordinates if preset not present for the dataset for line tests. + +point_coordinates — Coordinates for point plot tests (lat, lon). + +6. Tile Testing Information + +tile_coordinates — Zoom level + tile X + tile Y. +Used during tile API validation to ensure the map renders correct data. + + +# Area Test Overview + +- Opens the Edit Map Features window and verifies that it appears correctly. + +- Changes the upload type to Area and confirms the label updates to Polygon. + +- Uploads the dataset CSV file and verifies that the CSV content is displayed correctly. + +-Listens for the backend area map plot request and verifies that the response status is 200. + +- Checks that the dataset in the selector updates correctly after plotting. + +- If a quiver variable is defined, selects it, listens for the backend quiver plot request, and verifies the response status. + +- If additional contours are defined, selects the contour option, listens for the contour plot request, and verifies the response status. + +- Enables dataset comparison, and if comparing with a non-default dataset, switches to the specified dataset. + +- vListens for the backend compare plot request and verifies that the final successful response returns 200. + +# Point Plot Test Overview + +This test suite performs automated validation for each dataset by running a Point Plot Test. +The checks include: + +- 3D Dataset Validation + +Confirms the profile plot appears by verifying the backend’s /plot/profile response. + +- Virtual Mooring Plot + +Ensures the Virtual Mooring timeseries plot loads successfully by checking the /plot/timeseries API response. + +- Dataset Switching + +Verifies that the selected dataset actually changed by checking the visible dataset button name. + +- CSV Download Test + +Confirms the backend returns a successful CSV download response. + +Ensures the browser starts the file download and receives a valid filename. + +- API Script Download Test + +Validates the backend’s /generate_script response for the API script. + +Ensures the browser receives a downloadable file with a valid name. + + +# Line Test Overview + +This test suite performs automated validation for each dataset by running a Line Plot Test. +The checks include: + +- Line Coordinates Handling + +If the dataset does NOT include the preset Flemish Cap line, the test manually enters coordinates using the dataset’s line_coordinates values. + +If the dataset does include the preset line, it uses the built-in option. + +- Line Plot Request + +Listens for the backend line plot request and verifies the response status. + +- AZMP Line Validation (When Available) + +For datasets supporting line transects, the test listens for the AZMP line request and validates the backend response. + +For datasets without line transect support, this check is skipped. + +- Hovmöller Plot Verification + +Ensures the Hovmöller plot request completes successfully by validating the backend response. + +- Dataset Switch Confirmation + +Confirms that the selected dataset changed correctly by verifying the displayed dataset name. + + +# Subset Download Test Overview + +-Verifies the dataset switched correctly by checking the visible dataset button. + +-Opens Edit Map Features and sets upload type to Area (handles the SalishSeaCast special-case combobox index). + +-Uploads the dataset CSV and selects the uploaded feature. + +-Clicks Plot Selected Features and selects the dataset’s subset variable. + +-Enables Compress as *.zip (if required) and clicks Save. + +-Listens for the backend /api/v2.0/subset request and verifies the response status is 200. + +-Waits for the browser download event and confirms the downloaded file has a suggested filename. + + +# Variable Test Overview + + +-Verifies the dataset switched correctly by checking the visible dataset button. + +- For each variable in variables_to_test: + +- Selects the variable from the Variable control. + +- Clicks Go and listens for the backend tile request for that dataset/variable + tile coordinates, and verifies the response is 200. + +- Listens for the backend scale request for that dataset/variable and verifies the response is 200. + +- Confirms the Variable control shows the selected variable label. \ No newline at end of file diff --git a/Front-end_tests/Total Area Great Lakes 3.csv b/Front-end_tests/Total Area Great Lakes 3.csv new file mode 100644 index 000000000..795398915 --- /dev/null +++ b/Front-end_tests/Total Area Great Lakes 3.csv @@ -0,0 +1,4 @@ +longitude,latitude +-86.703,47.322 +-87.4289,47.8006 +-86.807,48.0384 diff --git a/Front-end_tests/Total Area NA 3.csv b/Front-end_tests/Total Area NA 3.csv new file mode 100644 index 000000000..b24ce5672 --- /dev/null +++ b/Front-end_tests/Total Area NA 3.csv @@ -0,0 +1,4 @@ +longitude,latitude +-59.3802,45.3181 +-56.9418,45.559 +-57.6699,45.9945 diff --git a/Front-end_tests/Total Area Salish 3.csv b/Front-end_tests/Total Area Salish 3.csv new file mode 100644 index 000000000..c8a37a8c8 --- /dev/null +++ b/Front-end_tests/Total Area Salish 3.csv @@ -0,0 +1,4 @@ +longitude,latitude +-123.68,49.3144 +-123.3265,49.2905 +-123.4583,49.0367 diff --git a/Front-end_tests/area_plot_test.spec.js b/Front-end_tests/area_plot_test.spec.js new file mode 100644 index 000000000..cdd537a9a --- /dev/null +++ b/Front-end_tests/area_plot_test.spec.js @@ -0,0 +1,348 @@ +import { test, expect } from "@playwright/test"; +import datasets from "./test_datasets.json"; + +let page; + +test.beforeAll(async ({ browser }) => { + // Create one page globally + page = await browser.newPage(); + + // Only ONE navigation for all datasets + await page.goto("http://0.0.0.0:8443/public/"); + + // Wait for progress bar to detach + await page.locator(".progress").waitFor({ state: "detached" }); +}); + +test.afterAll(async () => { + await page.close(); +}); + +//catches first successful response with a status of 200 +//if no successful response within 120 second it will throw an error +async function waitForSuccessfulResponse(page, matchFn, timeout = 120000) { + return await page.waitForResponse( + (response) => { + try { + if (!matchFn(response)) return false; + const status = response.status(); + return status == 200; + } catch (e) { + return false; + } + }, + { timeout } + ); +} + +// Helper function that handles the entire plotting and download process +async function runPlotTest(dataset) { + console.log(`Running Area test for dataset: ${dataset.name}`); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for progress bar to detach + await page.locator(".progress").waitFor({ state: "detached" }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + //test to see if dataset was correctly changed in dataset_selector + await expect(page.getByRole("button", { name: dataset.name })) + .toBeVisible() + .catch((err) => { + throw new Error( + `Dataset selector didn't get changed properly: ${err.message}` + ); + }); + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + //ensure edit map feature window opens + await expect(page.getByRole("dialog")) + .toBeVisible() + .catch((err) => { + throw new Error( + `Edit map feature button didn't successfully open the window: ${err.message}` + ); + }); + + // choosing area as upload type + //label position varies for SalishSeaCAst 3D Currents but same for the rest + if (dataset.name == "SalishSeaCast 3D Currents") { + const combo = page.getByRole("combobox").nth(3); + await combo.selectOption({ label: "Area" }); + //verifying if label change was successful + await expect(combo) + .toHaveValue("Polygon") + .catch((err) => { + throw new Error( + `Upload CSV type didn't get changed properly: ${err.message}` + ); + }); + } else { + const combo = page.getByRole("dialog").getByRole("combobox"); + await combo.selectOption({ label: "Area" }); + //verifying if label change was successful + await expect(combo) + .toHaveValue("Polygon") + .catch((err) => { + throw new Error( + `Upload CSV type didn't get changed properly: ${err.message}` + ); + }); + } + + // Uploading CSV file + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Upload CSV" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(dataset.csvPath); + //verify if csv file uploaded correctly + await expect( + page + .locator("div") + .filter({ hasText: /^PointLineArea\+LongitudeLatitude$/ }) + .first() + ) + .toBeVisible() + .catch((err) => { + throw new Error(`CSV file didn't get uploaded properly: ${err.message}`); + }); + + // Select checkbox + await page.getByRole("dialog").getByRole("checkbox").check(); + + // Listen for plot request + const plotRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/map") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + // Plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + // Check API response + //this check confirms if plot is being sent correctly from the back-end + const plotResponse = await plotRequestPromise; + try { + expect(plotResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Area plot didn't get generated: ${err.message}`); + } + + // selecting arrows from area settings column + if (dataset.arrows != "none") { + const arrowsRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/map") && + url.includes("format=json") && + url.includes("quiver") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + await page + .getByRole("combobox") + .nth(dataset.arrow_box_position) + .selectOption({ label: dataset.arrows }); + + //waiting for plot with arrows to be generated + const arrowsResponse = await arrowsRequestPromise; + + //checking if plot rendered properly with arrows + try { + expect(arrowsResponse.status()).toBe(200); + } catch (err) { + throw new Error( + `Area plot with arrows didn't get generated: ${err.message}` + ); + } + } + //selecting Addidtional Contours from Area Settings + if (dataset.contour != "none") { + const contourRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/map") && + url.includes("format=json") && + url.includes("contour") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + await page + .getByRole("combobox") + .nth(dataset.contour_box_position) + .selectOption({ label: dataset.contour }); + const contourResponse = await contourRequestPromise; + + //checking if plot rendered properly with additional_contours + try { + expect(contourResponse.status()).toBe(200); + } catch (err) { + throw new Error( + `Area plot with contour didn't get generated: ${err.message}` + ); + } + } + + //****************************************************************Compare plot TEST**********************************************************************// + //clciking on compare Dataset + await page.locator("#dataset_compare").check(); + + //By default every dataset is compared with GIOPS 10 Day Daily Mean + //if we want to compare with other dataset + if (dataset.compare_dataset != "GIOPS 10 Day Daily Mean") { + //changing the dataset on the right side of the map + await page + .locator("#right_map") + .getByRole("button", { name: "GIOPS 10 Day Daily Mean" }) + .click(); + + for (const step of dataset.compare_steps) { + await page.getByRole("button", { name: step }).click(); + } + //wait for the dataset to be loaded properly + const loadingDialog = page + .getByRole("dialog") + .filter({ hasText: "Loading RIOPS Forecast 3D - Polar Stereographic" }); + + await loadingDialog.waitFor({ state: "detached", timeout: 120000 }); + + // catches the successfull response for the new compare dataset + //Note: When we compare with a dataset, the first api response we get gives a status 500 error + //therefore waitForSuccessfulResponse function ensures that we only take the response that have a status 200. + const [comparePlotResponse] = await Promise.all([ + waitForSuccessfulResponse( + page, + (res) => + res.url().includes("/api/v2.0/plot/map") && + res.url().includes("compare_to") && + res.request().method() === "GET", + 120000 + ), + + page.locator("#right_map").getByRole("button", { name: "Go" }).click(), + ]); + + try { + expect(comparePlotResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Compare plot didn't get generated: ${err.message}`); + } + } else { + // Wait for the successful response + const [datasetCompareResponse] = await Promise.all([ + waitForSuccessfulResponse( + page, + (res) => + res.url().includes("/api/v2.0/plot/map") && + res.url().includes("compare_to") && + res.request().method() === "GET", + 120000 + ), + + page.locator("#dataset_compare").check(), + ]); + try { + expect(datasetCompareResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Compare plot didn't get generated: ${err.message}`); + } + } + + //close modal + await page.getByRole("button", { name: "Close" }).nth(1).click(); + //unclick compare mode + await page.getByRole("checkbox", { name: "Compare Datasets" }).click(); + //reset to giops dataset + if (dataset.id != "giops_day") { + await page.getByRole("button", { name: dataset.steps.at(-1) }).click(); + await page + .getByRole("button", { name: dataset.steps.at(-2), exact: true }) + .click(); + await page.getByRole("button", { name: "GIOPS Forecast" }).click(); + await page.getByRole("button", { name: "GIOPS 10 Day Daily Mean" }).click(); + } + + //reset map drawings + await page.locator(".MapTools > button:nth-child(6)").click(); + + //****************************************************************END OF TEST**********************************************************************// +} + +test("All datasets sequential test", async () => { + test.setTimeout(600000); + + const results = []; + + for (const dataset of datasets) { + await test.step(`Testing dataset: ${dataset.name}`, async () => { + try { + await runPlotTest(dataset); + results.push({ dataset: dataset.name, status: "passed" }); + } catch (err) { + results.push({ + dataset: dataset.name, + status: "failed", + error: err.message, + }); + console.error(`❌ Error in dataset "${dataset.name}": ${err.message}`); + // Reload the app so next dataset runs cleanly + console.log(`Reloading page after failure in ${dataset.name}...`); + + //doing a hard reload + await page.reload({ waitUntil: "domcontentloaded" }); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); + + //verifying if the reloaded page points to the home page + //some errors might display the api response and relaoding the page just reloads the response + const current = page.url(); + const target = "http://0.0.0.0:8443/public/"; + + if (current !== target) { + await page.goto(target, { waitUntil: "domcontentloaded" }); + } + } + }); + } + + // Report summary at the end + const failed = results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + console.log("\nTest Summary:"); + results.forEach((r) => { + console.log(`${r.dataset}: ${r.status}`); + if (r.error) console.log(` Error: ${r.error}`); + }); + throw new Error( + `${failed.length} datasets failed out of ${results.length}` + ); + } +}); diff --git a/Front-end_tests/line_test.spec.js b/Front-end_tests/line_test.spec.js new file mode 100644 index 000000000..1ced8d71b --- /dev/null +++ b/Front-end_tests/line_test.spec.js @@ -0,0 +1,261 @@ +import { test, expect } from "@playwright/test"; +import datasets from "./test_datasets.json"; + +let page; +test.beforeAll(async ({ browser }) => { + + // Create one page globally + page = await browser.newPage(); + + // Only ONE navigation for all datasets + await page.goto("http://0.0.0.0:8443/public/"); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); +}); + +test.afterAll(async () => { + await page.close(); +}); + +async function runPlotTest(dataset) { + console.log(`Running Line test for dataset: ${dataset.name}`); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); + + //apply dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //wait for dataset to be loaded fully + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + //For dataset that don't have data for flemish cap line + if (dataset.line_coordinates != "flemish_cap") { + //manually add coordinates + await page.locator(".MapTools > button:nth-child(2)").click(); + await page.getByRole("button", { name: "Add New Feature" }).click(); + await page.getByRole("combobox").nth(3).selectOption({ label: "Line" }); + await page.getByRole("dialog").getByRole("button", { name: "+" }).click(); + await page.locator('[id="0"]').first().fill(dataset.line_coordinates[0]); + await page.locator('[id="1"]').first().fill(dataset.line_coordinates[2]); + await page.locator('[id="0"]').nth(1).fill(dataset.line_coordinates[1]); + await page.locator('[id="1"]').nth(1).fill(dataset.line_coordinates[3]); + await page.getByRole("dialog").getByRole("checkbox").click(); + + //listen for plot request + const plotRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/transect") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + const plotResponse = await plotRequestPromise; + try { + expect(plotResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Line plot didn't get generated: ${err.message}`); + } + } else { + //click on preset features + await page.locator(".MapTools > button:nth-child(3)").click(); + + //wait for it to be visible + await page + .getByRole("button", { name: "AZMP Transects", exact: true }) + .waitFor({ state: "visible", timeout: 120000 }); + + // Listen for AZMP request + const azmpRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/kml/line/AZMP") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //click on AZMP Transects + await page + .getByRole("button", { name: "AZMP Transects", exact: true }) + .click(); + + const azmpResponse = await azmpRequestPromise; + + try { + expect(azmpResponse.status()).toBe(200); + } catch (err) { + throw new Error(`transect line didn't get generated: ${err.message}`); + } + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + + //select flemish cap + await page.getByRole("checkbox").nth(4).check(); + + if (dataset.line_transect == "no") { + // if dataset doesnt support line transect then hit plot and then go to hovmoller + await page + .getByRole("button", { name: "Plot Selected Features" }) + .click(); + } else { + //listen for plot request + const transectPlotRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/transect") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + // Plot + await page + .getByRole("button", { name: "Plot Selected Features" }) + .click(); + + // Check API response + //this check confirms if plot is being sent correctly from the back-end + const transectPlotResponse = await transectPlotRequestPromise; + try { + expect(transectPlotResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Transect Line didn't get generated: ${err.message}`); + } + + //test to see if dataset was correctly changed in dataset_selector\ + await expect( + page.locator("#left_map").getByRole("button", { name: dataset.name }) + ) + .toBeVisible() + .catch((err) => { + throw new Error( + `Dataset didn't get changed correctly: ${err.message}` + ); + }); + } + } + + //*********************************************************************************************** + // Hovmoller Diagram test + //*********************************************************************************************** + // Listen for plot request + const hovmollerPlotRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/hovmoller") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //click on Hovmoller Diagram + await page.getByRole("button", { name: "Hovmöller Diagram" }).click(); + + //wait for response + const hovmollerPlotResponse = await hovmollerPlotRequestPromise; + + //check status of response + + try { + expect(hovmollerPlotResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Hovmoller plot didn't get generated: ${err.message}`); + } + + //resetting ui for next dataset + + //close modal + await page.getByRole("button", { name: "Close" }).nth(1).click(); + + //reset to giops dataset + if (dataset.id != "giops_day") { + await page.getByRole("button", { name: dataset.steps.at(-1) }).click(); + await page + .getByRole("button", { name: dataset.steps.at(-2), exact: true }) + .click(); + await page.getByRole("button", { name: "GIOPS Forecast" }).click(); + await page.getByRole("button", { name: "GIOPS 10 Day Daily Mean" }).click(); + } + + //reset map drawings + await page.locator(".MapTools > button:nth-child(6)").click(); +} +//****************************************************************END OF TEST**********************************************************************// + +test("All datasets sequential test", async () => { + test.setTimeout(600000); + + const results = []; + + for (const dataset of datasets) { + await test.step(`Testing dataset: ${dataset.name}`, async () => { + try { + await runPlotTest(dataset); + results.push({ dataset: dataset.name, status: "passed" }); + } catch (err) { + results.push({ + dataset: dataset.name, + status: "failed", + error: err.message, + }); + console.error(`❌ Error in dataset "${dataset.name}": ${err.message}`); + // Reload the app so next dataset runs cleanly + console.log(`Reloading page after failure in ${dataset.name}...`); + + //doing a hard reload + await page.reload({ waitUntil: "domcontentloaded" }); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); + + //verifying if the reloaded page points to the home page + //some errors might display the api response and relaoding the page just reloads the response + const current = page.url(); + const target = "http://0.0.0.0:8443/public/"; + + if (current !== target) { + await page.goto(target, { waitUntil: "domcontentloaded" }); + } + } + }); + } + + // Report summary at the end + const failed = results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + console.log("\nTest Summary:"); + results.forEach((r) => { + console.log(`${r.dataset}: ${r.status}`); + if (r.error) console.log(` Error: ${r.error}`); + }); + throw new Error( + `${failed.length} datasets failed out of ${results.length}` + ); + } +}); diff --git a/Front-end_tests/point_test.spec.js b/Front-end_tests/point_test.spec.js new file mode 100644 index 000000000..e3ddeb122 --- /dev/null +++ b/Front-end_tests/point_test.spec.js @@ -0,0 +1,279 @@ +import { test, expect } from "@playwright/test"; +import datasets from "./test_datasets.json"; +let page; +test.beforeAll(async ({ browser }) => { + // Create one page globally + page = await browser.newPage(); + + // Only ONE navigation for all datasets + await page.goto("http://0.0.0.0:8443/public/"); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); +}); + +test.afterAll(async () => { + await page.close(); +}); + +async function runPlotTest(dataset) { + console.log(`Running Point test for dataset: ${dataset.name}`); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for progress bar to detach + await page.locator(".progress").waitFor({ state: "detached" }); + + //apply dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //wait for dataset to be loaded fully + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 60000 }); + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + + //click Add New Feature + await page.getByRole("button", { name: "Add New Feature" }).click(); + + //add coordinated for the datasets + //the coordinate box position differs for CIOPS 3D only + if (dataset.name == "CIOPS Forecast East 3D -") { + await page.locator('[id="0"]').nth(3).fill(dataset.point_coordinates[0]); + await page.locator('[id="0"]').nth(4).fill(dataset.point_coordinates[1]); + } else { + await page.locator('[id="0"]').first().fill(dataset.point_coordinates[0]); + await page.locator('[id="0"]').nth(1).fill(dataset.point_coordinates[1]); + } + + //click checkbox + await page.getByRole("dialog").getByRole("checkbox").click(); + + //listen for profile response + const profileRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/profile") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //click on plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + //profile plot is only available for 3D dataset + if (dataset.type == "3D") { + //wait for the response + const profilePlotResponse = await profileRequestPromise; + //verify if plot is rendered + try { + expect(profilePlotResponse.status()).toBe(200); + } catch (err) { + throw new Error(`profile plot didn't get generated: ${err.message}`); + } + } + + //listen for hovmoller plot request + const timeseriesRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/timeseries") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //verify id dataset changed properly + await expect(page.getByRole("button", { name: dataset.name }).nth(1)) + .toBeVisible() + .catch((err) => { + throw new Error(`Dataset didn't get changed correctly: ${err.message}`); + }); + + //click on Virtual Mooring button + await page.getByRole("button", { name: "Virtual Mooring" }).click(); + //wait for response + const virtualMooringplotResponse = await timeseriesRequestPromise; + //verify if plot is rendered + try { + expect(virtualMooringplotResponse.status()).toBe(200); + } catch (err) { + throw new Error( + `Virtual Mooring plot didn't get generated: ${err.message}` + ); + } + + await page.getByRole("button", { name: "Save Image" }).click(); + + //Listen for download request + const downloadResponsePromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/timeseries") && + url.includes("save=True") && + url.includes("format=csv") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //listening for download event + const downloadEventPromise = page.waitForEvent("download"); + //click on CSV file + await page.getByRole("button", { name: "CSV", exact: true }).click(); + + const [downloadResponse, download] = await Promise.all([ + downloadResponsePromise, + downloadEventPromise, + ]); + //check if back-end sends data + try { + expect(downloadResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Back-end didn't send csv file: ${err.message}`); + } + + // download object created, if browser begins downloading + //giving a name to the file and ensuring download has started + //Note: file created will not be stored in the system and automatically cleaned up after test finishes + const suggestedFilename = download.suggestedFilename(); + try { + expect(suggestedFilename).toBeTruthy(); + } catch (err) { + throw new Error(`file didn't start downloading: ${err.message}`); + } + + //********************************************************************************************************* + // API Script Download Test + //********************************************************************************************************* + + //click on API + await page.getByRole("button", { name: "API Script" }).click(); + + //listener for api download + const apiDownloadResponsePromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/generate_script") && + url.includes("script_type=csv") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + //listening for download event + const apiDownloadEventPromise = page.waitForEvent("download"); + + //click on csv + await page + .getByRole("button", { name: "Python 3 - CSV", exact: true }) + .click(); + + //responses + const [apiResponse, apidownload] = await Promise.all([ + apiDownloadResponsePromise, + apiDownloadEventPromise, + ]); + + //confirm if api responded with 200 status + try { + expect(apiResponse.status()).toBe(200); + } catch (err) { + throw new Error(`api file not recieved : ${err.message}`); + } + + //giving a name to the file and ensuring download has started + //Note: file created will not be stored in the system and automatically cleaned up after test finishes + const suggestedApiFilename = apidownload.suggestedFilename(); + try { + expect(suggestedApiFilename).toBeTruthy(); + } catch (err) { + throw new Error(`api file didn't start downloading: ${err.message}`); + } + + //resetting ui for next dataset + //close modal + await page.getByRole("button", { name: "Close" }).nth(1).click(); + + //reset to giops dataset + if (dataset.id != "giops_day") { + await page.getByRole("button", { name: dataset.steps.at(-1) }).click(); + await page + .getByRole("button", { name: dataset.steps.at(-2), exact: true }) + .click(); + await page.getByRole("button", { name: "GIOPS Forecast" }).click(); + await page.getByRole("button", { name: "GIOPS 10 Day Daily Mean" }).click(); + } + + //reset map drawings + await page.locator(".MapTools > button:nth-child(6)").click(); +} + +//****************************************************************END OF TEST**********************************************************************// + +test("All datasets sequential test", async () => { + test.setTimeout(600000); + + const results = []; + + for (const dataset of datasets) { + await test.step(`Testing dataset: ${dataset.name}`, async () => { + try { + await runPlotTest(dataset); + results.push({ dataset: dataset.name, status: "passed" }); + } catch (err) { + results.push({ + dataset: dataset.name, + status: "failed", + error: err.message, + }); + console.error(`❌ Error in dataset "${dataset.name}": ${err.message}`); + // Reload the app so next dataset runs cleanly + //doing a hard reload + await page.reload({ waitUntil: "domcontentloaded" }); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); + + //verifying if the reloaded page points to the home page + //some errors might display the api response and relaoding the page just reloads the response + const current = page.url(); + const target = "http://0.0.0.0:8443/public/"; + + if (current !== target) { + await page.goto(target, { waitUntil: "domcontentloaded" }); + } + } + }); + } + + // Report summary at the end + const failed = results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + console.log("\nTest Summary:"); + results.forEach((r) => { + console.log(`${r.dataset}: ${r.status}`); + if (r.error) console.log(` Error: ${r.error}`); + }); + throw new Error( + `${failed.length} datasets failed out of ${results.length}` + ); + } +}); diff --git a/Front-end_tests/subset_download_test.spec.js b/Front-end_tests/subset_download_test.spec.js new file mode 100644 index 000000000..6b13a244e --- /dev/null +++ b/Front-end_tests/subset_download_test.spec.js @@ -0,0 +1,186 @@ +import { test, expect } from "@playwright/test"; +import datasets from "./test_datasets.json"; + +let page; +// test.describe.configure({ mode: "serial" }); +test.beforeAll(async ({ browser }) => { + // Create one page globally + page = await browser.newPage(); + + // 🌟 Only ONE navigation for all datasets + await page.goto("http://0.0.0.0:8443/public/"); + + // Wait for progress bar to detach + await page.locator(".progress").waitFor({ state: "detached" }); +}); +test.afterAll(async () => { + await page.close(); +}); + +// handles subset download +async function runPlotTest(dataset) { + console.log(`Running subset download test for dataset: ${dataset.name}`); + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for progress bar to detach + await page.locator(".progress").waitFor({ state: "detached" }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + //verify id dataset changed properly + await expect(page.getByRole("button", { name: dataset.name })) + .toBeVisible() + .catch((err) => { + throw new Error(`Dataset didn't get changed properly: ${err.message}`); + }); + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + + // choosing area as upload type + //label position varies for SalishSeaCAst 3D Currents but same for the rest + if (dataset.name == "SalishSeaCast 3D Currents") { + await page.getByRole("combobox").nth(3).selectOption({ label: "Area" }); + } else { + await page + .getByRole("dialog") + .getByRole("combobox") + .selectOption({ label: "Area" }); + } + + // Uploading CSV file + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Upload CSV" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(dataset.csvPath); + await page.waitForTimeout(2000); + + // Select checkbox + await page.getByRole("dialog").getByRole("checkbox").check(); + + // Plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + // selecting subset variable + await page.getByRole("listbox").selectOption({ label: dataset.subset_var }); + + //compressing as zip file + await page.getByRole("checkbox", { name: "Compress as *.zip" }).click(); + + //Listen for download request + const downloadRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/subset") && + response.request().method() === "GET" + ); + }, + { timeout: 90000 } + ); + + //listening for download event + const downloadPromise = page.waitForEvent("download"); + + //clicking save file, should start downlaod + await page.getByRole("button", { name: "Save", exact: true }).click(); + + //check if back-end sends data + const downloadResponse = await downloadRequestPromise; + try { + expect(downloadResponse.status()).toBe(200); + } catch (err) { + throw new Error(`Didn't recieve download file: ${err.message}`); + } + + // download object created, if browser begins downloading + const download = await downloadPromise; + //giving a name to the file and ensuring download has started + //Note: file created will not be stored in the system and automatically cleaned up after test finishes + const suggestedFilename = download.suggestedFilename(); + try { + expect(suggestedFilename).toBeTruthy(); + } catch (err) { + throw new Error(`file didn't get downloaded: ${err.message}`); + } + + //resetting ui for next dataset + + //close modal + await page.getByRole("button", { name: "Close" }).nth(1).click(); + //reset to giops dataset + if (dataset.id != "giops_day") { + await page.getByRole("button", { name: dataset.steps.at(-1) }).click(); + await page + .getByRole("button", { name: dataset.steps.at(-2), exact: true }) + .click(); + await page.getByRole("button", { name: "GIOPS Forecast" }).click(); + await page.getByRole("button", { name: "GIOPS 10 Day Daily Mean" }).click(); + } + + //reset map drawings + await page.locator(".MapTools > button:nth-child(6)").click(); +} + +test("All datasets sequential test", async () => { + test.setTimeout(600000); + + const results = []; + + for (const dataset of datasets) { + await test.step(`Testing dataset: ${dataset.name}`, async () => { + try { + await runPlotTest(dataset); + results.push({ dataset: dataset.name, status: "passed" }); + } catch (err) { + results.push({ + dataset: dataset.name, + status: "failed", + error: err.message, + }); + console.error(`❌ Error in dataset "${dataset.name}": ${err.message}`); + //Reload the app so next dataset runs cleanly + console.log(`Reloading page after failure in ${dataset.name}...`); + + //doing a hard reload + await page.reload({ waitUntil: "domcontentloaded" }); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); + + //verifying if the reloaded page points to the home page + //some errors might display the api response and relaoding the page just reloads the response + const current = page.url(); + const target = "http://0.0.0.0:8443/public/"; + + if (current !== target) { + await page.goto(target, { waitUntil: "domcontentloaded" }); + } + } + }); + } + + // Report summary at the end + const failed = results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + console.log("\nTest Summary:"); + results.forEach((r) => { + console.log(`${r.dataset}: ${r.status}`); + if (r.error) console.log(` Error: ${r.error}`); + }); + throw new Error( + `${failed.length} datasets failed out of ${results.length}` + ); + } +}); diff --git a/Front-end_tests/test_datasets.json b/Front-end_tests/test_datasets.json new file mode 100644 index 000000000..9b8378536 --- /dev/null +++ b/Front-end_tests/test_datasets.json @@ -0,0 +1,155 @@ + +[ + { + "name": "GIOPS 10 Day Daily Mean", + "id":"giops_day", + "steps": ["Giops 10 Day Daily Mean"], + "compare_dataset":"RIOPS Forecast 3D - Polar", + "type":"3D", + "compare_steps":[ "RIOPS Forecast", "RIOPS Forecast 3D - Polar"], + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "3", + "contour_box_position":"4", + "line_transect":"yes", + "line_coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"], + "tile_coordinates":["4","6","4"], + "variables_to_test":{"sspeed":"Speed of Sound","psubsurfacechannel":"Potential Sub Surface Channel"} + }, + { + "name": "RIOPS Forecast 3D - Polar", + "id":"riops_fc_3dps", + "steps": [ + "GIOPS 10 Day Daily Mean", + "RIOPS Forecast", + "RIOPS Forecast 3D - Polar" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "type":"3D", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Speed of Sound", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "3", + "contour_box_position":"4", + "line_transect":"yes", + "line_coordinates":"flemish_cap", + "point_coordinates":["-50.9582","44.9855"], + "tile_coordinates":["4","6","4"], + "variables_to_test":{"sspeed":"Speed of Sound","psubsurfacechannel":"Potential Sub Surface Channel"} + }, + { + "name": "CCG RIOPS Forecast Surface -", + "id":"riops_fc_2dll", + "steps": [ + "GIOPS 10 Day Daily Mean", + "RIOPS Forecast", + "CCG RIOPS Forecast Surface -" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "type":"2D", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "2", + "contour_box_position":"3", + "line_transect":"no", + "line_coordinates":"flemish_cap", + "point_coordinates":["-50.9582","44.9855"], + "tile_coordinates":["4","5","6"], + "variables_to_test":{"vozocrtx":"Water East Velocity","sossheig":"Sea Surface Height"} + }, + { + "name": "WCPS-GLS Great Lakes Coupled", + "id":"wcps_2d11", + "steps": [ + "GIOPS 10 Day Daily Mean", + "WCPS Forecast", + "WCPS-GLS Great Lakes Coupled" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "type":"2D", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area Great Lakes 3.csv", + "subset_var": "Potential Temperature", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "2", + "contour_box_position":"3", + "line_transect":"yes", + "line_coordinates":"flemish_cap", + "point_coordinates":["-50.9582","44.9855"], + "tile_coordinates":["4","5","5"], + "variables_to_test":{"icevelocity":"Sea Ice Velocity","sossheig":"Sea Surface Height"} + }, + { + "name": "CIOPS Forecast East 3D -", + "id":"ciops-east_fc_3dll", + "steps": [ + "GIOPS 10 Day Daily Mean", + "CIOPS Forecast", + "CIOPS Forecast East 3D -" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "type":"3D", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "none", + "contour": "Water Velocity", + "arrow_box_position": "3", + "contour_box_position": "4", + "line_transect":"yes", + "line_coordinates":"flemish_cap", + "point_coordinates":["-50.9582","44.9855"], + "tile_coordinates":["4","5","5"], + "variables_to_test":{"sspeed":"Speed of Sound","psubsurfacechannel":"Potential Sub Surface Channel"} + + }, + { + "name": "Copernicus Marine Service Global Ocean 1/12 deg Physics Reanalysis (Monthly)", + "id":"cmems_monthly", + "steps": [ + "GIOPS 10 Day Daily Mean", + "CMEMS", + "Copernicus Marine Service Global Ocean 1/12 deg Physics Reanalysis (Monthly)" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "type":"2D", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "none", + "contour": "none", + "arrow_box_position": "2", + "contour_box_position": "3", + "line_transect":"yes", + "line_coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"], + "tile_coordinates":["4","6","6"], + "variables_to_test":{"icevelocity":"Sea Ice Velocity","zos":"Sea Surface Height"} + }, + { + "name": "SalishSeaCast 3D Currents", + "id":"salishseacast_3d_currents", + "steps": [ + "GIOPS 10 Day Daily Mean", + "SalishSeaCast", + "SalishSeaCast 3D Currents" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "type":"3D", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area Salish 3.csv", + "subset_var": "Speed of Current", + "arrows": "none", + "contour": "Eastward Current", + "arrow_box_position": "2", + "contour_box_position": "3", + "line_transect":"yes", + "line_coordinates":["-123.6","48.2","-123","48.3"], + "point_coordinates":["44.9855", "-50.9582"], + "tile_coordinates":["7","20","44"], + "variables_to_test":{"vVelocity":"Northward Current","current_speed":"Speed of Current"} + } +] \ No newline at end of file diff --git a/Front-end_tests/variable_test.spec.js b/Front-end_tests/variable_test.spec.js new file mode 100644 index 000000000..18d534660 --- /dev/null +++ b/Front-end_tests/variable_test.spec.js @@ -0,0 +1,174 @@ +import { test, expect } from "@playwright/test"; +import datasets from "./test_datasets.json"; + +test.use({ viewport: { width: 1920, height: 1080 } }); +let page; + +// test.describe.configure({ mode: "serial" }); +test.beforeAll(async ({ browser }) => { + // Create one page globally + page = await browser.newPage(); + + // Only ONE navigation for all datasets + await page.goto("http://0.0.0.0:8443/public/"); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); +}); + +test.afterAll(async () => { + await page.close(); +}); + +async function runPlotTest(dataset) { + console.log(`Running Variable test for dataset: ${dataset.name}`); + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for progress bar to detach + await page.locator(".progress").waitFor({ state: "detached" }); + + //apply dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //wait for dataset to be loaded fully + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + //verify id dataset changed properly + await expect(page.getByRole("button", { name: dataset.name })) + .toBeVisible() + .catch((err) => { + throw new Error(`Dataset didn't get changed properly: ${err.message}`); + }); + + for (const variable_key in dataset.variables_to_test) { + //select variable + await page + .getByLabel("Variable") + .selectOption({ label: dataset.variables_to_test[variable_key] }); + const tileResponsePromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes(`/api/v2.0/tiles/${dataset.id}/${variable_key}`) && + url.includes( + `${dataset.tile_coordinates[0]}/${dataset.tile_coordinates[1]}/${dataset.tile_coordinates[2]}` + ) && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } // wait up to 2 minutes + ); + + const scaleResponsePromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes(`api/v2.0/scale/${dataset.id}/${variable_key}`) && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //click on go + await page.getByRole("button", { name: "Go" }).click(); + + const tileResponse = await tileResponsePromise; + const scaleResponse = await scaleResponsePromise; + + //check response status + try { + expect(tileResponse.status()).toBe(200); + } catch (err) { + throw new Error( + `Map tiles for new variable didn't get generated: ${err.message}` + ); + } + + //check scale response + try { + expect(scaleResponse.status()).toBe(200); + } catch (err) { + throw new Error(`variable scale didn't get generated: ${err.message}`); + } + + await expect(page.getByLabel("Variable").locator("option:checked")) + .toHaveText(dataset.variables_to_test[variable_key]) + .catch((err) => { + throw new Error( + `Variable in dataset selector didn't get changed: ${err.message}` + ); + }); + } + + //reset to giops dataset + if (dataset.id != "giops_day") { + await page.getByRole("button", { name: dataset.steps.at(-1) }).click(); + await page + .getByRole("button", { name: dataset.steps.at(-2), exact: true }) + .click(); + await page.getByRole("button", { name: "GIOPS Forecast" }).click(); + await page.getByRole("button", { name: "GIOPS 10 Day Daily Mean" }).click(); + } + + //****************************************************************END OF TEST**********************************************************************// +} + +test("All datasets sequential test", async () => { + test.setTimeout(600000); + + const results = []; + + for (const dataset of datasets) { + await test.step(`Testing dataset: ${dataset.name}`, async () => { + try { + await runPlotTest(dataset); + results.push({ dataset: dataset.name, status: "passed" }); + } catch (err) { + results.push({ + dataset: dataset.name, + status: "failed", + error: err.message, + }); + console.error(`❌ Error in dataset "${dataset.name}": ${err.message}`); + // Reload the app so next dataset runs cleanly + console.log(`Reloading page after failure in ${dataset.name}...`); + + //doing a hard reload + await page.reload({ waitUntil: "domcontentloaded" }); + + // Wait for loading bar to be completed + await page.locator(".progress").waitFor({ state: "detached" }); + + //verifying if the reloaded page points to the home page + //some errors might display the api response and relaoding the page just reloads the response + const current = page.url(); + const target = "http://0.0.0.0:8443/public/"; + + if (current !== target) { + await page.goto(target, { waitUntil: "domcontentloaded" }); + } + } + }); + } + + // Report summary at the end + const failed = results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + console.log("\nTest Summary:"); + results.forEach((r) => { + console.log(`${r.dataset}: ${r.status}`); + if (r.error) console.log(` Error: ${r.error}`); + }); + throw new Error( + `${failed.length} datasets failed out of ${results.length}` + ); + } +}); diff --git a/Ocean-Navigator-Manual b/Ocean-Navigator-Manual new file mode 160000 index 000000000..39b13c1ff --- /dev/null +++ b/Ocean-Navigator-Manual @@ -0,0 +1 @@ +Subproject commit 39b13c1ff6e1d789ff2a2e39e58609bea13c4c95 diff --git a/oceannavigator/frontend/public/index.html b/oceannavigator/frontend/public/index.html index 9f5d0dc55..d7c3de5c0 100644 --- a/oceannavigator/frontend/public/index.html +++ b/oceannavigator/frontend/public/index.html @@ -21,7 +21,7 @@ - +
diff --git a/oceannavigator/frontend/public/oceannavigator.js b/oceannavigator/frontend/public/oceannavigator.js index 24fef8255..b3238572a 100644 --- a/oceannavigator/frontend/public/oceannavigator.js +++ b/oceannavigator/frontend/public/oceannavigator.js @@ -9,2513 +9,8563 @@ /******/ (() => { // webpackBootstrap /******/ var __webpack_modules__ = ({ -/***/ "./node_modules/@restart/hooks/esm/useCallbackRef.js": -/*!***********************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useCallbackRef.js ***! - \***********************************************************/ +/***/ "./node_modules/@fortawesome/react-fontawesome/index.es.js": +/*!*****************************************************************!*\ + !*** ./node_modules/@fortawesome/react-fontawesome/index.es.js ***! + \*****************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ useCallbackRef)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\n/**\n * A convenience hook around `useState` designed to be paired with\n * the component [callback ref](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) api.\n * Callback refs are useful over `useRef()` when you need to respond to the ref being set\n * instead of lazily accessing it in an effect.\n *\n * ```ts\n * const [element, attachRef] = useCallbackRef()\n *\n * useEffect(() => {\n * if (!element) return\n *\n * const calendar = new FullCalendar.Calendar(element)\n *\n * return () => {\n * calendar.destroy()\n * }\n * }, [element])\n *\n * return
\n * ```\n *\n * @category refs\n */\n\nfunction useCallbackRef() {\n return (0,react__WEBPACK_IMPORTED_MODULE_0__.useState)(null);\n}\n\n//# sourceURL=webpack://frontend/./node_modules/@restart/hooks/esm/useCallbackRef.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ FontAwesomeIcon: () => (/* binding */ FontAwesomeIcon)\n/* harmony export */ });\n/* harmony import */ var _fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @fortawesome/fontawesome-svg-core */ \"./node_modules/@fortawesome/fontawesome-svg-core/index.mjs\");\n/* harmony import */ var prop_types__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! prop-types */ \"./node_modules/prop-types/index.js\");\n/* harmony import */ var prop_types__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(prop_types__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);\n\n\n\n\nfunction ownKeys(object, enumerableOnly) {\n var keys = Object.keys(object);\n\n if (Object.getOwnPropertySymbols) {\n var symbols = Object.getOwnPropertySymbols(object);\n enumerableOnly && (symbols = symbols.filter(function (sym) {\n return Object.getOwnPropertyDescriptor(object, sym).enumerable;\n })), keys.push.apply(keys, symbols);\n }\n\n return keys;\n}\n\nfunction _objectSpread2(target) {\n for (var i = 1; i < arguments.length; i++) {\n var source = null != arguments[i] ? arguments[i] : {};\n i % 2 ? ownKeys(Object(source), !0).forEach(function (key) {\n _defineProperty(target, key, source[key]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) {\n Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));\n });\n }\n\n return target;\n}\n\nfunction _typeof(obj) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (obj) {\n return typeof obj;\n } : function (obj) {\n return obj && \"function\" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj;\n }, _typeof(obj);\n}\n\nfunction _defineProperty(obj, key, value) {\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n\n return obj;\n}\n\nfunction _objectWithoutPropertiesLoose(source, excluded) {\n if (source == null) return {};\n var target = {};\n var sourceKeys = Object.keys(source);\n var key, i;\n\n for (i = 0; i < sourceKeys.length; i++) {\n key = sourceKeys[i];\n if (excluded.indexOf(key) >= 0) continue;\n target[key] = source[key];\n }\n\n return target;\n}\n\nfunction _objectWithoutProperties(source, excluded) {\n if (source == null) return {};\n\n var target = _objectWithoutPropertiesLoose(source, excluded);\n\n var key, i;\n\n if (Object.getOwnPropertySymbols) {\n var sourceSymbolKeys = Object.getOwnPropertySymbols(source);\n\n for (i = 0; i < sourceSymbolKeys.length; i++) {\n key = sourceSymbolKeys[i];\n if (excluded.indexOf(key) >= 0) continue;\n if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue;\n target[key] = source[key];\n }\n }\n\n return target;\n}\n\nfunction _toConsumableArray(arr) {\n return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread();\n}\n\nfunction _arrayWithoutHoles(arr) {\n if (Array.isArray(arr)) return _arrayLikeToArray(arr);\n}\n\nfunction _iterableToArray(iter) {\n if (typeof Symbol !== \"undefined\" && iter[Symbol.iterator] != null || iter[\"@@iterator\"] != null) return Array.from(iter);\n}\n\nfunction _unsupportedIterableToArray(o, minLen) {\n if (!o) return;\n if (typeof o === \"string\") return _arrayLikeToArray(o, minLen);\n var n = Object.prototype.toString.call(o).slice(8, -1);\n if (n === \"Object\" && o.constructor) n = o.constructor.name;\n if (n === \"Map\" || n === \"Set\") return Array.from(o);\n if (n === \"Arguments\" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);\n}\n\nfunction _arrayLikeToArray(arr, len) {\n if (len == null || len > arr.length) len = arr.length;\n\n for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];\n\n return arr2;\n}\n\nfunction _nonIterableSpread() {\n throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\");\n}\n\n// Get CSS class list from a props object\nfunction classList(props) {\n var _classes;\n\n var beat = props.beat,\n fade = props.fade,\n beatFade = props.beatFade,\n bounce = props.bounce,\n shake = props.shake,\n flash = props.flash,\n spin = props.spin,\n spinPulse = props.spinPulse,\n spinReverse = props.spinReverse,\n pulse = props.pulse,\n fixedWidth = props.fixedWidth,\n inverse = props.inverse,\n border = props.border,\n listItem = props.listItem,\n flip = props.flip,\n size = props.size,\n rotation = props.rotation,\n pull = props.pull; // map of CSS class names to properties\n\n var classes = (_classes = {\n 'fa-beat': beat,\n 'fa-fade': fade,\n 'fa-beat-fade': beatFade,\n 'fa-bounce': bounce,\n 'fa-shake': shake,\n 'fa-flash': flash,\n 'fa-spin': spin,\n 'fa-spin-reverse': spinReverse,\n 'fa-spin-pulse': spinPulse,\n 'fa-pulse': pulse,\n 'fa-fw': fixedWidth,\n 'fa-inverse': inverse,\n 'fa-border': border,\n 'fa-li': listItem,\n 'fa-flip': flip === true,\n 'fa-flip-horizontal': flip === 'horizontal' || flip === 'both',\n 'fa-flip-vertical': flip === 'vertical' || flip === 'both'\n }, _defineProperty(_classes, \"fa-\".concat(size), typeof size !== 'undefined' && size !== null), _defineProperty(_classes, \"fa-rotate-\".concat(rotation), typeof rotation !== 'undefined' && rotation !== null && rotation !== 0), _defineProperty(_classes, \"fa-pull-\".concat(pull), typeof pull !== 'undefined' && pull !== null), _defineProperty(_classes, 'fa-swap-opacity', props.swapOpacity), _classes); // map over all the keys in the classes object\n // return an array of the keys where the value for the key is not null\n\n return Object.keys(classes).map(function (key) {\n return classes[key] ? key : null;\n }).filter(function (key) {\n return key;\n });\n}\n\n// Camelize taken from humps\n// humps is copyright © 2012+ Dom Christie\n// Released under the MIT license.\n// Performant way to determine if object coerces to a number\nfunction _isNumerical(obj) {\n obj = obj - 0; // eslint-disable-next-line no-self-compare\n\n return obj === obj;\n}\n\nfunction camelize(string) {\n if (_isNumerical(string)) {\n return string;\n } // eslint-disable-next-line no-useless-escape\n\n\n string = string.replace(/[\\-_\\s]+(.)?/g, function (match, chr) {\n return chr ? chr.toUpperCase() : '';\n }); // Ensure 1st char is always lowercase\n\n return string.substr(0, 1).toLowerCase() + string.substr(1);\n}\n\nvar _excluded = [\"style\"];\n\nfunction capitalize(val) {\n return val.charAt(0).toUpperCase() + val.slice(1);\n}\n\nfunction styleToObject(style) {\n return style.split(';').map(function (s) {\n return s.trim();\n }).filter(function (s) {\n return s;\n }).reduce(function (acc, pair) {\n var i = pair.indexOf(':');\n var prop = camelize(pair.slice(0, i));\n var value = pair.slice(i + 1).trim();\n prop.startsWith('webkit') ? acc[capitalize(prop)] = value : acc[prop] = value;\n return acc;\n }, {});\n}\n\nfunction convert(createElement, element) {\n var extraProps = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};\n\n if (typeof element === 'string') {\n return element;\n }\n\n var children = (element.children || []).map(function (child) {\n return convert(createElement, child);\n });\n /* eslint-disable dot-notation */\n\n var mixins = Object.keys(element.attributes || {}).reduce(function (acc, key) {\n var val = element.attributes[key];\n\n switch (key) {\n case 'class':\n acc.attrs['className'] = val;\n delete element.attributes['class'];\n break;\n\n case 'style':\n acc.attrs['style'] = styleToObject(val);\n break;\n\n default:\n if (key.indexOf('aria-') === 0 || key.indexOf('data-') === 0) {\n acc.attrs[key.toLowerCase()] = val;\n } else {\n acc.attrs[camelize(key)] = val;\n }\n\n }\n\n return acc;\n }, {\n attrs: {}\n });\n\n var _extraProps$style = extraProps.style,\n existingStyle = _extraProps$style === void 0 ? {} : _extraProps$style,\n remaining = _objectWithoutProperties(extraProps, _excluded);\n\n mixins.attrs['style'] = _objectSpread2(_objectSpread2({}, mixins.attrs['style']), existingStyle);\n /* eslint-enable */\n\n return createElement.apply(void 0, [element.tag, _objectSpread2(_objectSpread2({}, mixins.attrs), remaining)].concat(_toConsumableArray(children)));\n}\n\nvar PRODUCTION = false;\n\ntry {\n PRODUCTION = \"development\" === 'production';\n} catch (e) {}\n\nfunction log () {\n if (!PRODUCTION && console && typeof console.error === 'function') {\n var _console;\n\n (_console = console).error.apply(_console, arguments);\n }\n}\n\nfunction normalizeIconArgs(icon) {\n // this has everything that it needs to be rendered which means it was probably imported\n // directly from an icon svg package\n if (icon && _typeof(icon) === 'object' && icon.prefix && icon.iconName && icon.icon) {\n return icon;\n }\n\n if (_fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.parse.icon) {\n return _fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.parse.icon(icon);\n } // if the icon is null, there's nothing to do\n\n\n if (icon === null) {\n return null;\n } // if the icon is an object and has a prefix and an icon name, return it\n\n\n if (icon && _typeof(icon) === 'object' && icon.prefix && icon.iconName) {\n return icon;\n } // if it's an array with length of two\n\n\n if (Array.isArray(icon) && icon.length === 2) {\n // use the first item as prefix, second as icon name\n return {\n prefix: icon[0],\n iconName: icon[1]\n };\n } // if it's a string, use it as the icon name\n\n\n if (typeof icon === 'string') {\n return {\n prefix: 'fas',\n iconName: icon\n };\n }\n}\n\n// creates an object with a key of key\n// and a value of value\n// if certain conditions are met\nfunction objectWithKey(key, value) {\n // if the value is a non-empty array\n // or it's not an array but it is truthy\n // then create the object with the key and the value\n // if not, return an empty array\n return Array.isArray(value) && value.length > 0 || !Array.isArray(value) && value ? _defineProperty({}, key, value) : {};\n}\n\nvar defaultProps = {\n border: false,\n className: '',\n mask: null,\n maskId: null,\n fixedWidth: false,\n inverse: false,\n flip: false,\n icon: null,\n listItem: false,\n pull: null,\n pulse: false,\n rotation: null,\n size: null,\n spin: false,\n spinPulse: false,\n spinReverse: false,\n beat: false,\n fade: false,\n beatFade: false,\n bounce: false,\n shake: false,\n symbol: false,\n title: '',\n titleId: null,\n transform: null,\n swapOpacity: false\n};\nvar FontAwesomeIcon = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().forwardRef(function (props, ref) {\n var allProps = _objectSpread2(_objectSpread2({}, defaultProps), props);\n\n var iconArgs = allProps.icon,\n maskArgs = allProps.mask,\n symbol = allProps.symbol,\n className = allProps.className,\n title = allProps.title,\n titleId = allProps.titleId,\n maskId = allProps.maskId;\n var iconLookup = normalizeIconArgs(iconArgs);\n var classes = objectWithKey('classes', [].concat(_toConsumableArray(classList(allProps)), _toConsumableArray((className || '').split(' '))));\n var transform = objectWithKey('transform', typeof allProps.transform === 'string' ? _fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.parse.transform(allProps.transform) : allProps.transform);\n var mask = objectWithKey('mask', normalizeIconArgs(maskArgs));\n var renderedIcon = (0,_fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.icon)(iconLookup, _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({}, classes), transform), mask), {}, {\n symbol: symbol,\n title: title,\n titleId: titleId,\n maskId: maskId\n }));\n\n if (!renderedIcon) {\n log('Could not find icon', iconLookup);\n return null;\n }\n\n var abstract = renderedIcon.abstract;\n var extraProps = {\n ref: ref\n };\n Object.keys(allProps).forEach(function (key) {\n // eslint-disable-next-line no-prototype-builtins\n if (!defaultProps.hasOwnProperty(key)) {\n extraProps[key] = allProps[key];\n }\n });\n return convertCurry(abstract[0], extraProps);\n});\nFontAwesomeIcon.displayName = 'FontAwesomeIcon';\nFontAwesomeIcon.propTypes = {\n beat: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n border: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n beatFade: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n bounce: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n className: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n fade: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n flash: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n mask: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().object), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().array), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string)]),\n maskId: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n fixedWidth: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n inverse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n flip: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf([true, false, 'horizontal', 'vertical', 'both']),\n icon: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().object), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().array), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string)]),\n listItem: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n pull: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf(['right', 'left']),\n pulse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n rotation: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf([0, 90, 180, 270]),\n shake: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n size: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf(['2xs', 'xs', 'sm', 'lg', 'xl', '2xl', '1x', '2x', '3x', '4x', '5x', '6x', '7x', '8x', '9x', '10x']),\n spin: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n spinPulse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n spinReverse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n symbol: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string)]),\n title: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n titleId: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n transform: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().string), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().object)]),\n swapOpacity: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool)\n};\nvar convertCurry = convert.bind(null, (react__WEBPACK_IMPORTED_MODULE_1___default().createElement));\n\n\n\n\n//# sourceURL=webpack://frontend/./node_modules/@fortawesome/react-fontawesome/index.es.js?"); /***/ }), -/***/ "./node_modules/@restart/hooks/esm/useCommittedRef.js": -/*!************************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useCommittedRef.js ***! - \************************************************************/ +/***/ "./node_modules/@popperjs/core/lib/createPopper.js": +/*!*********************************************************!*\ + !*** ./node_modules/@popperjs/core/lib/createPopper.js ***! + \*********************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\n/**\n * Creates a `Ref` whose value is updated in an effect, ensuring the most recent\n * value is the one rendered with. Generally only required for Concurrent mode usage\n * where previous work in `render()` may be discarded before being used.\n *\n * This is safe to access in an event handler.\n *\n * @param value The `Ref` value\n */\n\nfunction useCommittedRef(value) {\n var ref = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)(value);\n (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(function () {\n ref.current = value;\n }, [value]);\n return ref;\n}\n\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (useCommittedRef);\n\n//# sourceURL=webpack://frontend/./node_modules/@restart/hooks/esm/useCommittedRef.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ createPopper: () => (/* binding */ createPopper),\n/* harmony export */ detectOverflow: () => (/* reexport safe */ _utils_detectOverflow_js__WEBPACK_IMPORTED_MODULE_8__[\"default\"]),\n/* harmony export */ popperGenerator: () => (/* binding */ popperGenerator)\n/* harmony export */ });\n/* harmony import */ var _dom_utils_getCompositeRect_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./dom-utils/getCompositeRect.js */ \"./node_modules/@popperjs/core/lib/dom-utils/getCompositeRect.js\");\n/* harmony import */ var _dom_utils_getLayoutRect_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./dom-utils/getLayoutRect.js */ \"./node_modules/@popperjs/core/lib/dom-utils/getLayoutRect.js\");\n/* harmony import */ var _dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./dom-utils/listScrollParents.js */ \"./node_modules/@popperjs/core/lib/dom-utils/listScrollParents.js\");\n/* harmony import */ var _dom_utils_getOffsetParent_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./dom-utils/getOffsetParent.js */ \"./node_modules/@popperjs/core/lib/dom-utils/getOffsetParent.js\");\n/* harmony import */ var _utils_orderModifiers_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./utils/orderModifiers.js */ \"./node_modules/@popperjs/core/lib/utils/orderModifiers.js\");\n/* harmony import */ var _utils_debounce_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./utils/debounce.js */ \"./node_modules/@popperjs/core/lib/utils/debounce.js\");\n/* harmony import */ var _utils_mergeByName_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./utils/mergeByName.js */ \"./node_modules/@popperjs/core/lib/utils/mergeByName.js\");\n/* harmony import */ var _utils_detectOverflow_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./utils/detectOverflow.js */ \"./node_modules/@popperjs/core/lib/utils/detectOverflow.js\");\n/* harmony import */ var _dom_utils_instanceOf_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./dom-utils/instanceOf.js */ \"./node_modules/@popperjs/core/lib/dom-utils/instanceOf.js\");\n\n\n\n\n\n\n\n\n\nvar DEFAULT_OPTIONS = {\n placement: 'bottom',\n modifiers: [],\n strategy: 'absolute'\n};\n\nfunction areValidElements() {\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n\n return !args.some(function (element) {\n return !(element && typeof element.getBoundingClientRect === 'function');\n });\n}\n\nfunction popperGenerator(generatorOptions) {\n if (generatorOptions === void 0) {\n generatorOptions = {};\n }\n\n var _generatorOptions = generatorOptions,\n _generatorOptions$def = _generatorOptions.defaultModifiers,\n defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def,\n _generatorOptions$def2 = _generatorOptions.defaultOptions,\n defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2;\n return function createPopper(reference, popper, options) {\n if (options === void 0) {\n options = defaultOptions;\n }\n\n var state = {\n placement: 'bottom',\n orderedModifiers: [],\n options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions),\n modifiersData: {},\n elements: {\n reference: reference,\n popper: popper\n },\n attributes: {},\n styles: {}\n };\n var effectCleanupFns = [];\n var isDestroyed = false;\n var instance = {\n state: state,\n setOptions: function setOptions(setOptionsAction) {\n var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction;\n cleanupModifierEffects();\n state.options = Object.assign({}, defaultOptions, state.options, options);\n state.scrollParents = {\n reference: (0,_dom_utils_instanceOf_js__WEBPACK_IMPORTED_MODULE_0__.isElement)(reference) ? (0,_dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(reference) : reference.contextElement ? (0,_dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(reference.contextElement) : [],\n popper: (0,_dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(popper)\n }; // Orders the modifiers based on their dependencies and `phase`\n // properties\n\n var orderedModifiers = (0,_utils_orderModifiers_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"])((0,_utils_mergeByName_js__WEBPACK_IMPORTED_MODULE_3__[\"default\"])([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers\n\n state.orderedModifiers = orderedModifiers.filter(function (m) {\n return m.enabled;\n });\n runModifierEffects();\n return instance.update();\n },\n // Sync update – it will always be executed, even if not necessary. This\n // is useful for low frequency updates where sync behavior simplifies the\n // logic.\n // For high frequency updates (e.g. `resize` and `scroll` events), always\n // prefer the async Popper#update method\n forceUpdate: function forceUpdate() {\n if (isDestroyed) {\n return;\n }\n\n var _state$elements = state.elements,\n reference = _state$elements.reference,\n popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements\n // anymore\n\n if (!areValidElements(reference, popper)) {\n return;\n } // Store the reference and popper rects to be read by modifiers\n\n\n state.rects = {\n reference: (0,_dom_utils_getCompositeRect_js__WEBPACK_IMPORTED_MODULE_4__[\"default\"])(reference, (0,_dom_utils_getOffsetParent_js__WEBPACK_IMPORTED_MODULE_5__[\"default\"])(popper), state.options.strategy === 'fixed'),\n popper: (0,_dom_utils_getLayoutRect_js__WEBPACK_IMPORTED_MODULE_6__[\"default\"])(popper)\n }; // Modifiers have the ability to reset the current update cycle. The\n // most common use case for this is the `flip` modifier changing the\n // placement, which then needs to re-run all the modifiers, because the\n // logic was previously ran for the previous placement and is therefore\n // stale/incorrect\n\n state.reset = false;\n state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier\n // is filled with the initial data specified by the modifier. This means\n // it doesn't persist and is fresh on each update.\n // To ensure persistent data, use `${name}#persistent`\n\n state.orderedModifiers.forEach(function (modifier) {\n return state.modifiersData[modifier.name] = Object.assign({}, modifier.data);\n });\n\n for (var index = 0; index < state.orderedModifiers.length; index++) {\n if (state.reset === true) {\n state.reset = false;\n index = -1;\n continue;\n }\n\n var _state$orderedModifie = state.orderedModifiers[index],\n fn = _state$orderedModifie.fn,\n _state$orderedModifie2 = _state$orderedModifie.options,\n _options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2,\n name = _state$orderedModifie.name;\n\n if (typeof fn === 'function') {\n state = fn({\n state: state,\n options: _options,\n name: name,\n instance: instance\n }) || state;\n }\n }\n },\n // Async and optimistically optimized update – it will not be executed if\n // not necessary (debounced to run at most once-per-tick)\n update: (0,_utils_debounce_js__WEBPACK_IMPORTED_MODULE_7__[\"default\"])(function () {\n return new Promise(function (resolve) {\n instance.forceUpdate();\n resolve(state);\n });\n }),\n destroy: function destroy() {\n cleanupModifierEffects();\n isDestroyed = true;\n }\n };\n\n if (!areValidElements(reference, popper)) {\n return instance;\n }\n\n instance.setOptions(options).then(function (state) {\n if (!isDestroyed && options.onFirstUpdate) {\n options.onFirstUpdate(state);\n }\n }); // Modifiers have the ability to execute arbitrary code before the first\n // update cycle runs. They will be executed in the same order as the update\n // cycle. This is useful when a modifier adds some persistent data that\n // other modifiers need to use, but the modifier is run after the dependent\n // one.\n\n function runModifierEffects() {\n state.orderedModifiers.forEach(function (_ref) {\n var name = _ref.name,\n _ref$options = _ref.options,\n options = _ref$options === void 0 ? {} : _ref$options,\n effect = _ref.effect;\n\n if (typeof effect === 'function') {\n var cleanupFn = effect({\n state: state,\n name: name,\n instance: instance,\n options: options\n });\n\n var noopFn = function noopFn() {};\n\n effectCleanupFns.push(cleanupFn || noopFn);\n }\n });\n }\n\n function cleanupModifierEffects() {\n effectCleanupFns.forEach(function (fn) {\n return fn();\n });\n effectCleanupFns = [];\n }\n\n return instance;\n };\n}\nvar createPopper = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules\n\n\n\n//# sourceURL=webpack://frontend/./node_modules/@popperjs/core/lib/createPopper.js?"); /***/ }), -/***/ "./node_modules/@restart/hooks/esm/useEventCallback.js": -/*!*************************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useEventCallback.js ***! - \*************************************************************/ +/***/ "./node_modules/@popperjs/core/lib/dom-utils/contains.js": +/*!***************************************************************!*\ + !*** ./node_modules/@popperjs/core/lib/dom-utils/contains.js ***! + \***************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ useEventCallback)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _useCommittedRef__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./useCommittedRef */ \"./node_modules/@restart/hooks/esm/useCommittedRef.js\");\n\n\nfunction useEventCallback(fn) {\n var ref = (0,_useCommittedRef__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(fn);\n return (0,react__WEBPACK_IMPORTED_MODULE_0__.useCallback)(function () {\n return ref.current && ref.current.apply(ref, arguments);\n }, [ref]);\n}\n\n//# sourceURL=webpack://frontend/./node_modules/@restart/hooks/esm/useEventCallback.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ contains)\n/* harmony export */ });\n/* harmony import */ var _instanceOf_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./instanceOf.js */ \"./node_modules/@popperjs/core/lib/dom-utils/instanceOf.js\");\n\nfunction contains(parent, child) {\n var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method\n\n if (parent.contains(child)) {\n return true;\n } // then fallback to custom implementation with Shadow DOM support\n else if (rootNode && (0,_instanceOf_js__WEBPACK_IMPORTED_MODULE_0__.isShadowRoot)(rootNode)) {\n var next = child;\n\n do {\n if (next && parent.isSameNode(next)) {\n return true;\n } // $FlowFixMe[prop-missing]: need a better way to handle this...\n\n\n next = next.parentNode || next.host;\n } while (next);\n } // Give up, the result is false\n\n\n return false;\n}\n\n//# sourceURL=webpack://frontend/./node_modules/@popperjs/core/lib/dom-utils/contains.js?"); /***/ }), -/***/ "./node_modules/@restart/hooks/esm/useMergedRefs.js": -/*!**********************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useMergedRefs.js ***! - \**********************************************************/ +/***/ "./node_modules/@popperjs/core/lib/dom-utils/getBoundingClientRect.js": +/*!****************************************************************************!*\ + !*** ./node_modules/@popperjs/core/lib/dom-utils/getBoundingClientRect.js ***! + \****************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__),\n/* harmony export */ \"mergeRefs\": () => (/* binding */ mergeRefs)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\n\nvar toFnRef = function toFnRef(ref) {\n return !ref || typeof ref === 'function' ? ref : function (value) {\n ref.current = value;\n };\n};\n\nfunction mergeRefs(refA, refB) {\n var a = toFnRef(refA);\n var b = toFnRef(refB);\n return function (value) {\n if (a) a(value);\n if (b) b(value);\n };\n}\n/**\n * Create and returns a single callback ref composed from two other Refs.\n *\n * ```tsx\n * const Button = React.forwardRef((props, ref) => {\n * const [element, attachRef] = useCallbackRef();\n * const mergedRef = useMergedRefs(ref, attachRef);\n *\n * return