diff --git a/er/.gitignore b/er/.gitignore index 35c8a7a..2f7bff0 100644 --- a/er/.gitignore +++ b/er/.gitignore @@ -39,6 +39,12 @@ build/Release # Dependency directories node_modules/ + +# Playwright (regression net) +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) diff --git a/er/e2e/fixtures/config.json b/er/e2e/fixtures/config.json new file mode 100644 index 0000000..b3eed15 --- /dev/null +++ b/er/e2e/fixtures/config.json @@ -0,0 +1,22 @@ +{ + "server": "mock.local", + "public_name": "test", + "map_title": "Test Tracker", + "color_scheme": ["#DB2222", "#219DB8", "#E69E39"], + "map": { + "center": [0, 0], + "zoom": 2, + "pitch": 0, + "show_subject_names": true, + "map_icon_size": 30 + }, + "subjects": { + "sub-1": { + "name": "Zola", + "age": "5 years", + "fun_fact": "Zola loves acacia leaves.", + "pictures": [{ "path": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" }], + "detail_description": "
Zola is a test giraffe.
" + } + } +} diff --git a/er/e2e/fixtures/subjects.json b/er/e2e/fixtures/subjects.json new file mode 100644 index 0000000..81cb2b0 --- /dev/null +++ b/er/e2e/fixtures/subjects.json @@ -0,0 +1,36 @@ +{ + "data": { + "data": [ + { + "id": "sub-1", + "name": "Zola", + "common_name": null, + "subject_subtype": "giraffe", + "sex": "female", + "last_position": { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [0.01, 0.01] }, + "properties": { + "DateTime": "2026-06-01T08:30:00", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + } + }, + { + "id": "sub-2", + "name": "Mara", + "common_name": null, + "subject_subtype": "giraffe", + "sex": "female", + "last_position": { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.02, 0.015] }, + "properties": { + "DateTime": "2026-06-01T09:15:00", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + } + } + ] + } +} diff --git a/er/e2e/fixtures/tracks.json b/er/e2e/fixtures/tracks.json new file mode 100644 index 0000000..eb4cc8d --- /dev/null +++ b/er/e2e/fixtures/tracks.json @@ -0,0 +1,14 @@ +{ + "data": { + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[0.01, 0.01], [0.005, 0.005], [0.0, 0.0]] + }, + "properties": { "id": "sub-1" } + } + ] + } +} diff --git a/er/e2e/journeys.spec.ts b/er/e2e/journeys.spec.ts new file mode 100644 index 0000000..ce4c99d --- /dev/null +++ b/er/e2e/journeys.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, Page } from '@playwright/test'; +import config from './fixtures/config.json' assert { type: 'json' }; +import subjects from './fixtures/subjects.json' assert { type: 'json' }; +import tracks from './fixtures/tracks.json' assert { type: 'json' }; + +// Regression net for the CURRENT app (PR 1.1). These journeys assert behavior through +// the UI, so they survive the Vite/React 19/MapLibre overhaul. All network is mocked so +// the suite is deterministic and independent of the live PADAS server. +test.beforeEach(async ({ page }) => { + await page.route('**/config/config.json', (route) => route.fulfill({ json: config })); + await page.route('**/api/v1.0/subjects', (route) => route.fulfill({ json: subjects })); + await page.route('**/tracks', (route) => route.fulfill({ json: tracks })); +}); + +// Open the legend and wait for subjects (fetched inside the Mapbox "load" event) to render. +async function openLegendWithSubjects(page: Page) { + await page.goto('/'); + await page.locator('#legend-open-button').click(); + await expect(page.getByText('Zola')).toBeVisible({ timeout: 20_000 }); +} + +// Wrap window.GlobalMap.flyTo so we can assert it was called (and with what) without +// depending on pixel-level WebGL rendering. +async function spyFlyTo(page: Page) { + await page.evaluate(() => { + const m = (window as any).GlobalMap; + (window as any).__flyTo = null; + const orig = m.flyTo.bind(m); + m.flyTo = (opts: any) => { (window as any).__flyTo = opts; return orig(opts); }; + }); +} + +test('1. app loads: static controls render', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#legend-open-button')).toBeVisible(); + await expect(page.locator('#tips-button-container')).toBeVisible(); +}); + +test('2. legend populates from subjects', async ({ page }) => { + await openLegendWithSubjects(page); + await expect(page.getByText('Mara')).toBeVisible(); +}); + +test('3. clicking a subject name opens its story panel', async ({ page }) => { + await openLegendWithSubjects(page); + await page.locator('#Zola').first().click(); + await expect(page.getByText('Back')).toBeVisible(); + await expect(page.getByText(/test giraffe/i)).toBeVisible(); +}); + +test('4. toggling the track button requests that subject\'s tracks', async ({ page }) => { + await openLegendWithSubjects(page); + const trackRequest = page.waitForRequest('**/tracks'); + await page.locator('#subject-track-button').first().click(); + await trackRequest; // resolves only if the track-display flow fired +}); + +test('5. location button flies the map to the subject\'s coordinates', async ({ page }) => { + await openLegendWithSubjects(page); + await spyFlyTo(page); + await page.locator('#subject-location-button').first().click(); + const opts = await page.evaluate(() => (window as any).__flyTo); + expect(opts).not.toBeNull(); + expect(opts.center).toEqual([0.01, 0.01]); +}); + +test('6. help tips open and close', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Helpful Tips')).toBeHidden(); + await page.locator('#tips-button-container').click(); + await expect(page.getByText('Helpful Tips')).toBeVisible(); + await page.locator('#close-icon').click(); + await expect(page.getByText('Helpful Tips')).toBeHidden(); +}); + +test('7. Alt+R hotkey resets the map orientation', async ({ page }) => { + await openLegendWithSubjects(page); + await spyFlyTo(page); + // The hotkey handler lives on #map-container (onKeyDown). Dispatch native keydowns + // with the keyCodes the app checks for: Alt (18) then R (82). + await page.evaluate(() => { + const el = document.getElementById('map-container')!; + const fire = (keyCode: number) => { + const ev = new KeyboardEvent('keydown', { bubbles: true }); + Object.defineProperty(ev, 'keyCode', { get: () => keyCode }); + el.dispatchEvent(ev); + }; + fire(18); + fire(82); + }); + const opts = await page.evaluate(() => (window as any).__flyTo); + expect(opts).not.toBeNull(); + expect(opts.bearing).toBe(0); +}); diff --git a/er/package.json b/er/package.json index 8785980..0bfa899 100644 --- a/er/package.json +++ b/er/package.json @@ -7,7 +7,8 @@ "scripts": { "start": "webpack-dev-server --mode development --open --host localhost", "build": "webpack --mode production", - "lint": "eslint --cache --format codeframe --ext mjs,jsx,js src" + "lint": "eslint --cache --format codeframe --ext mjs,jsx,js src", + "test:e2e": "playwright test" }, "dependencies": { "mapbox-gl": "2.1.1", @@ -21,6 +22,7 @@ "@neutrinojs/dev-server": "^9.5.0", "@neutrinojs/react": "^9.5.0", "@neutrinojs/standardjs": "^9.5.0", + "@playwright/test": "^1.60.0", "eslint": "^7", "neutrino": "^9.5.0", "webpack": "^4", diff --git a/er/playwright.config.ts b/er/playwright.config.ts new file mode 100644 index 0000000..860285a --- /dev/null +++ b/er/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + // ⚠️ REMOVE-AFTER-VITE (overhaul Pass 1.2): webpack 4 (Neutrino 9) crashes on + // Node 17+ with ERR_OSSL_EVP_UNSUPPORTED. --openssl-legacy-provider props up the + // legacy dev server just long enough to run this regression net. Once the app + // builds on Vite, delete the NODE_OPTIONS prefix below (ideally this whole flag). + // Tracking: workspace/design-docs/2026-06-03-app-overhaul-design.md (Pass 1.2). + command: 'NODE_OPTIONS=--openssl-legacy-provider npx webpack-dev-server --mode development --host localhost --port 3000', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/er/yarn.lock b/er/yarn.lock index ad678b4..998797a 100644 --- a/er/yarn.lock +++ b/er/yarn.lock @@ -1182,6 +1182,13 @@ babel-merge "^3.0.0" deepmerge "^1.5.2" +"@playwright/test@^1.60.0": + version "1.60.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.60.0.tgz#e696c31427e8882851235cd556dc2490c3206d97" + integrity sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag== + dependencies: + playwright "1.60.0" + "@types/glob@^7.1.1": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" @@ -3445,6 +3452,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^1.2.7: version "1.2.13" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" @@ -3453,11 +3465,6 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -5348,6 +5355,20 @@ pkg-dir@^4.1.0: dependencies: find-up "^4.0.0" +playwright-core@1.60.0: + version "1.60.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.60.0.tgz#24e0d9cc4730713db5dffcace29b5e4696b1907a" + integrity sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA== + +playwright@1.60.0: + version "1.60.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.60.0.tgz#89710863a51f21112633ef8b6b182594d3bfd7b5" + integrity sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA== + dependencies: + playwright-core "1.60.0" + optionalDependencies: + fsevents "2.3.2" + portfinder@^1.0.26: version "1.0.28" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"