From 85ed03cd7ae8daf3be5c78279f31c477ff2d0f67 Mon Sep 17 00:00:00 2001 From: pralav-25 <174412353+pralav-25@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:29:31 +0530 Subject: [PATCH 1/2] fix: render dashboard data safely and test browser flows --- .github/workflows/tests.yml | 38 +++++++++ .gitignore | 1 + README.md | 22 ++++++ auth.html | 83 ++++++++++++------- index.html | 4 +- package-lock.json | 60 ++++++++++++++ package.json | 10 +++ tests/test_rendering.cjs | 153 ++++++++++++++++++++++++++++++++++++ 8 files changed, 341 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tests/test_rendering.cjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2a3ed66 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ['3.10', '3.12'] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install -r requirements.txt + - run: python -m unittest discover -s tests -v + + browser: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm test diff --git a/.gitignore b/.gitignore index 0b2fb07..e6436a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .env .venv/ venv/ +node_modules/ diff --git a/README.md b/README.md index 2ed5a46..d0b12a5 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,28 @@ python -m http.server 8080 | `POST` | `/weather/trigger-flood` | Simulate flood impact on road assets | | `POST` | `/assets/{asset_id}/maintenance` | Record a maintenance improvement | +## Tests + +Run the Python validation and scoring tests after installing the API dependencies: + +```bash +python -m unittest discover -s tests -v +``` + +For browser regression tests, install Node.js 24 and run: + +```bash +npm ci +npx playwright install chromium +npm test +``` + +The browser tests use mocked API responses and map dependencies; no running API, +database, or external CDN is needed during the tests. They check that asset names +and reports render as literal text, quoted names keep their chart controls, +repairs target the correct asset, and resolved reports leave the incident feed. +GitHub Actions runs both suites on pull requests and pushes to `main`. + ## Scope StructIQ is a demonstration prototype, not a production structural-safety diff --git a/auth.html b/auth.html index 870c821..74ee50e 100644 --- a/auth.html +++ b/auth.html @@ -212,7 +212,7 @@ const assets = await res.json(); document.getElementById('asset-count').innerText = assets.length; const listDiv = document.getElementById('asset-list'); - listDiv.innerHTML = ''; + listDiv.replaceChildren(); markers.forEach(m => map.removeLayer(m)); markers = []; @@ -222,26 +222,43 @@ if (a.health_score < 40) statusColor = '#ef4444'; else if (a.health_score < 75) statusColor = '#f59e0b'; - listDiv.innerHTML += ` -
-
- ${a.name} - ${a.asset_type} -
-
-
-
-
- HEALTH: ${a.health_score.toFixed(1)}% - ID: #${a.id} -
- -
`; - + const card = document.createElement('div'); + card.className = 'asset-card p-3 rounded mb-2'; + card.style.borderLeft = `6px solid ${statusColor}`; + // Only static markup belongs in this template. API values use textContent. + card.innerHTML = ` +
+ + +
+
+
+
+
+ + +
+ `; + const nameButton = card.querySelector('.asset-name'); + nameButton.textContent = a.name; + nameButton.addEventListener('click', () => showGraph(a.name)); + card.querySelector('.asset-type').textContent = a.asset_type; + card.querySelector('.asset-health').textContent = `HEALTH: ${a.health_score.toFixed(1)}%`; + card.querySelector('.asset-id').textContent = `ID: #${a.id}`; + const progressBar = card.querySelector('.progress-bar'); + progressBar.style.width = `${a.health_score}%`; + progressBar.style.backgroundColor = statusColor; + card.querySelector('.repair-asset').addEventListener('click', () => repairAsset(a.id)); + listDiv.append(card); + + const popup = document.createElement('div'); + const popupName = document.createElement('b'); + popupName.textContent = a.name; + popup.append(popupName, document.createElement('br'), `Health: ${a.health_score.toFixed(1)}%`); const m = L.circleMarker([a.latitude, a.longitude], { radius: 10, color: 'white', weight: 2, fillColor: statusColor, fillOpacity: 0.9 - }).addTo(map).bindPopup(`${a.name}
Health: ${a.health_score.toFixed(1)}%`); - + }).addTo(map).bindPopup(popup); + if (a.health_score < 40) { m.getElement()?.classList.add('emergency-mode'); } markers.push(m); }); @@ -258,15 +275,25 @@ const res = await fetch(`${API_URL}/reports`); const reports = await res.json(); const feed = document.getElementById('incident-feed'); - if (reports.length > 0) { - feed.innerHTML = ''; - reports.reverse().slice(0, 8).forEach(rep => { - feed.innerHTML += ` -
- [${new Date().toLocaleTimeString()}] REPORT #${rep.id}
- > ASSET: ${rep.asset_id} | SEV: ${rep.severity}
- > ${rep.description}
-
`; + feed.replaceChildren(); + if (reports.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'text-muted'; + emptyState.textContent = 'No open reports.'; + feed.append(emptyState); + } else { + reports.slice(-8).reverse().forEach(rep => { + const entry = document.createElement('div'); + entry.className = 'ticket-log'; + entry.append( + `[${new Date().toLocaleTimeString()}] REPORT #${rep.id}`, + document.createElement('br'), + `> ASSET: ${rep.asset_id} | SEV: ${rep.severity}`, + document.createElement('br'), + `> ${rep.description}`, + document.createElement('br'), + ); + feed.append(entry); }); } } catch(e) { } diff --git a/index.html b/index.html index 50ac3d2..0c8f308 100644 --- a/index.html +++ b/index.html @@ -1300,10 +1300,10 @@

