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 @@
TELEMETRY_SCAN
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 @@ TELEMETRY_SCAN
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 @@ TELEMETRY_SCAN
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 REPORTSelect Bridge or Road...';
+ select.replaceChildren(new Option('Select Bridge or Road...', ''));
assets.forEach(asset => {
// 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 = '