@desplega.ai/qa-use 2.15.0 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api/index.ts CHANGED
@@ -840,9 +840,89 @@ export class ApiClient {
840
840
  * Run test definitions with SSE streaming progress
841
841
  * @param options - Test execution options
842
842
  * @param onEvent - Optional callback for SSE events
843
+ * @param runtimeOptions - Optional runtime controls (idle timeout, external signal)
843
844
  * @returns Promise resolving to test result
844
845
  */
845
- async runCliTest(options: RunCliTestOptions, onEvent?: SSECallback): Promise<RunCliTestResult> {
846
+ async runCliTest(
847
+ options: RunCliTestOptions,
848
+ onEvent?: SSECallback,
849
+ runtimeOptions?: { idleTimeoutSec?: number; signal?: AbortSignal }
850
+ ): Promise<RunCliTestResult> {
851
+ // Internal AbortController is wired into both `fetch` and `streamSSE` so
852
+ // we can deterministically tear down the underlying TCP socket on every
853
+ // exit path. Phase 2 actively aborts on terminal SSE events (`complete` /
854
+ // `error`) so we don't wait for the backend to close the stream.
855
+ // Phase 3 widens the public signature to accept a caller signal and an
856
+ // idle-timeout watchdog (`--timeout` end-to-end).
857
+ const controller = new AbortController();
858
+
859
+ // Tracks why the loop terminated so the catch block can distinguish
860
+ // "we aborted on purpose because of `complete`/`error`" (swallow the
861
+ // AbortError, return result) from "real network error or external abort"
862
+ // (re-throw), or from "idle timeout fired" (throw a typed timeout error).
863
+ //
864
+ // The reason is mutated from inside async callbacks (idle watchdog
865
+ // setTimeout, caller signal abort listener) which TS can't see during
866
+ // control-flow analysis. We use a small wrapper object so TS doesn't
867
+ // narrow the type to the literal assigned in the synchronous flow.
868
+ type TerminationReason = 'complete' | 'idle-timeout' | 'external' | null;
869
+ const reasonRef: { current: TerminationReason } = { current: null };
870
+ let result: RunCliTestResult | null = null;
871
+
872
+ const idleTimeoutSec = runtimeOptions?.idleTimeoutSec;
873
+ const idleTimeoutMs =
874
+ idleTimeoutSec && idleTimeoutSec > 0 ? Math.round(idleTimeoutSec * 1000) : 0;
875
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
876
+
877
+ const clearIdleTimer = () => {
878
+ if (idleTimer !== null) {
879
+ clearTimeout(idleTimer);
880
+ idleTimer = null;
881
+ }
882
+ };
883
+
884
+ const armIdleTimer = () => {
885
+ if (idleTimeoutMs <= 0) return;
886
+ clearIdleTimer();
887
+ idleTimer = setTimeout(() => {
888
+ // Idle watchdog fired — no SSE bytes for `idleTimeoutSec` seconds.
889
+ // Mark the reason BEFORE aborting so the catch block can throw a
890
+ // typed timeout error instead of swallowing the AbortError.
891
+ reasonRef.current = 'idle-timeout';
892
+ controller.abort();
893
+ }, idleTimeoutMs);
894
+ // Don't keep the event loop alive just for the watchdog.
895
+ if (typeof idleTimer === 'object' && idleTimer && 'unref' in idleTimer) {
896
+ (idleTimer as { unref: () => void }).unref();
897
+ }
898
+ };
899
+
900
+ // Wire caller-provided signal: if it aborts, mark `terminationReason` so
901
+ // the catch block re-throws (instead of swallowing) with the abort error.
902
+ const callerSignal = runtimeOptions?.signal;
903
+ let callerAbortListener: (() => void) | null = null;
904
+ if (callerSignal) {
905
+ if (callerSignal.aborted) {
906
+ reasonRef.current = 'external';
907
+ controller.abort();
908
+ } else {
909
+ callerAbortListener = () => {
910
+ if (reasonRef.current === null) {
911
+ reasonRef.current = 'external';
912
+ }
913
+ controller.abort();
914
+ };
915
+ callerSignal.addEventListener('abort', callerAbortListener, { once: true });
916
+ }
917
+ }
918
+
919
+ // Merge caller signal with internal controller.signal so `fetch` and
920
+ // `streamSSE` see whichever fires first. `AbortSignal.any` is Node 20.3+
921
+ // and the project requires Node 20+.
922
+ const mergedSignal: AbortSignal = callerSignal
923
+ ? AbortSignal.any([callerSignal, controller.signal])
924
+ : controller.signal;
925
+
846
926
  try {
847
927
  const response = await fetch(`${this.getApiUrl()}/vibe-qa/cli/run`, {
848
928
  method: 'POST',
@@ -853,6 +933,7 @@ export class ApiClient {
853
933
  Accept: 'text/event-stream',
854
934
  },
855
935
  body: JSON.stringify(options),
936
+ signal: mergedSignal,
856
937
  });
857
938
 
858
939
  if (!response.ok) {
@@ -863,28 +944,77 @@ export class ApiClient {
863
944
  throw new Error(formatApiError(errorData, `HTTP ${response.status}: Failed to run test`));
864
945
  }
865
946
 
866
- let result: RunCliTestResult | null = null;
867
-
868
- // Stream SSE events
869
- for await (const event of streamSSE(response)) {
947
+ // Arm the idle watchdog AFTER headers arrive — first event may take
948
+ // a while on a cold backend / large test. We don't punish slow first
949
+ // bytes; we punish silence between bytes.
950
+ armIdleTimer();
951
+
952
+ // Stream SSE events. `onChunk` resets the idle watchdog on every
953
+ // byte chunk (so SSE comment pings — which yield zero parsed events
954
+ // — still keep the connection alive).
955
+ for await (const event of streamSSE(response, {
956
+ signal: mergedSignal,
957
+ onChunk: () => armIdleTimer(),
958
+ })) {
870
959
  if (onEvent) onEvent(event);
871
960
 
872
- // Capture final result
961
+ // Capture final result and tear down the socket immediately on a
962
+ // terminal event. Trailing events (e.g. `test_fixed`, `persisted`)
963
+ // that may arrive after `complete` are intentionally dropped per the
964
+ // plan — the backend keeps the SSE stream open, so waiting for it to
965
+ // close on its own is what causes the ~80s hang we're fixing.
873
966
  if (event.event === 'complete' || event.event === 'error') {
874
967
  result = event.data;
968
+ reasonRef.current = 'complete';
969
+ controller.abort();
970
+ break;
875
971
  }
876
972
  }
877
973
 
878
974
  if (!result) {
975
+ // No terminal event arrived AND the stream closed (or was aborted).
976
+ // If we got here via idle timeout, surface that explicitly.
977
+ if (reasonRef.current === 'idle-timeout') {
978
+ throw new Error(`Test run timed out: no SSE events for ${idleTimeoutSec}s`);
979
+ }
879
980
  throw new Error('No result received from test execution');
880
981
  }
881
982
 
882
983
  return result;
883
984
  } catch (error) {
985
+ // If we triggered the abort ourselves on a terminal event, the for-await
986
+ // loop may surface the abort as an AbortError after we've already
987
+ // captured `result`. Swallow it and return the captured result.
988
+ if (
989
+ reasonRef.current === 'complete' &&
990
+ error instanceof Error &&
991
+ error.name === 'AbortError' &&
992
+ result
993
+ ) {
994
+ return result;
995
+ }
996
+ // Idle-timeout: the abort came from our watchdog, not from a real
997
+ // network failure. Surface a typed message that tools (and humans)
998
+ // can grep for.
999
+ if (
1000
+ reasonRef.current === 'idle-timeout' &&
1001
+ error instanceof Error &&
1002
+ error.name === 'AbortError'
1003
+ ) {
1004
+ throw new Error(`Test run timed out: no SSE events for ${idleTimeoutSec}s`);
1005
+ }
884
1006
  if (error instanceof Error) {
885
1007
  throw error;
886
1008
  }
887
1009
  throw new Error('Unknown error running test');
1010
+ } finally {
1011
+ // Defensive teardown: abort is idempotent. Guarantees the socket is
1012
+ // released even if the consumer above threw mid-iteration.
1013
+ controller.abort();
1014
+ clearIdleTimer();
1015
+ if (callerSignal && callerAbortListener) {
1016
+ callerSignal.removeEventListener('abort', callerAbortListener);
1017
+ }
888
1018
  }
889
1019
  }
890
1020