EdgeFunctionRecentErrors.utils.test.ts336 lines · main
| 1 | import { afterEach, describe, expect, it, vi } from 'vitest' |
| 2 | |
| 3 | import { |
| 4 | buildGroupAssistantPrompt, |
| 5 | buildTroubleshootingDocsUrl, |
| 6 | formatLogTimestamp, |
| 7 | formatSingleLineMessage, |
| 8 | getDisplayErrorMessage, |
| 9 | getFunctionRuntimeLogsSql, |
| 10 | getNoErrorsSinceLastDeployMessage, |
| 11 | getRecentErrorGroups, |
| 12 | getRecentErrorGroupsBase, |
| 13 | getRelatedExecutionIds, |
| 14 | getSinceLastDeployInvocationCount, |
| 15 | getSinceLastDeployInvocationCountSql, |
| 16 | getSinceLastDeployInvocationPhrase, |
| 17 | getSinceLastDeployLogRange, |
| 18 | getStatusBadgeVariant, |
| 19 | summarizeErrorMessage, |
| 20 | toAlertError, |
| 21 | toIsoTimestamp, |
| 22 | } from './EdgeFunctionRecentErrors.utils' |
| 23 | |
| 24 | describe('EdgeFunctionRecentErrors.utils', () => { |
| 25 | afterEach(() => { |
| 26 | vi.useRealTimers() |
| 27 | }) |
| 28 | |
| 29 | it('normalizes alert errors and single-line messages', () => { |
| 30 | expect(toAlertError('boom')).toEqual({ message: 'boom' }) |
| 31 | expect(toAlertError({ message: 'broken' })).toEqual({ message: 'broken' }) |
| 32 | expect(toAlertError({ message: 123 })).toBeUndefined() |
| 33 | expect(toAlertError(null)).toBeUndefined() |
| 34 | |
| 35 | expect(formatSingleLineMessage(' first line\n second\t\tline ')).toBe( |
| 36 | 'first line second line' |
| 37 | ) |
| 38 | }) |
| 39 | |
| 40 | it('builds runtime log SQL and escapes interpolated values', () => { |
| 41 | expect(getFunctionRuntimeLogsSql({ functionId: undefined, executionIds: ['abc'] })).toBe('') |
| 42 | expect(getFunctionRuntimeLogsSql({ functionId: 'fn_123', executionIds: [] })).toBe('') |
| 43 | |
| 44 | expect( |
| 45 | getFunctionRuntimeLogsSql({ |
| 46 | functionId: "fn_'123", |
| 47 | executionIds: ['exec_1', "exec_'2"], |
| 48 | limit: 25, |
| 49 | }) |
| 50 | ) |
| 51 | .toBe(`select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs |
| 52 | cross join unnest(metadata) as metadata |
| 53 | where metadata.function_id = 'fn_''123' and metadata.execution_id in ('exec_1', 'exec_''2') |
| 54 | order by timestamp desc |
| 55 | limit 25`) |
| 56 | }) |
| 57 | |
| 58 | it('normalizes deploy timestamps and derives the logs query range', () => { |
| 59 | vi.useFakeTimers() |
| 60 | vi.setSystemTime(new Date('2026-03-20T12:00:00.000Z')) |
| 61 | |
| 62 | const deployedAt = '2026-03-20T10:15:00.000Z' |
| 63 | const deployedAtMilliseconds = Date.parse(deployedAt) |
| 64 | |
| 65 | expect(toIsoTimestamp(deployedAt)).toBe(deployedAt) |
| 66 | expect(toIsoTimestamp(String(deployedAtMilliseconds))).toBe(deployedAt) |
| 67 | expect(toIsoTimestamp(String(deployedAtMilliseconds * 1000))).toBe(deployedAt) |
| 68 | expect(toIsoTimestamp('')).toBeUndefined() |
| 69 | expect(toIsoTimestamp('not-a-date')).toBeUndefined() |
| 70 | |
| 71 | expect(getSinceLastDeployLogRange(deployedAt)).toEqual({ |
| 72 | isoTimestampStart: deployedAt, |
| 73 | isoTimestampEnd: '2026-03-20T12:00:00.000Z', |
| 74 | }) |
| 75 | |
| 76 | expect(getSinceLastDeployLogRange('2026-03-20T13:00:00.000Z')).toEqual({ |
| 77 | isoTimestampStart: '2026-03-20T13:00:00.000Z', |
| 78 | isoTimestampEnd: '2026-03-20T13:00:00.000Z', |
| 79 | }) |
| 80 | |
| 81 | expect(getSinceLastDeployLogRange()).toEqual({}) |
| 82 | }) |
| 83 | |
| 84 | it('builds the since-deploy invocation count query and empty-state message', () => { |
| 85 | expect(getSinceLastDeployInvocationCountSql()).toContain( |
| 86 | 'SELECT count(*) as count FROM function_edge_logs' |
| 87 | ) |
| 88 | expect(getSinceLastDeployInvocationCountSql()).toContain("(function_id = '__pending__')") |
| 89 | |
| 90 | expect( |
| 91 | getSinceLastDeployInvocationCount([ |
| 92 | { |
| 93 | count: '12', |
| 94 | }, |
| 95 | ] as unknown as Parameters<typeof getSinceLastDeployInvocationCount>[0]) |
| 96 | ).toBe(12) |
| 97 | expect(getSinceLastDeployInvocationCount([])).toBe(0) |
| 98 | |
| 99 | expect(getSinceLastDeployInvocationPhrase(1)).toBe('1 invocation') |
| 100 | expect(getSinceLastDeployInvocationPhrase(1200)).toBe('1,200 invocations') |
| 101 | |
| 102 | expect(getNoErrorsSinceLastDeployMessage(0)).toBe( |
| 103 | 'There have been 0 invocations since last deploy and no errors.' |
| 104 | ) |
| 105 | expect(getNoErrorsSinceLastDeployMessage(1)).toBe( |
| 106 | 'There has been 1 invocation since last deploy and no errors.' |
| 107 | ) |
| 108 | expect(getNoErrorsSinceLastDeployMessage(1200)).toBe( |
| 109 | 'There have been 1,200 invocations since last deploy and no errors.' |
| 110 | ) |
| 111 | }) |
| 112 | |
| 113 | it('groups recent failed invocations by parsed error message', () => { |
| 114 | const groups = getRecentErrorGroupsBase([ |
| 115 | { |
| 116 | id: 'invocation-1', |
| 117 | event_message: 'POST | 500 | database exploded', |
| 118 | method: 'POST', |
| 119 | status_code: 500, |
| 120 | execution_id: 'exec-1', |
| 121 | execution_time_ms: 123.7, |
| 122 | timestamp: 100, |
| 123 | }, |
| 124 | { |
| 125 | id: 'invocation-2', |
| 126 | event_message: 'POST | 500 | database exploded', |
| 127 | method: 'POST', |
| 128 | status_code: 500, |
| 129 | execution_id: 'exec-2', |
| 130 | execution_time_ms: 85.1, |
| 131 | timestamp: 120, |
| 132 | }, |
| 133 | { |
| 134 | id: 'invocation-3', |
| 135 | event_message: '', |
| 136 | method: 'GET', |
| 137 | status_code: 503, |
| 138 | execution_id: '', |
| 139 | timestamp: 110, |
| 140 | }, |
| 141 | ]) |
| 142 | |
| 143 | expect(groups).toEqual([ |
| 144 | { |
| 145 | message: 'database exploded', |
| 146 | count: 2, |
| 147 | lastSeen: 120, |
| 148 | lastExecutionId: 'exec-2', |
| 149 | lastStatusCode: '500', |
| 150 | lastMethod: 'POST', |
| 151 | executionTime: '85ms', |
| 152 | executionIds: ['exec-1', 'exec-2'], |
| 153 | }, |
| 154 | { |
| 155 | message: 'Unknown error', |
| 156 | count: 1, |
| 157 | lastSeen: 110, |
| 158 | lastExecutionId: undefined, |
| 159 | lastStatusCode: '503', |
| 160 | lastMethod: 'GET', |
| 161 | executionTime: undefined, |
| 162 | executionIds: [], |
| 163 | }, |
| 164 | ]) |
| 165 | }) |
| 166 | |
| 167 | it('deduplicates execution ids and attaches grouped runtime logs', () => { |
| 168 | const recentErrorGroupsBase = [ |
| 169 | { |
| 170 | message: 'database exploded', |
| 171 | count: 2, |
| 172 | lastSeen: 120, |
| 173 | lastExecutionId: 'exec-2', |
| 174 | lastStatusCode: '500', |
| 175 | lastMethod: 'POST', |
| 176 | executionTime: '85ms', |
| 177 | executionIds: ['exec-1', 'exec-2', 'exec-1'], |
| 178 | }, |
| 179 | ] |
| 180 | |
| 181 | expect(getRelatedExecutionIds(recentErrorGroupsBase)).toEqual(['exec-1', 'exec-2']) |
| 182 | |
| 183 | expect( |
| 184 | getRecentErrorGroups({ |
| 185 | recentErrorGroupsBase, |
| 186 | functionRuntimeLogs: [ |
| 187 | { |
| 188 | id: 'runtime-log-1', |
| 189 | execution_id: 'exec-1', |
| 190 | level: 'error', |
| 191 | event_message: 'stack trace', |
| 192 | timestamp: 101, |
| 193 | }, |
| 194 | { |
| 195 | id: 'runtime-log-2', |
| 196 | execution_id: 'exec-2', |
| 197 | level: 'error', |
| 198 | event_message: 'stack trace', |
| 199 | timestamp: 121, |
| 200 | }, |
| 201 | { |
| 202 | id: 'runtime-log-3', |
| 203 | execution_id: 'exec-2', |
| 204 | event_type: 'warn', |
| 205 | event_message: 'retrying upstream', |
| 206 | timestamp: 119, |
| 207 | }, |
| 208 | { |
| 209 | id: 'runtime-log-4', |
| 210 | execution_id: '', |
| 211 | level: 'info', |
| 212 | event_message: 'ignored', |
| 213 | timestamp: 999, |
| 214 | }, |
| 215 | ], |
| 216 | }) |
| 217 | ).toEqual([ |
| 218 | { |
| 219 | ...recentErrorGroupsBase[0], |
| 220 | logs: [ |
| 221 | { |
| 222 | key: 'error:stack trace', |
| 223 | message: 'stack trace', |
| 224 | level: 'error', |
| 225 | count: 2, |
| 226 | lastSeen: 121, |
| 227 | }, |
| 228 | { |
| 229 | key: 'warn:retrying upstream', |
| 230 | message: 'retrying upstream', |
| 231 | level: 'warn', |
| 232 | count: 1, |
| 233 | lastSeen: 119, |
| 234 | }, |
| 235 | ], |
| 236 | }, |
| 237 | ]) |
| 238 | }) |
| 239 | |
| 240 | it('formats timestamps, prompts, and status variants', () => { |
| 241 | expect(formatLogTimestamp(undefined, 'time')).toBe('-') |
| 242 | expect(formatLogTimestamp('2026-03-20T10:15:00.000Z', 'time')).toBe('10:15:00') |
| 243 | |
| 244 | expect( |
| 245 | buildGroupAssistantPrompt( |
| 246 | { |
| 247 | message: 'database exploded', |
| 248 | count: 2, |
| 249 | lastSeen: 1742465700000000, |
| 250 | lastExecutionId: 'exec-2', |
| 251 | lastStatusCode: '500', |
| 252 | lastMethod: 'POST', |
| 253 | executionTime: '85ms', |
| 254 | executionIds: ['exec-1', 'exec-2'], |
| 255 | logs: [ |
| 256 | { |
| 257 | key: 'error:stack trace', |
| 258 | message: 'stack trace', |
| 259 | level: 'error', |
| 260 | count: 2, |
| 261 | lastSeen: 1742465700000000, |
| 262 | }, |
| 263 | ], |
| 264 | }, |
| 265 | 'my-function' |
| 266 | ) |
| 267 | ).toContain('Analyze this edge function error since the last deploy for `my-function`.') |
| 268 | |
| 269 | expect(getStatusBadgeVariant()).toBe('destructive') |
| 270 | expect(getStatusBadgeVariant('500')).toBe('destructive') |
| 271 | expect(getStatusBadgeVariant('404')).toBe('default') |
| 272 | }) |
| 273 | |
| 274 | it('summarizes verbose error messages by trimming the stack trace', () => { |
| 275 | expect(summarizeErrorMessage('')).toBe('') |
| 276 | expect(summarizeErrorMessage('boom')).toBe('boom') |
| 277 | expect( |
| 278 | summarizeErrorMessage( |
| 279 | "SyntaxError: Expected ',' or '}' after property value in JSON at position 22 at parse (<anonymous>) at packageData (ext:deno_fetch/22_body.js:408:14)" |
| 280 | ) |
| 281 | ).toBe("SyntaxError: Expected ',' or '}' after property value in JSON at position 22") |
| 282 | expect(summarizeErrorMessage(' multi\n line\t error ')).toBe('multi line error') |
| 283 | }) |
| 284 | |
| 285 | it('prefers the first runtime error log message and falls back to invocation message', () => { |
| 286 | expect( |
| 287 | getDisplayErrorMessage({ |
| 288 | message: 'https://example.briven.red/functions/v1/hello-world', |
| 289 | count: 1, |
| 290 | lastSeen: 0, |
| 291 | executionIds: [], |
| 292 | logs: [ |
| 293 | { |
| 294 | key: 'log:booted (time: 22ms)', |
| 295 | message: 'booted (time: 22ms)', |
| 296 | level: 'log', |
| 297 | count: 1, |
| 298 | lastSeen: 1, |
| 299 | }, |
| 300 | { |
| 301 | key: 'error:SyntaxError: bad json at parse (<anonymous>)', |
| 302 | message: 'SyntaxError: bad json at parse (<anonymous>)', |
| 303 | level: 'error', |
| 304 | count: 1, |
| 305 | lastSeen: 2, |
| 306 | }, |
| 307 | ], |
| 308 | }) |
| 309 | ).toBe('SyntaxError: bad json') |
| 310 | |
| 311 | expect( |
| 312 | getDisplayErrorMessage({ |
| 313 | message: 'https://example.briven.red/functions/v1/hello-world', |
| 314 | count: 1, |
| 315 | lastSeen: 0, |
| 316 | executionIds: [], |
| 317 | logs: [], |
| 318 | }) |
| 319 | ).toBe('https://example.briven.red/functions/v1/hello-world') |
| 320 | }) |
| 321 | |
| 322 | it('builds a troubleshooting docs URL keyed off the response status code', () => { |
| 323 | expect(buildTroubleshootingDocsUrl({ statusCode: '500' })).toBe( |
| 324 | 'https://supabase.com/docs/guides/troubleshooting/edge-function-500-response' |
| 325 | ) |
| 326 | expect(buildTroubleshootingDocsUrl({ statusCode: '503' })).toBe( |
| 327 | 'https://supabase.com/docs/guides/troubleshooting/edge-function-503-response' |
| 328 | ) |
| 329 | expect(buildTroubleshootingDocsUrl({})).toBe( |
| 330 | 'https://supabase.com/docs/guides/troubleshooting?search=edge%20function' |
| 331 | ) |
| 332 | expect(buildTroubleshootingDocsUrl({ statusCode: 'not-a-number' })).toBe( |
| 333 | 'https://supabase.com/docs/guides/troubleshooting?search=edge%20function' |
| 334 | ) |
| 335 | }) |
| 336 | }) |