⚠ NEW INCIDENT REPORT { // Citizen sees name, backend gets ID - select.innerHTML += ``; + select.add(new Option(asset.name, String(asset.id))); }); } catch (e) { console.error("Failed to load assets", e); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..db680e9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "structiq-browser-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "structiq-browser-tests", + "devDependencies": { + "playwright": "1.62.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e44238 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "structiq-browser-tests", + "private": true, + "scripts": { + "test": "node --test tests/test_rendering.cjs" + }, + "devDependencies": { + "playwright": "1.62.1" + } +} diff --git a/tests/test_rendering.cjs b/tests/test_rendering.cjs new file mode 100644 index 0000000..f877146 --- /dev/null +++ b/tests/test_rendering.cjs @@ -0,0 +1,153 @@ +const assert = require('node:assert/strict'); +const { readFile } = require('node:fs/promises'); +const path = require('node:path'); +const { before, after, test } = require('node:test'); +const { chromium } = require('playwright'); + +const ROOT = path.join(__dirname, '..'); +let browser; + +before(async () => { + browser = await chromium.launch(); +}); + +after(async () => { + await browser?.close(); +}); + +function asset(id, name) { + return { + id, name, asset_type: 'Bridge', health_score: 80, + latitude: 13.08, longitude: 80.27, + }; +} + +async function openPage(t, filename, assets = [], reports = []) { + const page = await browser.newPage(); + t.after(() => page.close()); + const requests = []; + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin === 'http://structiq.test') { + return route.fulfill({ + contentType: 'text/html', + body: await readFile(path.join(ROOT, filename), 'utf8'), + }); + } + if (url.origin === 'http://127.0.0.1:8000') { + requests.push({ path: url.pathname, method: route.request().method() }); + const data = url.pathname === '/assets' ? assets : + url.pathname === '/reports' ? reports : {}; + return route.fulfill({ json: data }); + } + // Third-party styling, map tiles, and analytics are outside these tests. + return route.fulfill({ body: '', contentType: 'text/plain' }); + }); + await page.addInitScript(() => { + // Stop polling so every refresh is explicit and deterministic. + window.setInterval = () => 0; + window.renderedPopups = []; + // Model Leaflet's string-vs-DOM popup contract without external requests. + window.L = { + tileLayer: () => ({}), + map: () => ({ removeLayer() {} }), + control: { layers: () => ({ addTo() {} }) }, + circleMarker: () => ({ + addTo() { return this; }, + bindPopup(content) { + const popup = document.createElement('div'); + if (typeof content === 'string') popup.innerHTML = content; + else popup.append(content); + document.body.append(popup); + window.renderedPopups.push(popup); + return this; + }, + getElement() { return null; }, + }), + }; + }); + await page.goto('http://structiq.test/' + filename); + return { page, requests }; +} + +test('asset cards and map popups display markup as literal text', async t => { + const name = ''; + const { page } = await openPage(t, 'auth.html', [asset(1, name)]); + await page.waitForFunction(() => document.querySelectorAll('.asset-card').length === 1); + assert.equal(await page.locator('.asset-card .text-info').textContent(), name); + assert.equal(await page.locator('.asset-card img').count(), 0); + assert.equal(await page.evaluate(() => window.renderedPopups[0].textContent), + name + 'Health: 80.0%'); + assert.equal(await page.evaluate(() => window.renderedPopups[0].querySelector('img')), null); + assert.equal(await page.evaluate(() => window.injected), undefined); +}); + +test('quoted asset names keep chart and repair controls working after refresh', async t => { + const name = 'O\'Brien "East" Bridge'; + const { page, requests } = await openPage(t, 'auth.html', [asset(1, name), asset(2, 'Second Bridge')]); + await page.waitForFunction(() => document.querySelectorAll('.asset-card').length === 2); + await page.evaluate(() => { + window.showGraph = name => { window.openedGraph = name; }; + }); + for (let refresh = 0; refresh < 2; refresh++) { + await page.locator('.asset-card .text-info').first().click(); + assert.equal(await page.evaluate(() => window.openedGraph), name); + await page.evaluate(() => loadAssets()); + assert.equal(await page.locator('.asset-card').count(), 2); + } + const repairRequest = page.waitForRequest(request => + request.url().endsWith('/assets/2/maintenance') && request.method() === 'POST'); + await page.locator('.asset-card').nth(1).getByRole('button', { name: 'INITIATE REPAIR' }).click(); + await repairRequest; + assert.ok(requests.some(request => request.path === '/assets/2/maintenance' && request.method === 'POST')); +}); + +test('citizen descriptions cannot create elements in the incident feed', async t => { + const description = ' Crack & damage'; + const { page } = await openPage(t, 'auth.html', [], [ + { id: 7, asset_id: 1, severity: 10, description }, + ]); + await page.waitForFunction(() => document.querySelectorAll('.ticket-log').length === 1); + assert.ok((await page.locator('.ticket-log').textContent()).includes(description)); + assert.equal(await page.locator('#incident-feed img, #incident-feed b').count(), 0); + assert.equal(await page.evaluate(() => window.injected), undefined); +}); + +test('the incident feed clears resolved reports on its next refresh', async t => { + const reports = [{ id: 7, asset_id: 1, severity: 10, description: 'Surface crack' }]; + const { page } = await openPage(t, 'auth.html', [], reports); + await page.waitForFunction(() => document.querySelectorAll('.ticket-log').length === 1); + reports.length = 0; + await page.evaluate(() => fetchIncidents()); + assert.equal(await page.locator('.ticket-log').count(), 0); + assert.ok((await page.locator('#incident-feed').textContent()).includes('No open reports')); +}); + +test('the incident feed keeps the eight most recent reports in reverse order', async t => { + const reports = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, asset_id: 1, severity: 5, description: 'Report ' + (i + 1), + })); + const { page } = await openPage(t, 'auth.html', [], reports); + await page.waitForFunction(() => document.querySelectorAll('.ticket-log').length === 8); + const entries = await page.locator('.ticket-log').allTextContents(); + assert.ok(entries[0].includes('REPORT #10')); + assert.ok(entries[7].includes('REPORT #3')); +}); + +test('the citizen selector preserves names and IDs without parsing options from names', async t => { + const name = '