Files
TFI-Live-G2/scripts/smoke-test.mjs
2026-06-01 16:48:03 +01:00

136 lines
5.2 KiB
JavaScript

import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildStopIndex } from './build-stop-index.mjs'
import worker from '../worker/nta-proxy.js'
const tempDir = mkdtempSync(join(tmpdir(), 'tfi-live-g2-'))
try {
await testStopIndexBuilder()
await testWorkerDepartures()
testToolkitMigration()
testStopsJson()
testNoLocalSecretBundled()
console.log('Smoke tests passed')
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
async function testStopIndexBuilder() {
const gtfsDir = join(tempDir, 'gtfs')
mkdirSync(gtfsDir, { recursive: true })
writeFileSync(join(gtfsDir, 'stops.txt'), 'stop_id,stop_code,stop_name,stop_lat,stop_lon\nS1,100,Main Street,53.1,-6.1\n')
writeFileSync(join(gtfsDir, 'routes.txt'), 'route_id,route_short_name,route_long_name,route_type\nR1,46A,Route 46A,3\n')
writeFileSync(join(gtfsDir, 'trips.txt'), 'route_id,service_id,trip_id\nR1,W,T1\n')
writeFileSync(join(gtfsDir, 'stop_times.txt'), 'trip_id,arrival_time,departure_time,stop_id,stop_sequence\nT1,10:00:00,10:00:00,S1,1\n')
const outputPath = join(tempDir, 'stops.json')
await buildStopIndex(gtfsDir, outputPath)
const payload = JSON.parse(readFileSync(outputPath, 'utf8'))
assert(payload.stops?.[0]?.routes?.[0] === '46A', 'stop index should include route short name')
assert(payload.stops?.[0]?.modes?.[0] === 'bus', 'stop index should infer bus mode')
}
async function testWorkerDepartures() {
const nowSeconds = Math.floor(Date.now() / 1000)
const feed = {
entity: [
{
id: 'fresh',
tripUpdate: {
trip: { routeId: '46A', tripHeadsign: 'Dun Laoghaire' },
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds + 300 } }],
},
},
{
id: 'stale',
tripUpdate: {
trip: { routeId: '46A', tripHeadsign: 'Past' },
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds - 600 } }],
},
},
],
}
const cacheStore = new Map()
globalThis.caches = {
default: {
match: async (request) => cacheStore.get(request.url),
put: async (request, response) => {
cacheStore.set(request.url, response)
},
},
}
globalThis.fetch = async () => Response.json(feed)
const response = await worker.fetch(
new Request('https://proxy.example/departures?stopId=S1'),
{ NTA_API_KEY: 'test' },
{ waitUntil: () => undefined },
)
assert(response.ok, 'worker departures response should be ok')
const payload = await response.json()
assert(payload.departures.length === 1, 'worker should filter stale departures')
assert(payload.departures[0].route === '46A', 'worker should preserve route id')
}
function testNoLocalSecretBundled() {
const key = process.env.NTA_API_KEY
if (!key) {
return
}
const matches = findFilesContaining(key, ['dist', 'src', 'worker', 'scripts', 'README.md', 'app.json'])
assert(matches.length === 0, `local NTA_API_KEY must not be bundled or documented verbatim: ${matches.join(', ')}`)
}
function testStopsJson() {
const payload = JSON.parse(readFileSync('public/stops.json', 'utf8'))
assert(payload.stops?.length > 10_000, 'public/stops.json should contain the generated TFI stop index')
assert(payload.stops.some((stop) => stop.name && stop.lat && stop.lon), 'stops should include names and coordinates')
}
function testToolkitMigration() {
const source = readFileSync('src/main.ts', 'utf8')
assert(source.includes("from 'even-toolkit/bridge'"), 'app should use Even toolkit bridge')
assert(source.includes("from 'even-toolkit/glass-display-builders'"), 'glasses lists should use toolkit display builders')
assert(source.includes("from 'even-toolkit/web/button'"), 'phone UI should use Even toolkit Button')
assert(source.includes("from 'even-toolkit/web/list-item'"), 'phone lists should use Even toolkit ListItem')
assert(source.includes("from 'even-toolkit/web/stat-grid'"), 'phone status grid should use Even toolkit StatGrid')
assert(source.includes("from 'react-dom/client'"), 'phone UI should be rendered through React toolkit components')
assert(!source.includes('createStartUpPageContainer'), 'app should not render glasses UI through raw startup containers')
assert(!source.includes('new TextContainerUpgrade'), 'app should not update glasses UI through raw text upgrades')
assert(!source.includes('document.createElement'), 'phone UI should not use hand-built DOM rendering')
assert(!source.includes('innerHTML'), 'phone UI should not render raw HTML strings')
}
function findFilesContaining(needle, paths) {
const matches = []
for (const path of paths) {
collectMatches(path, needle, matches)
}
return matches
}
function collectMatches(path, needle, matches) {
const stats = statSync(path)
if (stats.isDirectory()) {
for (const entry of readdirSync(path)) {
collectMatches(join(path, entry), needle, matches)
}
return
}
const content = readFileSync(path, 'utf8')
if (content.includes(needle)) {
matches.push(path)
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}