// 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') })