Skip to content
This repository was archived by the owner on Feb 20, 2026. It is now read-only.

Commit fd653b6

Browse files
committed
Fix SSE buffer parsing and add timeout support
Fix partial event loss when chunk boundaries fall mid-SSE-message by retaining the last (potentially incomplete) element from buffer.split(). Add configurable timeout (default 600s) to prevent the action from hanging indefinitely when the backend never sends a terminal event.
1 parent 1c31796 commit fd653b6

File tree

6 files changed

+239
-34
lines changed

6 files changed

+239
-34
lines changed

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# desplega.ai-action
2+
3+
## Versioning
4+
5+
Versions are tracked via **git tags** (not `package.json`). The tag format is `vX.Y.Z`.
6+
7+
To bump the version after a commit:
8+
```
9+
git tag v<new-version>
10+
```
11+
12+
Always create a new tag when shipping changes. Use semver: patch for fixes, minor for new features.
13+
14+
## Build
15+
16+
After modifying source files, rebuild the dist bundle:
17+
```
18+
npm run package
19+
```
20+
21+
The `dist/index.js` must be committed — it's the action entry point.

__tests__/main.test.ts

Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class MockReadableStreamDefaultReader {
5959
}
6060
return this.events[this.currentEventIndex++]
6161
}
62+
63+
releaseLock(): void {
64+
// no-op for mock
65+
}
6266
}
6367

