Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/cli/src/services/__tests__/abstract-check-runner.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import AbstractCheckRunner, { Events, SequenceId } from '../abstract-check-runner.js'
import { NoMatchingChecksError } from '../../rest/test-sessions.js'

// ---------------------------------------------------------------------------
// Module mocks — must be hoisted before any imports that pull these in
Expand Down Expand Up @@ -239,4 +240,81 @@ describe('AbstractCheckRunner — SocketClient lifecycle', () => {

expect(mockClient.endAsync).toHaveBeenCalledTimes(1)
})

it('reports safe diagnostics when MQTT connection fails without a message', async () => {
const connectionError = Object.assign(new Error(''), { code: 'ECONNRESET' })
vi.mocked(SocketClient.connect).mockRejectedValueOnce(connectionError)
const runner = makeRunner()
const errors: Error[] = []
runner.on(Events.ERROR, error => errors.push(error))

await runner.run()

expect(errors).toHaveLength(1)
expect(errors[0].message).toBe('MQTT connection failed: no error message was provided (code: ECONNRESET)')
})

it('omits unsafe MQTT connection error codes from fallback diagnostics', async () => {
const connectionError = Object.assign(new Error(''), { code: 'api-key=secret-value' })
vi.mocked(SocketClient.connect).mockRejectedValueOnce(connectionError)
const runner = makeRunner()
const errors: Error[] = []
runner.on(Events.ERROR, error => errors.push(error))

await runner.run()

expect(errors).toHaveLength(1)
expect(errors[0].message).toBe('MQTT connection failed: no error message was provided')
expect(errors[0].message).not.toContain('secret-value')
})

it('preserves useful MQTT connection error messages', async () => {
const connectionError = new Error('Connection refused')
vi.mocked(SocketClient.connect).mockRejectedValueOnce(connectionError)
const runner = makeRunner()
const errors: Error[] = []
runner.on(Events.ERROR, error => errors.push(error))

await runner.run()

expect(errors).toHaveLength(1)
expect(errors[0].message).toBe('Connection refused')
})

it('reports safe diagnostics when MQTT subscription fails without a message', async () => {
const subscriptionError = Object.assign(new Error(''), { code: 135 })
const mockClient = {
on: vi.fn(),
subscribeAsync: vi.fn().mockRejectedValue(subscriptionError),
endAsync: vi.fn().mockResolvedValue(undefined),
}
vi.mocked(SocketClient.connect).mockResolvedValueOnce(mockClient as any)
const runner = makeRunner()
const errors: Error[] = []
runner.on(Events.ERROR, error => errors.push(error))

await runner.run()

expect(errors).toHaveLength(1)
expect(errors[0].message).toBe('MQTT subscription failed: no error message was provided (code: 135)')
})

it('preserves typed scheduling errors', async () => {
const mockClient = {
on: vi.fn(),
subscribeAsync: vi.fn().mockResolvedValue(undefined),
endAsync: vi.fn().mockResolvedValue(undefined),
}
vi.mocked(SocketClient.connect).mockResolvedValueOnce(mockClient as any)
const schedulingError = new NoMatchingChecksError()
const runner = makeRunner()
runner.scheduleChecks = vi.fn().mockRejectedValue(schedulingError)
const errors: Error[] = []
runner.on(Events.ERROR, error => errors.push(error))

await runner.run()

expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(NoMatchingChecksError)
})
})
27 changes: 25 additions & 2 deletions packages/cli/src/services/abstract-check-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ export const DEFAULT_PLAYWRIGHT_CHECK_RUN_TIMEOUT_SECONDS = 1200

const DEFAULT_SCHEDULING_DELAY_EXCEEDED_MS = 20000

function ensureErrorMessage (err: unknown, fallback: string): Error {
if (!(err instanceof Error)) {
return new Error(fallback)
}
if (err.message.trim()) {
return err
}

const code = (err as Error & { code?: unknown }).code
const isSafeNumericCode = typeof code === 'number' && Number.isFinite(code)
const isSafeStringCode = typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)
err.message = isSafeNumericCode || isSafeStringCode ? `${fallback} (code: ${code})` : fallback
return err
}

export default abstract class AbstractCheckRunner extends EventEmitter {
checks: Map<SequenceId, { check: any }>
testSessionId?: string
Expand Down Expand Up @@ -96,10 +111,18 @@ export default abstract class AbstractCheckRunner extends EventEmitter {
return
}

socketClient = await SocketClient.connect()
try {
socketClient = await SocketClient.connect()
} catch (err) {
throw ensureErrorMessage(err, 'MQTT connection failed: no error message was provided')
}

// Configure the socket listener and allChecksFinished listener before starting checks to avoid race conditions
await this.configureResultListener(checkRunSuiteId, socketClient)
try {
await this.configureResultListener(checkRunSuiteId, socketClient)
} catch (err) {
throw ensureErrorMessage(err, 'MQTT subscription failed: no error message was provided')
}

const { testSessionId, checks } = await this.scheduleChecks(checkRunSuiteId)
this.testSessionId = testSessionId
Expand Down
Loading