Something went wrong. Try again.
A website for music events across the Boston area, specifically Porchfest. Also see http://porchfest.info porchfest.at
Something went wrong. Try again.
13 kB · 321 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322// E2E test: Somerville PorchFest 2026 Band Data & Map// Usage: npm run test:e2e (run from project root)//// Tests are split into two groups:// LIVE — require the Vite dev server (started automatically by Playwright config).// OFFLINE — load bands.json from disk, no server needed (~0ms).
const { test, expect } = require('@playwright/test')const fs = require('fs')const path = require('path')
// ---- preload bands.json (used by offline + live tests) ----
const HOURLY_ZONES = {"12":"12PM","13":"1PM","14":"2PM","15":"3PM","16":"4PM","17":"5PM"}const SCALE = {"12PM":12,"1PM":13,"2PM":14,"3PM":15,"4PM":16,"5PM":17}
function loadBands() { const city = process.env.TEST_CITY || "watertown" const raw = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'data', city, 'bands.json'), 'utf-8')) // Detect Water town-specific (hourly zones) vs zone-specific (West/Central/East) const isHourly = raw.some(b => !["West","Central","East"].includes(b.porch_zone)) const zones = {} for (const b of raw) { const zoneKey = isHourly ? String(parseInt(b.start_time.split(' ')[1].split(':')[0], 10)) : b.porch_zone if (!zones[zoneKey]) { zones[zoneKey] = [] } zones[zoneKey].push(b) } const hourlyBands = {} for (const h of ['12','13','14','15','16','17']) { hourlyBands[HOURLY_ZONES[h]] = zones[h] || [] } return { all: raw, hourly: hourlyBands, grouped: zones, withdrawn: raw.filter(b => b.is_withdrawn === 'true'), isHourly, }}const B = loadBands()
// ---- helpers ----
function expectZoneStats(statsText) { expect(statsText).toContain(String(B.all.length)) expect(statsText).toContain('bands')}
async function waitForDataLoaded(page) { // Wait for the stats element to show band count text (not "Loading" or "Error") await expect(page.locator('#stats span').first()).toBeVisible() await page.locator('#searchInput').waitFor({ state: 'visible' })}
test('app loads and renders map + band cards', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page)
// Verify stats text const statsText = await page.locator('#stats').first().textContent() await expectZoneStats(statsText)
// Verify map dimensions (prevents the "map renders below sidebar" regression) const mapBox = await page.locator(".leaflet-container").boundingBox() expect(mapBox.height).toBeGreaterThan(50)
// Verify band cards exist with correct count const cardCount = await page.locator('div[id^="card_"]').count() expect(cardCount).toBe(B.all.length)})
test('search filters by name, genre, and shows no results for unknown terms', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page)
const searchInput = page.locator('#searchInput')
// Search by first band name await searchInput.fill(B.all[0].title) await expect(page.locator('div[id^="card_"]')).toHaveCount(1)
// Search by genre const rockBands = B.all.filter( b => (b.title + ' ' + (b.genres || []).join(' ') + ' ' + (b.street || '')).toLowerCase().includes('rock') ) await searchInput.fill('Rock') await expect(page.locator('div[id^="card_"]')).toHaveCount(rockBands.length)
// Search for something that doesn't exist await searchInput.fill('zzzzxyww-not') await expect(page.locator('div[id^="card_"]')).toHaveCount(0)})
test('map layout is correct flex children with indoor/outdoor zones', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page)
const meta = await page.evaluate(() => { const outerContainer = document.querySelector('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') const innerContainer = outerContainer ? outerContainer.querySelector(':scope > div.flex.overflow-hidden') : null const innerChildren = innerContainer ? Array.from(innerContainer.children) : [] const sidebar = innerChildren?.some(n => n.tagName.toLowerCase() === 'aside') ? 'yes' : 'no' const mapPane = innerChildren?.some(n => n.classList.contains('flex-1') && n.id === 'mapid') ? 'yes' : 'no' const mapId = document.querySelector('.leaflet-container') const mapIdInContentOuter = mapId && (innerContainer ? mapId.closest('.h-\\[100vh\\].w-\\[100vw\\].overflow-hidden.bg-\\[\\#0d0d0d\\].flex.flex-col') === outerContainer : false) ? 'yes' : 'no' const tileCount = document.querySelectorAll('.leaflet-tile').length return { sidebar, mapPane, mapIdInContentOuter, tileCount, numChildren: innerChildren.length, hasHeader: outerContainer?.children[0]?.tagName.toLowerCase() === 'header' } }) expect(meta.sidebar).toBe('yes') expect(meta.mapPane).toBe('yes') expect(meta.mapIdInContentOuter).toBe('yes') expect(meta.tileCount).toBeGreaterThan(0) expect(meta.hasHeader).toBe(true) expect(meta.numChildren).toBe(2)})
test('search input has white text for visibility on dark background', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page) const textColor = await page.locator('#searchInput').evaluate(el => getComputedStyle(el).color) expect(textColor).toEqual('rgb(255, 255, 255)')})
test('map zooms to city level (not world view)', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page) await page.waitForTimeout(3000)
const tileInfo = await page.evaluate(() => { const container = document.querySelector('.leaflet-container') const tiles = container?.querySelectorAll('.leaflet-tile') let zoom = null const firstSrc = tiles?.[0]?.src if (firstSrc) { const parts = firstSrc.split('/') const zoomStr = parts.filter(p => !isNaN(parseInt(p)))[0] if (zoomStr !== undefined) { zoom = parseInt(zoomStr); } } return { zoom } }) expect(tileInfo.zoom).toBeGreaterThan(10)})
// ============================================================// OFFLINE TESTS (no server needed — load bands.json directly)// ============================================================
test('total band count is 145 with correct zone distribution', () => { const city = process.env.TEST_CITY || "watertown" if (city === "gloucester-magnolia") { expect(B.all.length).toBe(18) expect(B.grouped['Gloucester-Magnolia'].length).toBe(18) } else if (B.isHourly && city === "swampscott") { expect(B.all.length).toBe(36) expect(B.hourly['12PM'].length).toBe(1) expect(B.hourly['1PM'].length).toBe(7) expect(B.hourly['2PM'].length).toBe(7) expect(B.hourly['3PM'].length).toBe(8) expect(B.hourly['4PM'].length).toBe(10) expect(B.hourly['5PM'].length).toBe(3) } else if (B.isHourly && city === "watertown") { expect(B.all.length).toBe(205) expect(B.hourly['12PM'].length).toBe(49) expect(B.hourly['1PM'].length).toBe(30) expect(B.hourly['2PM'].length).toBe(44) expect(B.hourly['3PM'].length).toBe(36) expect(B.hourly['4PM'].length).toBe(33) expect(B.hourly['5PM'].length).toBe(13) } else { expect(B.all.length).toBe(145) expect(B.grouped['West'].length).toBe(53) expect(B.grouped['Central'].length).toBe(36) expect(B.grouped['East'].length).toBe(56) }})
test('every band has all required fields', () => { for (const band of B.all) { expect(band.title).toBeTruthy() expect(band.start_time).toBeTruthy() expect(band.porch_zone).toBeTruthy() expect(band.is_withdrawn).toBeTruthy() }})
test('zones are hourly time slots for Watertown (12PM–5PM) or geographic for other cities', () => { if (B.isHourly) { const zones = B.all.map(b => { const hour = parseInt(b.start_time.split(' ')[1].split(':')[0], 10) return HOURLY_ZONES[String(hour)] }) const uniqueZones = [...new Set(zones)].sort((a, b) => (SCALE[a] || 0) - (SCALE[b] || 0)) expect(uniqueZones).toEqual(Object.values(HOURLY_ZONES)) } else { const city = process.env.TEST_CITY || "watertown" if (city === "gloucester-magnolia") { const zones = B.all.map(b => b.porch_zone) const uniqueZones = [...new Set(zones)] expect(uniqueZones).toEqual(["Gloucester-Magnolia"]) } else { const zones = B.all.map(b => b.porch_zone) const uniqueZones = [...new Set(zones)].sort() expect(uniqueZones).toEqual(["Central","East","West"]) } }})
test('zone stats text matches correct counts', () => { const statsText = B.all.length + ' bands' expect(statsText).toContain(String(B.all.length)) if (B.isHourly) { for (const z of Object.values(HOURLY_ZONES)) { expect(B.hourly[z].length).toBeGreaterThan(0) } expect(B.all.length).toBe(Object.values(B.hourly).reduce((s, arr) => s + arr.length, 0)) } else { const city = process.env.TEST_CITY || "watertown" if (city === "gloucester-magnolia") { expect(Object.keys(B.grouped).length).toBe(1) expect(B.all.length).toBe(Object.values(B.grouped).reduce((s, arr) => s + arr.length, 0)) } else { for (const z of ["West","Central","East"]) { expect(B.grouped[z].length).toBeGreaterThan(0) } expect(B.all.length).toBe(Object.values(B.grouped).reduce((s, arr) => s + arr.length, 0)) } }})
test('withdrawn bands have is_withdrawn "true"', () => { for (const band of B.withdrawn) { expect(band.is_withdrawn).toBe('true') }})
test('all bands have valid start_time and end_time', () => { for (const band of B.all) { expect(band.start_time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) expect(band.end_time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) }})
test('band card sidebar row badge renders as markup (not broken string) for withdrawn bands', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page)
const statsText = await page.locator('#stats span').first().textContent() const hasWithdrawn = /withdrawn/.test(statsText) const hasWD = await page.evaluate(() => { const cards = document.querySelectorAll('[class*="mx-1 my-1"]') for (const card of cards) { if (card.textContent.includes('[WD]')) { const childSpans = card.querySelectorAll('span') for (const span of childSpans) { if (span.textContent.includes('[WD]') && span.className.includes('text-red-400')) { return true } } } } return false }) // Watertown has 0 withdrawn bands, so hasWD should be false expect(hasWD).toBe(hasWithdrawn)})
test('sidebar toggle button controls visibility on desktop', async ({ page }) => { await page.goto('/') await waitForDataLoaded(page)
// On desktop (default viewport 1280x800), the sidebar should be visible by default const aside = page.locator('aside') const asideBox = await aside.boundingBox() expect(asideBox).not.toBeNull() expect(asideBox?.width).toBeGreaterThan(200)
// There should be a toggle button in the header (visible on desktop too) const toggleBtn = page.locator('header button[aria-label="Toggle sidebar"]') await expect(toggleBtn).toBeVisible()
// Initially sidebar is visible (sidebarOpen=false), so icon shows hamburger (3 horizontal lines) await expect(page.locator('header button[aria-label="Toggle sidebar"] svg line')).toHaveCount(3)
// Click the toggle button — sidebar should hide await toggleBtn.click()
// The sidebar aside should now have zero dimensions (hidden via CSS display: none) const hiddenAsideBox = await aside.boundingBox() expect(hiddenAsideBox).toBeNull()
// Icon should now be X (2 diagonal lines) after hiding the sidebar await expect(page.locator('header button[aria-label="Toggle sidebar"] svg line')).toHaveCount(2)
// Click again — sidebar should reappear await toggleBtn.click() const visibleAsideBox = await aside.boundingBox() expect(visibleAsideBox).not.toBeNull() expect(visibleAsideBox?.width).toBeGreaterThan(200)})
test('mobile: search input is visible and functional', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/') await page.waitForTimeout(2000)
// On mobile, the search input should be visible (always rendered) const searchInput = page.locator('#searchInput') await expect(searchInput).toBeVisible()
// Typing should update the input value (lowercased by the app) await searchInput.fill('Bone') await expect(searchInput).toHaveValue('bone')})