diff --git a/AGENTS.md b/AGENTS.md index 74c61c5e..4147c8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,15 @@ Controllers automatically have the `current_scope` available if they use the `:b - **SNMP Binary Data**: Convert to printable strings before saving. Non-printable bytes → colon-separated hex. See `lib/towerops/snmp/neighbor_discovery.ex:sanitize_string_field/1`. +- **Fragment Queries with Binary UUIDs**: When using PostgreSQL fragments that aggregate or manipulate UUIDs (like `array_agg`, `string_agg`), **ALWAYS cast to `::text`** to avoid binary UUID data that causes `Jason.EncodeError` during LiveView socket serialization: + + # ❌ Wrong - returns binary UUID + fragment("(array_agg(? ORDER BY ? DESC))[1]", table.id, table.inserted_at) + + # ✅ Correct - cast to text + fragment("(array_agg(?::text ORDER BY ? DESC))[1]", table.id, table.inserted_at) + + Without `::text`, PostgreSQL returns raw 16-byte binary UUIDs containing bytes like `0xFD` that aren't valid UTF-8. When interpolated into strings (e.g., `"id-#{row.id}"`), these create invalid UTF-8 sequences that crash Jason encoding when sending data to LiveView clients. diff --git a/e2e/tests/smoke-test.spec.ts b/e2e/tests/smoke-test.spec.ts new file mode 100644 index 00000000..053fe52f --- /dev/null +++ b/e2e/tests/smoke-test.spec.ts @@ -0,0 +1,386 @@ +import { test, expect } from '@playwright/test'; + +/** + * Smoke Tests - Critical Path Coverage + * + * These tests navigate to every major page and verify: + * 1. Page loads without 500 errors + * 2. No JavaScript console errors + * 3. No LiveView socket crashes (Jason.EncodeError, etc.) + * 4. Basic content renders + * + * Purpose: Catch server-side crashes (like binary UUID encoding bugs) + * before they reach production. + */ + +test.describe('Smoke Tests - All Pages Load Without Errors', () => { + let consoleErrors: string[]; + let pageErrors: Error[]; + + test.beforeEach(async ({ page }) => { + consoleErrors = []; + pageErrors = []; + + // Capture console errors + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + // Capture page errors (uncaught exceptions) + page.on('pageerror', error => { + pageErrors.push(error); + }); + }); + + test.afterEach(async ({ page }) => { + // Report any errors found + if (consoleErrors.length > 0) { + console.log('Console errors:', consoleErrors); + } + if (pageErrors.length > 0) { + console.log('Page errors:', pageErrors); + } + + // Don't fail tests on errors - just report them + // Some pages might have expected errors (missing data, etc.) + }); + + test('dashboard loads without errors', async ({ page }) => { + const response = await page.goto('/dashboard'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + + // Verify basic content loaded + await expect(page.locator('body')).toBeVisible(); + + // Check for common error indicators + const errorText = await page.locator(':has-text("error"), :has-text("Error")').first().isVisible({ timeout: 1000 }).catch(() => false); + if (errorText) { + console.warn('Dashboard may have errors visible on page'); + } + }); + + test('devices list loads without errors', async ({ page }) => { + const response = await page.goto('/devices'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('sites list loads without errors', async ({ page }) => { + const response = await page.goto('/sites'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('alerts list loads without errors', async ({ page }) => { + const response = await page.goto('/alerts'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('agents list loads without errors', async ({ page }) => { + const response = await page.goto('/agents'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('activity feed loads without errors', async ({ page }) => { + const response = await page.goto('/activity'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + + // This page specifically had the binary UUID bug + // Verify no LiveView socket crash + const socketError = await page.locator(':has-text("socket"), :has-text("crash")').first().isVisible({ timeout: 1000 }).catch(() => false); + expect(socketError).toBe(false); + }); + + test('trace page loads without errors', async ({ page }) => { + const response = await page.goto('/trace'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('integrations page loads without errors', async ({ page }) => { + const response = await page.goto('/integrations'); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('organization settings loads without errors', async ({ page }) => { + const response = await page.goto('/org/settings'); + + // Skip if redirected to sudo verify + if ((await page.url()).includes('sudo-verify')) { + return; + } + + expect(response?.status()).toBeLessThan(500); + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('user settings loads without errors', async ({ page }) => { + const response = await page.goto('/users/settings'); + + // Skip if redirected to sudo verify + if ((await page.url()).includes('sudo-verify')) { + return; + } + + expect(response?.status()).toBeLessThan(500); + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test('billing page loads without errors', async ({ page }) => { + const response = await page.goto('/org/billing'); + + // Skip if redirected to sudo verify + if ((await page.url()).includes('sudo-verify')) { + return; + } + + expect(response?.status()).toBeLessThan(500); + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + }); + + test.describe('Device Detail Pages', () => { + test('device detail loads without errors', async ({ page }) => { + // First get a device ID + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstDevice.getAttribute('href'); + + if (href) { + const response = await page.goto(href); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + } + } + }); + + test('device config timeline loads without errors', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstDevice.getAttribute('href'); + + if (href) { + const deviceId = href.split('/').pop(); + const response = await page.goto(`/devices/${deviceId}/config-timeline`); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + } + } + }); + + test('device interfaces loads without errors', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstDevice.getAttribute('href'); + + if (href) { + const deviceId = href.split('/').pop(); + const response = await page.goto(`/devices/${deviceId}/interfaces`); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + } + } + }); + + test('device neighbors loads without errors', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstDevice.getAttribute('href'); + + if (href) { + const deviceId = href.split('/').pop(); + const response = await page.goto(`/devices/${deviceId}/neighbors`); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + } + } + }); + }); + + test.describe('Site Detail Pages', () => { + test('site detail loads without errors', async ({ page }) => { + await page.goto('/sites'); + + const firstSite = page.locator('a[href*="/sites/"]:not([href*="/sites/new"])').first(); + + if (await firstSite.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstSite.getAttribute('href'); + + if (href) { + const response = await page.goto(href); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + } + } + }); + + test('site map view loads without errors', async ({ page }) => { + await page.goto('/sites'); + + const firstSite = page.locator('a[href*="/sites/"]:not([href*="/sites/new"])').first(); + + if (await firstSite.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstSite.getAttribute('href'); + + if (href) { + const siteId = href.split('/').pop(); + const response = await page.goto(`/sites/${siteId}/map`); + expect(response?.status()).toBeLessThan(500); + + await page.waitForTimeout(1000); + await expect(page.locator('body')).toBeVisible(); + } + } + }); + }); + + test.describe('LiveView Socket Stability', () => { + test('activity feed maintains socket connection', async ({ page }) => { + await page.goto('/activity'); + + // Wait for initial load + await page.waitForTimeout(2000); + + // Verify no socket errors in console + const hasSocketError = consoleErrors.some(err => + err.includes('socket') || + err.includes('Jason') || + err.includes('encode') + ); + + expect(hasSocketError).toBe(false); + }); + + test('dashboard maintains socket connection', async ({ page }) => { + await page.goto('/dashboard'); + + await page.waitForTimeout(2000); + + const hasSocketError = consoleErrors.some(err => + err.includes('socket') || + err.includes('Jason') || + err.includes('encode') + ); + + expect(hasSocketError).toBe(false); + }); + + test('devices list maintains socket connection', async ({ page }) => { + await page.goto('/devices'); + + await page.waitForTimeout(2000); + + const hasSocketError = consoleErrors.some(err => + err.includes('socket') || + err.includes('Jason') || + err.includes('encode') + ); + + expect(hasSocketError).toBe(false); + }); + }); + + test.describe('Edge Cases - Binary Data Handling', () => { + test('activity feed with sync events renders correctly', async ({ page }) => { + await page.goto('/activity'); + + await page.waitForTimeout(1000); + + // Look for sync-type activities (the ones that had binary UUID issue) + const syncActivity = page.locator('[data-activity-type="sync"], :has-text("sync")').first(); + + if (await syncActivity.isVisible({ timeout: 2000 }).catch(() => false)) { + // If sync activities exist, verify they rendered + await expect(syncActivity).toBeVisible(); + } + + // Verify no encoding errors + const encodingError = await page.locator(':has-text("EncodeError"), :has-text("invalid byte")').first().isVisible({ timeout: 1000 }).catch(() => false); + expect(encodingError).toBe(false); + }); + + test('all activity feed filter types work', async ({ page }) => { + await page.goto('/activity'); + + await page.waitForTimeout(1000); + + const filterTypes = [ + 'Config', + 'Alerts', + 'Resolved', + 'Events', + 'Syncs', + 'Devices' + ]; + + for (const filterType of filterTypes) { + const filterButton = page.locator(`button:has-text("${filterType}")`).first(); + + if (await filterButton.isVisible({ timeout: 1000 }).catch(() => false)) { + await filterButton.click(); + await page.waitForTimeout(500); + + // Verify page still works + await expect(page.locator('body')).toBeVisible(); + + // Verify no errors + const hasError = consoleErrors.some(err => + err.includes('EncodeError') || + err.includes('invalid byte') + ); + expect(hasError).toBe(false); + + // Toggle back off + await filterButton.click(); + await page.waitForTimeout(300); + } + } + }); + }); +});