diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..3eb13143 --- /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 29bd46de..f4d9d00c 100755 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ yarn.lock npm-debug.log* yarn-debug.log* yarn-error.log* + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 2f66e7d0..0aaf9906 100755 --- a/README.md +++ b/README.md @@ -23,6 +23,25 @@ Builds and bundles the minified app for production to the `./build` folder. Your app is ready to be deployed! +## Testing + +This project includes end-to-end tests using [Playwright](https://playwright.dev/) to ensure the application works correctly across different scenarios. + +### Running Tests + +```bash +# Run all e2e tests in headless mode +npm run test:e2e + +# Run tests with visible browser (useful for debugging) +npm run test:e2e:headed + +# Open Playwright Test UI for interactive testing +npm run test:e2e:ui +``` + +Tests automatically start the development server if it's not already running. + ## Get started with Docker ```bash git clone https://github.com/nilsnolde/valhalla-app.git diff --git a/e2e/helpers.js b/e2e/helpers.js new file mode 100644 index 00000000..5c265f1b --- /dev/null +++ b/e2e/helpers.js @@ -0,0 +1,673 @@ +export const BERLIN_COORDINATES = { + lat: 52.507027222951635, + lon: 13.385467529296877, + bounds: { + minLat: 52.0, + maxLat: 53.0, + minLon: 13.0, + maxLon: 14.0, + }, +} + +export const mockNominatimResponse = { + place_id: 123456, + licence: 'Data © OpenStreetMap contributors, ODbL 1.0.', + osm_type: 'way', + osm_id: 12345, + lat: BERLIN_COORDINATES.lat.toString(), + lon: BERLIN_COORDINATES.lon.toString(), + display_name: 'Unter den Linden, Mitte, Berlin, Germany', + address: { + road: 'Unter den Linden', + suburb: 'Mitte', + city: 'Berlin', + country: 'Germany', + country_code: 'de', + }, +} + +export const simpleMockNominatimResponse = { + place_id: 123456, + lat: BERLIN_COORDINATES.lat.toString(), + lon: BERLIN_COORDINATES.lon.toString(), + display_name: 'Brandenburg Gate, Berlin, Germany', +} + +export const mockRouteResponse = { + trip: { + locations: [ + { + type: 'break', + lat: 52.534811, + lon: 13.360748, + side_of_street: 'right', + original_index: 0, + }, + { + type: 'break', + lat: 52.502429, + lon: 13.373451, + original_index: 1, + }, + ], + legs: [ + { + maneuvers: [ + { + type: 2, + instruction: 'Bike southeast.', + verbal_succinct_transition_instruction: 'Bike southeast.', + verbal_pre_transition_instruction: 'Bike southeast.', + verbal_post_transition_instruction: 'Continue for 800 meters.', + bearing_after: 147, + time: 139.839, + length: 0.768, + cost: 258.696, + begin_shape_index: 0, + end_shape_index: 13, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left onto Döberitzer Straße.', + verbal_transition_alert_instruction: + 'Turn left onto Döberitzer Straße.', + verbal_succinct_transition_instruction: 'Turn left.', + verbal_pre_transition_instruction: + 'Turn left onto Döberitzer Straße.', + verbal_post_transition_instruction: 'Continue for 100 meters.', + street_names: ['Döberitzer Straße'], + bearing_before: 137, + bearing_after: 68, + time: 65.146, + length: 0.122, + cost: 443.177, + begin_shape_index: 13, + end_shape_index: 20, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right onto Heidestraße/B 96.', + verbal_transition_alert_instruction: 'Turn right onto Heidestraße.', + verbal_succinct_transition_instruction: 'Turn right.', + verbal_pre_transition_instruction: + 'Turn right onto Heidestraße, B 96.', + verbal_post_transition_instruction: 'Continue for 80 meters.', + street_names: ['Heidestraße', 'B 96'], + bearing_before: 69, + bearing_after: 158, + time: 13.628, + length: 0.082, + cost: 58.346, + begin_shape_index: 20, + end_shape_index: 23, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right toward B 96/Schöneberg/Kreuzberg.', + verbal_transition_alert_instruction: 'Turn right toward B 96.', + verbal_succinct_transition_instruction: + 'Turn right toward B 96, Schöneberg.', + verbal_pre_transition_instruction: + 'Turn right toward B 96, Schöneberg.', + verbal_post_transition_instruction: + 'Continue on Minna-Cauer-Straße for 400 meters.', + street_names: ['Minna-Cauer-Straße'], + begin_street_names: ['Minna-Cauer-Straße', 'B 96'], + bearing_before: 157, + bearing_after: 239, + time: 65.117, + length: 0.39, + cost: 136.228, + begin_shape_index: 23, + end_shape_index: 48, + sign: {}, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right toward A 100.', + verbal_transition_alert_instruction: 'Turn right toward A 100.', + verbal_succinct_transition_instruction: 'Turn right toward A 100.', + verbal_pre_transition_instruction: 'Turn right toward A 100.', + verbal_post_transition_instruction: 'Continue for 200 meters.', + street_names: ['Invalidenstraße'], + bearing_before: 158, + bearing_after: 240, + time: 34.304, + length: 0.151, + cost: 124.472, + begin_shape_index: 48, + end_shape_index: 53, + sign: {}, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left toward Hauptbahnhof.', + verbal_transition_alert_instruction: + 'Turn left toward Hauptbahnhof.', + verbal_succinct_transition_instruction: + 'Turn left toward Hauptbahnhof.', + verbal_pre_transition_instruction: 'Turn left toward Hauptbahnhof.', + verbal_post_transition_instruction: 'Continue for 200 meters.', + street_names: ['Clara-Jaschke-Straße'], + bearing_before: 238, + bearing_after: 131, + time: 53.684, + length: 0.249, + cost: 108.073, + begin_shape_index: 53, + end_shape_index: 75, + sign: {}, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left onto Bertha-Benz-Straße.', + verbal_transition_alert_instruction: + 'Turn left onto Bertha-Benz-Straße.', + verbal_succinct_transition_instruction: 'Turn left.', + verbal_pre_transition_instruction: + 'Turn left onto Bertha-Benz-Straße.', + verbal_post_transition_instruction: 'Continue for 100 meters.', + street_names: ['Bertha-Benz-Straße'], + bearing_before: 179, + bearing_after: 90, + time: 41.041, + length: 0.141, + cost: 93.726, + begin_shape_index: 75, + end_shape_index: 86, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right onto Ella-Trebe-Straße.', + verbal_transition_alert_instruction: + 'Turn right onto Ella-Trebe-Straße.', + verbal_succinct_transition_instruction: 'Turn right.', + verbal_pre_transition_instruction: + 'Turn right onto Ella-Trebe-Straße.', + verbal_post_transition_instruction: 'Continue for 80 meters.', + street_names: ['Ella-Trebe-Straße'], + bearing_before: 90, + bearing_after: 180, + time: 20.584, + length: 0.082, + cost: 48.143, + begin_shape_index: 86, + end_shape_index: 95, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right onto Rahel-Hirsch-Straße.', + verbal_transition_alert_instruction: + 'Turn right onto Rahel-Hirsch-Straße.', + verbal_succinct_transition_instruction: + 'Turn right. Then Turn left onto Moltkebrücke.', + verbal_pre_transition_instruction: + 'Turn right onto Rahel-Hirsch-Straße. Then Turn left onto Moltkebrücke.', + verbal_post_transition_instruction: 'Continue for 30 meters.', + street_names: ['Rahel-Hirsch-Straße'], + bearing_before: 166, + bearing_after: 223, + time: 7.92, + length: 0.029, + cost: 36.759, + begin_shape_index: 95, + end_shape_index: 99, + verbal_multi_cue: true, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left onto Moltkebrücke.', + verbal_transition_alert_instruction: 'Turn left onto Moltkebrücke.', + verbal_succinct_transition_instruction: 'Turn left.', + verbal_pre_transition_instruction: 'Turn left onto Moltkebrücke.', + verbal_post_transition_instruction: 'Continue for 100 meters.', + street_names: ['Moltkebrücke'], + bearing_before: 213, + bearing_after: 142, + time: 27.04, + length: 0.116, + cost: 68.274, + begin_shape_index: 99, + end_shape_index: 103, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 8, + instruction: 'Continue on Willy-Brandt-Straße.', + verbal_transition_alert_instruction: + 'Continue on Willy-Brandt-Straße.', + verbal_pre_transition_instruction: + 'Continue on Willy-Brandt-Straße.', + verbal_post_transition_instruction: 'Continue for 300 meters.', + street_names: ['Willy-Brandt-Straße'], + bearing_before: 133, + bearing_after: 129, + time: 65.415, + length: 0.275, + cost: 129.892, + begin_shape_index: 103, + end_shape_index: 114, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 8, + instruction: 'Continue on Heinrich-von-Gagern-Straße.', + verbal_transition_alert_instruction: + 'Continue on Heinrich-von-Gagern-Straße.', + verbal_pre_transition_instruction: + 'Continue on Heinrich-von-Gagern-Straße.', + verbal_post_transition_instruction: 'Continue for 400 meters.', + street_names: ['Heinrich-von-Gagern-Straße'], + bearing_before: 180, + bearing_after: 180, + time: 79.557, + length: 0.41, + cost: 198.702, + begin_shape_index: 114, + end_shape_index: 127, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 9, + instruction: 'Bear right onto the cycleway.', + verbal_transition_alert_instruction: + 'Bear right onto the cycleway.', + verbal_succinct_transition_instruction: + 'Bear right. Then Continue.', + verbal_pre_transition_instruction: + 'Bear right onto the cycleway. Then Continue.', + verbal_post_transition_instruction: 'Continue for 50 meters.', + bearing_before: 184, + bearing_after: 227, + time: 25.293, + length: 0.049, + cost: 65.833, + begin_shape_index: 127, + end_shape_index: 133, + verbal_multi_cue: true, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 8, + instruction: 'Continue.', + verbal_transition_alert_instruction: 'Continue.', + verbal_pre_transition_instruction: + 'Continue. Then Turn right onto Bremer Weg.', + verbal_post_transition_instruction: 'Continue for 30 meters.', + bearing_before: 212, + bearing_after: 196, + time: 8.078, + length: 0.026, + cost: 19.361, + begin_shape_index: 133, + end_shape_index: 135, + verbal_multi_cue: true, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right onto Bremer Weg.', + verbal_transition_alert_instruction: 'Turn right onto Bremer Weg.', + verbal_succinct_transition_instruction: 'Turn right.', + verbal_pre_transition_instruction: 'Turn right onto Bremer Weg.', + verbal_post_transition_instruction: 'Continue for 200 meters.', + street_names: ['Bremer Weg'], + bearing_before: 196, + bearing_after: 266, + time: 44.907, + length: 0.225, + cost: 88.153, + begin_shape_index: 135, + end_shape_index: 141, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left.', + verbal_transition_alert_instruction: 'Turn left.', + verbal_succinct_transition_instruction: 'Turn left.', + verbal_pre_transition_instruction: 'Turn left.', + verbal_post_transition_instruction: 'Continue for 200 meters.', + bearing_before: 263, + bearing_after: 171, + time: 51.355, + length: 0.226, + cost: 112.713, + begin_shape_index: 141, + end_shape_index: 149, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left onto Bellevueallee.', + verbal_transition_alert_instruction: + 'Turn left onto Bellevueallee.', + verbal_succinct_transition_instruction: 'Turn left.', + verbal_pre_transition_instruction: 'Turn left onto Bellevueallee.', + verbal_post_transition_instruction: 'Continue for 400 meters.', + street_names: ['Bellevueallee'], + bearing_before: 203, + bearing_after: 118, + time: 72.987, + length: 0.417, + cost: 149.007, + begin_shape_index: 149, + end_shape_index: 155, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 16, + instruction: 'Bear left onto the cycleway.', + verbal_transition_alert_instruction: 'Bear left onto the cycleway.', + verbal_succinct_transition_instruction: 'Bear left.', + verbal_pre_transition_instruction: 'Bear left onto the cycleway.', + verbal_post_transition_instruction: 'Continue for 200 meters.', + bearing_before: 165, + bearing_after: 128, + time: 72.96, + length: 0.23, + cost: 170.325, + begin_shape_index: 155, + end_shape_index: 169, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: + 'Turn right onto Potsdamer Straße/B 1. Continue on B 1.', + verbal_transition_alert_instruction: + 'Turn right onto Potsdamer Straße.', + verbal_succinct_transition_instruction: 'Turn right.', + verbal_pre_transition_instruction: + 'Turn right onto Potsdamer Straße, B 1.', + verbal_post_transition_instruction: + 'Continue on B 1 for 400 meters.', + street_names: ['B 1'], + begin_street_names: ['Potsdamer Straße', 'B 1'], + bearing_before: 165, + bearing_after: 263, + time: 79.856, + length: 0.442, + cost: 206.721, + begin_shape_index: 169, + end_shape_index: 206, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 16, + instruction: 'Bear left onto Potsdamer Brücke.', + verbal_transition_alert_instruction: + 'Bear left onto Potsdamer Brücke.', + verbal_succinct_transition_instruction: + 'Bear left. Then Turn left onto Schöneberger Ufer.', + verbal_pre_transition_instruction: + 'Bear left onto Potsdamer Brücke. Then Turn left onto Schöneberger Ufer.', + verbal_post_transition_instruction: 'Continue for 40 meters.', + street_names: ['Potsdamer Brücke'], + bearing_before: 202, + bearing_after: 169, + time: 5.489, + length: 0.035, + cost: 15.887, + begin_shape_index: 206, + end_shape_index: 211, + verbal_multi_cue: true, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 15, + instruction: 'Turn left onto Schöneberger Ufer.', + verbal_transition_alert_instruction: + 'Turn left onto Schöneberger Ufer.', + verbal_succinct_transition_instruction: 'Turn left.', + verbal_pre_transition_instruction: + 'Turn left onto Schöneberger Ufer.', + verbal_post_transition_instruction: 'Continue for 500 meters.', + street_names: ['Schöneberger Ufer'], + bearing_before: 166, + bearing_after: 89, + time: 88.851, + length: 0.486, + cost: 176.304, + begin_shape_index: 211, + end_shape_index: 246, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 10, + instruction: 'Turn right.', + verbal_transition_alert_instruction: 'Turn right.', + verbal_succinct_transition_instruction: 'Turn right.', + verbal_pre_transition_instruction: 'Turn right.', + verbal_post_transition_instruction: 'Continue for 80 meters.', + bearing_before: 135, + bearing_after: 195, + time: 19.637, + length: 0.075, + cost: 42.1, + begin_shape_index: 246, + end_shape_index: 253, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + { + type: 4, + instruction: 'You have arrived at your destination.', + verbal_transition_alert_instruction: + 'You will arrive at your destination.', + verbal_pre_transition_instruction: + 'You have arrived at your destination.', + bearing_before: 188, + time: 0, + length: 0, + cost: 0, + begin_shape_index: 253, + end_shape_index: 253, + travel_mode: 'bicycle', + travel_type: 'hybrid', + }, + ], + summary: { + has_time_restrictions: false, + has_toll: false, + has_highway: false, + has_ferry: false, + min_lat: 52.502431, + min_lon: 13.361159, + max_lat: 52.534975, + max_lon: 13.373645, + time: 1082.698, + length: 5.029, + cost: 2750.902, + }, + shape: + '_knecBm_onXlAsAlkAypA`iAmnAhYoYjQyNxRyPvg@mk@lr@wbAvEy@fD}@|BeBvBmBl[oj@q@mEwBoNkGma@gBmLiCqPWeBcBkLbLyGbS{LpHqEbCnJfFfSzG|ShG|NrIrNnFtFfIrInEtEdBrDx@x@fE`CrE|AxDp@pIv@jc@dBl[lAhB@t@EbAM~@UzAq@bAi@dAi@pAw@vBoAlCxKnOdu@bHx\\b@tBhGxTjAoCn@wA~AuDtCyGj@a@^UzAeA`CoArDqAtCUxEIpCIh@CnAEf@AfBGj@C~Sq@xHWhFQxDCnb@[?q@AmD?yAAol@?k@A}F@eF?m@Dsl@@kA?}E^?d@?hA?\\?xC?pG?nSA`AYjDeAtBbE|@jAzApA~BxCvF_IZs@l@wAhb@g}@Pc@zDyIz@kBp\\gu@`CoF|IyRx@aBpA?j_A@dA?pA?xB?vE@jC?|wA^`A@~D`AvEfBr@XhBnAnS|IfhAhf@~D~@b@BzA`D|BtCdBRfFx@bDb@z@|@bA^~JnD\\|S|@bc@dBnw@j@tO\\bOpCxz@jm@sJvH`AxPzDdH|AhFbChClAbLzGpNzIl_A_cEhEeRrXmmAbCsKdKkDpHcChAyCZy@nC{@|B}@tCkAh@ObH}BhGwAvY_FrZ{EtY}EbAO~B[lBo@LrDPlHj@~LZtEf@xE|@bFr@pC^hAf@zAnAdDvHpRfDpIhCnGfArC`CpFpAxBjA`BhBtBd@^jAbAzAdAxBjAhAl@bf@fUhFfClD`BzCtAjEx@tCx@hA^|DhArf@nT|@^l@V~CtAzDvBxCfB`C_@rBi@rBi@hBi@rC{@AaGBgBD{AFmAHaA^gD\\}C^kDz@eI|@qHjAyGlCwNhIa\\vNsf@`@iAvOgf@Xy@ZiAnAuD~AmEfAsC~AoE`O_]lHmLtCyDdHmItDiEbKgIhEkD~@u@`BeAbCwBfPgNrFyGP]nBn@bAZd@NdA\\`Bj@~V~ExDj@', + }, + ], + summary: { + has_time_restrictions: false, + has_toll: false, + has_highway: false, + has_ferry: false, + min_lat: 52.502431, + min_lon: 13.361159, + max_lat: 52.534975, + max_lon: 13.373645, + time: 1082.698, + length: 5.029, + cost: 2750.902, + }, + status_message: 'Found route between points', + status: 0, + units: 'kilometers', + language: 'en-US', + }, + id: 'valhalla_directions', +} + +export const mockHeightResponse = { + shape: [ + { + lat: 52.517317, + lon: 13.370447, + }, + ], + height: [34], + id: 'valhalla_height', +} + +export const mockLocateResponse = [ + { + input_lat: 52.51246, + input_lon: 13.363323, + edges: [ + { + way_id: 117116622, + correlated_lat: 52.512263, + correlated_lon: 13.363185, + side_of_street: 'right', + percent_along: 0.1334, + }, + { + way_id: 117116622, + correlated_lat: 52.512263, + correlated_lon: 13.363185, + side_of_street: 'left', + percent_along: 0.86659, + }, + ], + nodes: [], + }, +] + +export async function setupNominatimMock( + page, + response = mockNominatimResponse +) { + const apiRequests = [] + + await page.route( + '**/nominatim.openstreetmap.org/reverse**', + async (route) => { + const request = route.request() + const url = request.url() + const urlObj = new URL(url) + + apiRequests.push({ + url, + method: request.method(), + params: { + lon: urlObj.searchParams.get('lon'), + lat: urlObj.searchParams.get('lat'), + format: urlObj.searchParams.get('format'), + }, + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }) + } + ) + + return apiRequests +} + +export async function setupRouteMock(page, response = mockRouteResponse) { + const apiRequests = [] + + await page.route('**/valhalla1.openstreetmap.de/route**', async (route) => { + const request = route.request() + const url = request.url() + + apiRequests.push({ + url, + method: request.method(), + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }) + }) + + return apiRequests +} + +export async function setupHeightMock(page, response = mockHeightResponse) { + const apiRequests = [] + + await page.route('**/valhalla1.openstreetmap.de/height**', async (route) => { + const request = route.request() + const url = request.url() + + apiRequests.push({ + url, + method: request.method(), + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }) + }) + + return apiRequests +} + +export async function setupLocateMock(page, response = mockLocateResponse) { + const apiRequests = [] + + await page.route('**/valhalla1.openstreetmap.de/locate', async (route) => { + const request = route.request() + const url = request.url() + const body = await request.postData() + + apiRequests.push({ + url, + method: request.method(), + body: JSON.parse(body || '{}'), + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }) + }) + + return apiRequests +} diff --git a/e2e/homepage.spec.js b/e2e/homepage.spec.js new file mode 100644 index 00000000..db0a1947 --- /dev/null +++ b/e2e/homepage.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/') +}) + +test('has title', async ({ page }) => { + await expect(page).toHaveTitle(/Valhalla FOSSGIS/) +}) + +test('has default elements in the page', async ({ page }) => { + await page.goto('http://localhost:3000/') + await expect( + page.locator('div').filter({ + hasText: /^Calculations by Valhalla • Visualized with Valhalla App$/, + }) + ).toBeVisible() + await expect(page.getByTestId('map')).toBeVisible() + await expect(page.getByRole('button', { name: 'Open OSM' })).toBeVisible() + await expect(page.getByTitle('Height Graph')).toBeVisible() + await expect(page.getByTitle('Edit Layers').getByRole('button')).toBeVisible() + await expect(page.getByTitle('Drag Layers').getByRole('button')).toBeVisible() + await expect( + page.getByTitle('Remove Layers').getByRole('button') + ).toBeVisible() + await expect( + page.getByTitle('Rotate Layers').getByRole('button') + ).toBeVisible() + await expect(page.getByTitle('Draw Text').getByRole('button')).toBeVisible() + await expect( + page.getByTitle('Draw Polygons').getByRole('button') + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible() +}) diff --git a/e2e/map.spec.js b/e2e/map.spec.js new file mode 100644 index 00000000..81625a8a --- /dev/null +++ b/e2e/map.spec.js @@ -0,0 +1,478 @@ +import { test, expect } from '@playwright/test' +import { + BERLIN_COORDINATES, + setupHeightMock, + setupLocateMock, + setupNominatimMock, + setupRouteMock, + simpleMockNominatimResponse, +} from './helpers' + +function validateNominatimRequest(request) { + expect(request.method).toBe('GET') + expect(request.url).toMatch( + /https:\/\/nominatim\.openstreetmap\.org\/reverse/ + ) + expect(request.params.format).toBe('json') + expect(request.params.lon).toBeTruthy() + expect(request.params.lat).toBeTruthy() +} + +function validateRouteRequest(request) { + expect(request.method).toBe('GET') + expect(request.url).toMatch(/https:\/\/valhalla1\.openstreetmap\.de\/route/) +} + +function validateBerlinCoordinates(lonStr, latStr) { + const lon = parseFloat(lonStr || '0') + const lat = parseFloat(latStr || '0') + + expect(lon).toBeGreaterThan(BERLIN_COORDINATES.bounds.minLon) + expect(lon).toBeLessThan(BERLIN_COORDINATES.bounds.maxLon) + expect(lat).toBeGreaterThan(BERLIN_COORDINATES.bounds.minLat) + expect(lat).toBeLessThan(BERLIN_COORDINATES.bounds.maxLat) +} + +test.describe('Map interactions with right context menu', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/') + }) + + test('should show right-click context menu', async ({ page }) => { + await page.getByTestId('map').click({ + button: 'right', + }) + await expect( + page.getByRole('button', { name: 'Directions from here' }) + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Add as via point' }) + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Directions to here' }) + ).toBeVisible() + }) + + test('should make Nominatim request when clicking "Directions from here"', async ({ + page, + }) => { + const apiRequests = await setupNominatimMock(page) + + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Directions from here' }).click() + await page.waitForTimeout(2000) + + expect(apiRequests.length).toBeGreaterThan(0) + + const request = apiRequests[0] + validateNominatimRequest(request) + + const lon = parseFloat(request.params.lon || '') + const lat = parseFloat(request.params.lat || '') + expect(lon).not.toBeNaN() + expect(lat).not.toBeNaN() + }) + + test('should make Nominatim request with Berlin coordinates', async ({ + page, + }) => { + const apiRequests = await setupNominatimMock( + page, + simpleMockNominatimResponse + ) + + await page.waitForSelector('[data-testid="map"]', { state: 'visible' }) + await page.waitForTimeout(1000) + + await page.getByTestId('map').click({ + button: 'right', + force: true, + }) + + await page.getByRole('button', { name: 'Directions from here' }).click() + await page.waitForTimeout(2000) + + expect(apiRequests.length).toBe(1) + + const request = apiRequests[0] + validateNominatimRequest(request) + validateBerlinCoordinates(request.params.lon, request.params.lat) + }) + + test('should populate "from" input with Nominatim result', async ({ + page, + }) => { + await setupNominatimMock(page) + + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Directions from here' }).click() + await page.waitForTimeout(2000) + + await expect( + page + .getByTestId('waypoint-input-0') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + }) + + test('should make Nominatim request when clicking "Directions to here"', async ({ + page, + }) => { + const apiRequests = await setupNominatimMock(page) + + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Directions to here' }).click() + await page.waitForTimeout(2000) + + expect(apiRequests.length).toBeGreaterThan(0) + + const request = apiRequests[0] + validateNominatimRequest(request) + + const lon = parseFloat(request.params.lon || '') + const lat = parseFloat(request.params.lat || '') + expect(lon).not.toBeNaN() + expect(lat).not.toBeNaN() + }) + + test('should make Nominatim request with Berlin coordinates for "to here"', async ({ + page, + }) => { + const apiRequests = await setupNominatimMock( + page, + simpleMockNominatimResponse + ) + + await page.waitForSelector('[data-testid="map"]', { state: 'visible' }) + await page.waitForTimeout(1000) + + await page.getByTestId('map').click({ + button: 'right', + force: true, + }) + + await page.getByRole('button', { name: 'Directions to here' }).click() + await page.waitForTimeout(2000) + + expect(apiRequests.length).toBe(1) + + const request = apiRequests[0] + validateNominatimRequest(request) + validateBerlinCoordinates(request.params.lon, request.params.lat) + }) + + test('should populate "to" input with Nominatim result', async ({ page }) => { + await setupNominatimMock(page) + + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Directions to here' }).click() + await page.waitForTimeout(2000) + + await expect( + page + .getByTestId('waypoint-input-1') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + }) + + test('should make Nominatim request when clicking "Add as via point"', async ({ + page, + }) => { + const apiRequests = await setupNominatimMock(page) + + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + expect(apiRequests.length).toBeGreaterThan(0) + + const request = apiRequests[0] + validateNominatimRequest(request) + + const lon = parseFloat(request.params.lon || '') + const lat = parseFloat(request.params.lat || '') + expect(lon).not.toBeNaN() + expect(lat).not.toBeNaN() + }) + + test('should populate via point input with Nominatim result', async ({ + page, + }) => { + await setupNominatimMock(page) + + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + await expect( + page + .getByTestId('waypoint-input-1') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + }) + + test('should add multiple via points', async ({ page }) => { + await setupNominatimMock(page) + + // Add first via point + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + // Add second via point + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + await expect(page.getByTestId('waypoint-input-1')).toBeVisible() + await expect(page.getByTestId('waypoint-input-2')).toBeVisible() + + await expect( + page + .getByTestId('waypoint-input-1') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + + await expect( + page + .getByTestId('waypoint-input-2') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + }) + + test('should handle at least 9 waypoints', async ({ page }) => { + await setupNominatimMock(page) + + // Add "from" waypoint + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Directions from here' }).click() + await page.waitForTimeout(1000) + + // Add "to" waypoint + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Directions to here' }).click() + await page.waitForTimeout(1000) + + // Add 7 via points (total waypoints = 9, but only 8 should be allowed) + for (let i = 0; i < 7; i++) { + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(1000) + } + + // Check that waypoint inputs 0 to 7 are visible + for (let i = 0; i < 8; i++) { + await expect(page.getByTestId(`waypoint-input-${i}`)).toBeVisible() + } + + // Check that waypoint input 8 does not exist + await expect(page.getByTestId('waypoint-input-8')).toHaveCount(1) + }) + + test('selecting two point should display route on the map', async ({ + page, + }) => { + await setupNominatimMock(page) + const apiRequests = await setupRouteMock(page) + + // Select "from" point + await page.getByTestId('map').click({ button: 'right', force: true }) + await page.getByRole('button', { name: 'Directions from here' }).click() + await page.waitForTimeout(1000) + + // Select "to" point + await page.getByTestId('map').click({ button: 'right', force: true }) + await page.getByRole('button', { name: 'Directions to here' }).click() + await page.waitForTimeout(2000) + + expect(apiRequests.length).toBeGreaterThan(0) + + const request = apiRequests[0] + validateRouteRequest(request) + + await expect(page.locator('svg.leaflet-zoom-animated')).toHaveCount(1) + await expect( + page.locator('svg.leaflet-zoom-animated .leaflet-interactive') + ).toHaveCount(2) + }) + + test('should display maneuvers when route is created', async ({ page }) => { + await setupNominatimMock(page) + await setupRouteMock(page) + + // Select "from" point + await page.getByTestId('map').click({ button: 'right', force: true }) + await page.getByRole('button', { name: 'Directions from here' }).click() + await page.waitForTimeout(1000) + + // Select "to" point + await page.getByTestId('map').click({ button: 'right', force: true }) + await page.getByRole('button', { name: 'Directions to here' }).click() + await page.waitForTimeout(1000) + + // Add a via point + await page.getByTestId('map').click({ button: 'right', force: true }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + await expect( + page.locator('div').filter({ hasText: /^Directions$/ }) + ).toBeVisible() + + await expect( + page.getByRole('button', { name: 'Show Maneuvers' }) + ).toBeVisible() + + await page.getByRole('button', { name: 'Show Maneuvers' }).click() + + await expect( + page.getByRole('button', { name: 'Hide Maneuvers' }) + ).toBeVisible() + + await expect(page.getByText('Bike southeast.')).toBeVisible() + + await page.getByRole('button', { name: 'Hide Maneuvers' }).click() + + await expect(page.getByText('Bike southeast.')).not.toBeVisible() + }) +}) + +test.describe('Map interactions with left context menu', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/') + }) + + test('should show left-click context menu', async ({ page }) => { + await setupHeightMock(page) + + await page.getByTestId('map').click({ + button: 'left', + }) + + await expect( + page.getByRole('button', { name: 'Locate Point' }) + ).toBeVisible() + + await expect( + page.getByRole('button', { name: 'Valhalla Location JSON' }) + ).toBeVisible() + + await expect(page.getByTestId('dd-button')).toContainText( + '13.393707, 52.517892' + ) + + await expect(page.getByTestId('latlng-button')).toContainText( + '52.517892, 13.393707' + ) + + await expect(page.getByTestId('dms-button')).toContainText( + '52° 31\' 4" N 13° 23\' 37" E' + ) + }) + + test('should show height from api response', async ({ page }) => { + await setupHeightMock(page) + + await page.getByTestId('map').click({ + button: 'left', + }) + + await expect(page.getByTestId('elevation-button')).toContainText('34 m') + }) + + test('should call locate', async ({ page }) => { + await setupHeightMock(page) + const locateRequests = await setupLocateMock(page) + + await page.getByTestId('map').click({ + button: 'left', + }) + + await expect( + page.getByRole('button', { name: 'Locate Point' }) + ).toBeVisible() + + await page.getByRole('button', { name: 'Locate Point' }).click() + + expect(locateRequests.length).toBeGreaterThan(0) + + const locateRequest = locateRequests[0] + expect(locateRequest.method).toBe('POST') + expect(locateRequest.url).toMatch( + /https:\/\/valhalla1\.openstreetmap\.de\/locate/ + ) + expect(locateRequest.body).toBeDefined() + expect(locateRequest.body.costing).toBe('bicycle') + expect(locateRequest.body.locations).toStrictEqual([ + { lat: 52.51789222838286, lon: 13.393707275390627 }, + ]) + }) +}) + +test.describe('Map interactions with URL parameters', () => { + test('should show the route if url has route parameters', async ({ + page, + }) => { + const nominatimRequests = await setupNominatimMock(page) + const routeRequests = await setupRouteMock(page) + + await page.goto( + `http://localhost:3000/directions?profile=pedestrian&wps=13.343067169189455%2C52.5296422146409%2C13.33414077758789%2C52.50901237642168` + ) + + await page.waitForTimeout(2000) + + await expect(page.locator('svg.leaflet-zoom-animated')).toHaveCount(1) + await expect( + page.locator('svg.leaflet-zoom-animated .leaflet-interactive') + ).toHaveCount(2) + + await expect( + page + .getByTestId('waypoint-input-0') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + + await expect( + page + .getByTestId('waypoint-input-1') + .getByRole('textbox', { name: 'Hit enter for search...' }) + ).toHaveValue('Unter den Linden, Mitte, Berlin, Germany') + + expect(nominatimRequests.length).toBe(2) + expect(routeRequests.length).toBe(1) + }) +}) + +test.describe('Left drawer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/') + }) + + test('should send the route request again when user changed profile', async ({ + page, + }) => { + await setupNominatimMock(page) + const routeRequests = await setupRouteMock(page) + + // Add first via point + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + // Add second via point + await page.getByTestId('map').click({ button: 'right' }) + await page.getByRole('button', { name: 'Add as via point' }).click() + await page.waitForTimeout(2000) + + await expect(page.getByTestId('waypoint-input-1')).toBeVisible() + await expect(page.getByTestId('waypoint-input-2')).toBeVisible() + + expect(routeRequests.length).toBe(1) + + await page.getByTestId('profile-button-pedestrian').click() + + // currently, there is a bug where we are sending two requests instead of one + expect(routeRequests.length).toBe(3) + }) +}) diff --git a/package-lock.json b/package-lock.json index fb1dbff5..af43bf3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,10 @@ "throttle-debounce": "^5.0.0" }, "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^24.4.0", "babel-eslint": "^10.0.2", + "dotenv": "^17.2.2", "eslint": "^8.27.0", "eslint-config-prettier": "^8.5.0", "eslint-config-react": "^1.1.7", @@ -3446,6 +3449,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -5610,9 +5629,13 @@ "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==" }, "node_modules/@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==" + "version": "24.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", + "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.11.0" + } }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -8774,11 +8797,16 @@ } }, "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dotenv-expand": { @@ -15792,6 +15820,38 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/point-in-polygon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", @@ -17697,6 +17757,15 @@ } } }, + "node_modules/react-scripts/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, "node_modules/react-scripts/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -19843,6 +19912,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz", + "integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index b4dcf39a..3a4ef85d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,10 @@ "fix": "yarn fix:js && yarn fix:other", "prettier": "prettier \"**/*.{json,md,scss,yaml,yml}\"", "test:other": "yarn prettier --list-different", - "test:js": "eslint --debug --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{js,jsx}\"" + "test:js": "eslint --debug --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{js,jsx}\"", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed" }, "lint-staged": { "*.{js,jsx}": [ @@ -74,7 +77,10 @@ "not op_mini all" ], "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^24.4.0", "babel-eslint": "^10.0.2", + "dotenv": "^17.2.2", "eslint": "^8.27.0", "eslint-config-prettier": "^8.5.0", "eslint-config-react": "^1.1.7", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..03339be7 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import dotenv from 'dotenv' +import path from 'path' +dotenv.config({ path: path.resolve(__dirname, '.env'), debug: false }) + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + video: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/src/Controls/Directions/OutputControl.jsx b/src/Controls/Directions/OutputControl.jsx index 2938a47e..a247afff 100644 --- a/src/Controls/Directions/OutputControl.jsx +++ b/src/Controls/Directions/OutputControl.jsx @@ -151,7 +151,10 @@ class OutputControl extends React.Component { {this.state.showResults[i] ? ( -