6468
const mockReader = new MockReadableStreamDefaultReader()
@@ -326,9 +330,7 @@ describe('main.ts', () => {
326330
await run()
327331

328332
expect(core.setOutput).toHaveBeenCalledWith('status', status)
329-
330-
// Reader issue triggers this, but only once
331-
expect(core.setFailed).toHaveBeenCalledTimes(1)
333+
expect(core.setFailed).not.toHaveBeenCalled()
332334
}
333335
)
334336
})
@@ -373,9 +375,7 @@ describe('main.ts', () => {
373375
await run()
374376

375377
expect(core.setOutput).not.toHaveBeenCalledWith('status', status)
376-
377-
// Reader issue triggers this, but only once
378-
expect(core.setFailed).toHaveBeenCalledTimes(1)
378+
expect(core.setFailed).not.toHaveBeenCalled()
379379
}
380380
)
381381
})
@@ -577,4 +577,154 @@ describe('main.ts', () => {
577577
)
578578
})
579579
})
580+
581+
describe('Timeout', () => {
582+
it('Should fail with timeout message when SSE reader hangs', async () => {
583+
// Use fake timers so we can advance the timeout instantly
584+
jest.useFakeTimers()
585+
586+
// Set timeout to 1 second
587+
core.getInput.mockImplementation((name) => {
588+
if (name === 'apiKey') return mockApiKey
589+
if (name === 'originUrl') return mockOriginUrl
590+
if (name === 'suiteIds') return 'suite1,suite2'
591+
if (name === 'failFast') return 'false'
592+
if (name === 'block') return 'false'
593+
if (name === 'timeout') return '1'
594+
return ''
595+
})
596+
597+
// Track the abort signal so we can simulate AbortError when aborted
598+
let capturedSignal: AbortSignal | undefined
599+
600+
fetchMock.mockImplementation(async (url, init) => {
601+
if (url === `${mockOriginUrl}/version`) {
602+
return createMockResponse({
603+
ok: true,
604+
json: async () => ({ version: '1337' })
605+
})
606+
} else if (url === `${mockOriginUrl}/external/actions/trigger`) {
607+
return createMockResponse({
608+
ok: true,
609+
json: async () => ({ run_id: mockRunId })
610+
})
611+
} else if (
612+
url === `${mockOriginUrl}/external/actions/run/${mockRunId}/events`
613+
) {
614+
// Capture the abort signal from the request
615+
capturedSignal = (init as RequestInit)?.signal ?? undefined
616+
617+
// Create a reader that blocks until aborted
618+
const hangingReader = {
619+
read: () =>
620+
new Promise<{ done: boolean; value: Uint8Array }>(
621+
(resolve, reject) => {
622+
if (capturedSignal?.aborted) {
623+
const err = new Error('The operation was aborted')
624+
err.name = 'AbortError'
625+
reject(err)
626+
return
627+
}
628+
// Listen for abort to reject the promise
629+
capturedSignal?.addEventListener('abort', () => {
630+
const err = new Error('The operation was aborted')
631+
err.name = 'AbortError'
632+
reject(err)
633+
})
634+
// Never resolves on its own — simulates a hanging reader
635+
}
636+
),
637+
releaseLock: () => {}
638+
}
639+
640+
return createMockResponse({
641+
ok: true,
642+
body: { getReader: () => hangingReader }
643+
})
644+
}
645+
646+
return createMockResponse({
647+
ok: false,
648+
status: 404,
649+
text: async () => 'Not found'
650+
})
651+
})
652+
653+
// Start run() — it will block on the hanging reader
654+
const runPromise = run()
655+
656+
// Advance timers past the 1-second timeout
657+
await jest.advanceTimersByTimeAsync(1500)
658+
659+
await runPromise
660+
661+
expect(core.setFailed).toHaveBeenCalledWith(
662+
'Timed out after 1s waiting for test suite completion'
663+
)
664+
665+
jest.useRealTimers()
666+
})
667+
})
668+
669+
describe('SSE buffer partial chunk parsing', () => {
670+
it('Should correctly parse events split across multiple chunks', async () => {
671+
core.getInput.mockImplementation((name) => {
672+
if (name === 'apiKey') return mockApiKey
673+
if (name === 'originUrl') return mockOriginUrl
674+
if (name === 'suiteIds') return 'suite1,suite2'
675+
if (name === 'failFast') return 'false'
676+
if (name === 'block') return 'false'
677+
return ''
678+
})
679+
680+
fetchMock.mockImplementation(async (url) => {
681+
if (url === `${mockOriginUrl}/version`) {
682+
return createMockResponse({
683+
ok: true,
684+
json: async () => ({ version: '1337' })
685+
})
686+
} else if (url === `${mockOriginUrl}/external/actions/trigger`) {
687+
return createMockResponse({
688+
ok: true,
689+
json: async () => ({ run_id: mockRunId })
690+
})
691+
} else if (
692+
url === `${mockOriginUrl}/external/actions/run/${mockRunId}/events`
693+
) {
694+
const encoder = new TextEncoder()
695+
// Split the event mid-message across two chunks
696+
const splitReader = new MockReadableStreamDefaultReader()
697+
splitReader.setEvents([
698+
{
699+
done: false,
700+
value: encoder.encode(
701+
'event: test_suite_run.event\ndata: {"status":'
702+
)
703+
},
704+
{
705+
done: false,
706+
value: encoder.encode(' "passed", "elapsed": 1.5}\n\n')
707+
},
708+
{ done: true, value: new Uint8Array() }
709+
])
710+
711+
return createMockResponse({
712+
ok: true,
713+
body: { getReader: () => splitReader }
714+
})
715+
}
716+
717+
return createMockResponse({
718+
ok: false,
719+
status: 404,
720+
text: async () => 'Not found'
721+
})
722+
})
723+
724+
await run()
725+
726+
expect(core.setOutput).toHaveBeenCalledWith('status', 'passed')
727+
expect(core.setFailed).not.toHaveBeenCalled()
728+
})
729+
})
580730
})

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ inputs:
3232
Maximum number of retries for the trigger call (0 disables retries)
3333
required: false
3434
default: '0'
35+
timeout:
36+
description: 'Maximum time in seconds to wait for the test suite to complete'
37+
required: false
38+
default: '600'
3539

3640
# Define your outputs here.
3741
outputs:

dist/index.js

Lines changed: 24 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)