diff --git a/.changeset/pr-99.md b/.changeset/pr-99.md new file mode 100644 index 0000000..a1e933b --- /dev/null +++ b/.changeset/pr-99.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed WebdriverIO test results sometimes not appearing (builds staying "in progress") when the build-completion signal failed or the test runner was interrupted. diff --git a/packages/browserstack-service/src/exitHandler.ts b/packages/browserstack-service/src/exitHandler.ts index b8e1e13..67a7f37 100644 --- a/packages/browserstack-service/src/exitHandler.ts +++ b/packages/browserstack-service/src/exitHandler.ts @@ -1,18 +1,155 @@ -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import path from 'node:path' import BrowserStackConfig from './config.js' import { saveFunnelData } from './instrumentation/funnelInstrumentation.js' import { fileURLToPath } from 'node:url' -import { BROWSERSTACK_TESTHUB_JWT } from './constants.js' +import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID } from './constants.js' import PerformanceTester from './instrumentation/performance/performance-tester.js' import TestOpsConfig from './testOps/testOpsConfig.js' import { BStackLogger } from './bstackLogger.js' import { BrowserstackCLI } from './cli/index.js' +import APIUtils from './cli/apiUtils.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +// SDK-7061: a termination signal (SIGTERM / SIGINT / …) delivered mid-run kills the WDIO +// launcher before onComplete or the 'exit' hook can send the TRA build-stop, so the build +// hangs 'running' until the ~60-min server-side inactivity timeout. An async stop from a +// signal handler does NOT help: WDIO's launcher calls process.exit ~70ms into onComplete, +// the event loop stops, and the in-flight async PUT never lands. Java avoids this because +// Runtime.getRuntime().addShutdownHook runs synchronously and the JVM blocks exit until the +// hook returns. Node has no native sync HTTP, so replicate that blocking semantics with a +// spawnSync child: the parent process cannot exit until the stop PUT has completed. +// Guarded so we never double-send with onComplete or the 'exit' cleanup path. +const STOP_ENDPOINT_ENV = '__BROWSERSTACK_STOP_ENDPOINT' + +// Inline script run by the blocking spawnSync child. Node `-e` defaults to CommonJS, so +// require() is available. Reads UUID/JWT/endpoint from the inherited env, PUTs the build +// stop, treats non-2xx as failure, retries up to 3x with backoff, exits 0 only on success. +const SYNC_STOP_SCRIPT = ` +const uuid = process.env['${BROWSERSTACK_TESTHUB_UUID}']; +const jwt = process.env['${BROWSERSTACK_TESTHUB_JWT}']; +const endpoint = process.env['${STOP_ENDPOINT_ENV}']; +if (!uuid || !jwt || !endpoint) { process.exit(0); } +const target = endpoint + '/api/v1/builds/' + uuid + '/stop'; +const body = JSON.stringify({ stop_time: new Date().toISOString() }); +const lib = target.indexOf('https') === 0 ? require('https') : require('http'); +function attempt(n) { + const req = lib.request(target, { + method: 'PUT', + timeout: 4000, + headers: { + 'Content-Type': 'application/json', + 'X-BSTACK-OBS': 'true', + 'Authorization': 'Bearer ' + jwt, + 'Content-Length': Buffer.byteLength(body) + } + }, function (res) { + let d = ''; + res.on('data', function (c) { d += c; }); + res.on('end', function () { + if (res.statusCode >= 200 && res.statusCode < 300) { + process.stdout.write('[STOP_BUILD] sync stop success ' + res.statusCode + '\\n'); + process.exit(0); + } else { + retry(n, 'HTTP ' + res.statusCode); + } + }); + }); + req.on('error', function (e) { retry(n, e && e.message); }); + req.on('timeout', function () { req.destroy(new Error('timeout')); }); + req.write(body); + req.end(); +} +function retry(n, msg) { + process.stdout.write('[STOP_BUILD] sync attempt ' + n + ' failed: ' + msg + '\\n'); + if (n < 3) { setTimeout(function () { attempt(n + 1); }, 500 * n); } + else { process.exit(1); } +} +attempt(1); +` + +let signalHandlersRegistered = false +let buildStopSentOnSignal = false + +// SDK-7061: synchronous, blocking build-stop mirroring java's addShutdownHook. The parent +// BLOCKS on spawnSync until the child's stop PUT completes, guaranteeing it lands before +// process.exit. Only the direct-HTTP (non-CLI) observability path with a created build is +// handled here — the CLI manages its own build lifecycle. Best-effort, never throws. +export function stopBuildSyncBlocking(reason: string): void { + // SDK-7061: setupExitHandlers() (which arms this handler) runs in the launcher + // constructor BEFORE BrowserStackConfig is initialized, so getInstance() can return + // undefined during an early uncaughtException. Guard so this never throws — a throw + // from inside the uncaughtException listener would be fatal and mask the original error. + const config = BrowserStackConfig.getInstance() + if (!config) { + return + } + if (buildStopSentOnSignal || config.testObservability?.buildStopped) { + return + } + if (!process.env[BROWSERSTACK_TESTHUB_UUID] || !process.env[BROWSERSTACK_TESTHUB_JWT]) { + return + } + if (BrowserstackCLI.getInstance().isRunning()) { + return + } + buildStopSentOnSignal = true + try { + BStackLogger.debug(`Shutdown (${reason}) — stopping TestHub build synchronously (blocking)`) + const result = spawnSync(process.execPath, ['-e', SYNC_STOP_SCRIPT], { + timeout: 15000, + encoding: 'utf-8', + env: { ...process.env, [STOP_ENDPOINT_ENV]: APIUtils.DATA_ENDPOINT } + }) + if (result.stdout) { + BStackLogger.debug(`[STOP_BUILD] sync child stdout: ${String(result.stdout).trim()}`) + } + if (result.stderr) { + BStackLogger.debug(`[STOP_BUILD] sync child stderr: ${String(result.stderr).trim()}`) + } + if (result.status === 0) { + config.testObservability.buildStopped = true + BStackLogger.debug('[STOP_BUILD] sync stop completed successfully') + } else { + BStackLogger.debug(`[STOP_BUILD] sync stop did not confirm success (status=${result.status}, signal=${result.signal})`) + } + } catch (err) { + BStackLogger.debug(`Error stopping build synchronously on ${reason}: ${err}`) + } +} + +function registerSignalHandlers(): void { + if (signalHandlersRegistered) { + return + } + signalHandlersRegistered = true + + const onSignal = (signal: NodeJS.Signals) => { + stopBuildSyncBlocking(signal) + process.exit(0) + } + + process.on('SIGTERM', onSignal) + process.on('SIGINT', onSignal) + process.on('SIGHUP', onSignal) + if (process.platform === 'win32') { + process.on('SIGBREAK', onSignal) + } else { + process.on('SIGABRT', onSignal) + process.on('SIGQUIT', onSignal) + } + + process.on('uncaughtException', (err) => { + BStackLogger.debug(`Uncaught exception — stopping TestHub build: ${err?.stack || err}`) + stopBuildSyncBlocking('uncaughtException') + process.exit(1) + }) +} + export function setupExitHandlers() { + registerSignalHandlers() const handleCLICleanup = () => { BStackLogger.debug('Handling CLI cleanup in exit handler') try { @@ -45,6 +182,10 @@ export function setupExitHandlers() { } } process.on('exit', () => { + // 'exit' can only do sync work; the blocking spawnSync stop fits. This is the + // guaranteed catch-all — even when WDIO's own process.exit pre-empts our signal + // handler, this still runs synchronously before the process dies (SDK-7061). + stopBuildSyncBlocking('exit') const isCLIEnabled = BrowserstackCLI.getInstance().isRunning() handleCLICleanup() const args = shouldCallCleanup(BrowserStackConfig.getInstance(), isCLIEnabled) diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 6c49c86..fd97655 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -629,8 +629,12 @@ export default class BrowserstackLauncherService implements Services.ServiceInst // SDK-4671: before stopping the build, synthesize TestRunFinished for any // test runs whose worker died mid-test, else they stay 'in progress' on TRA. await finalizeOrphanedRuns() + // SDK-7061: capture the stop result so we only mark the build stopped when it + // actually succeeded. A failed stop must leave buildStopped=false so the + // process-exit cleanup re-stop path can still close the build. + let stopResult: { status?: string } | undefined try { - await (isCLIEnabled ? BrowserstackCLI.getInstance().stop() : stopBuildUpstream()) + stopResult = await (isCLIEnabled ? BrowserstackCLI.getInstance().stop() : stopBuildUpstream()) as { status?: string } | undefined PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.STOP) } catch (err) { BStackLogger.error(`Error while stopping CLI ${err}`) @@ -639,7 +643,11 @@ export default class BrowserstackLauncherService implements Services.ServiceInst if (process.env[BROWSERSTACK_OBSERVABILITY] && process.env[BROWSERSTACK_TESTHUB_UUID]) { console.log(`\nVisit https://automation.browserstack.com/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]} to view build report, insights, and many more debugging information all at one place!\n`) } - this.browserStackConfig.testObservability.buildStopped = true + // CLI path manages its own build lifecycle; for the direct-HTTP path only + // mark stopped when stopBuildUpstream returned success (SDK-7061). + if (isCLIEnabled || stopResult?.status === 'success') { + this.browserStackConfig.testObservability.buildStopped = true + } await PerformanceTester.stopAndGenerate('performance-launcher.html') if (process.env[PERF_MEASUREMENT_ENV]) { diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 3d0254d..d521e72 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -736,30 +736,46 @@ export const stopBuildUpstream = PerformanceTester.measureWrapper(PERFORMANCE_SD 'stop_time': (new Date()).toISOString() } - try { - const url = `${APIUtils.DATA_ENDPOINT}/api/v1/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]}/stop` - const response = await fetch(url, { - method: 'PUT', - headers: { - ...DEFAULT_REQUEST_CONFIG.headers, - 'Authorization': `Bearer ${process.env[BROWSERSTACK_TESTHUB_JWT]}` - }, - body: JSON.stringify(data) - }) - BStackLogger.debug(`[STOP_BUILD] Success response: ${await response.text()}`) - stopBuildUsage.success() - return { - status: 'success', - message: '' - } - } catch (error: unknown) { - stopBuildUsage.failed(error) - BStackLogger.debug(`[STOP_BUILD] Failed. Error: ${error}`) - return { - status: 'error', - message: (error as Error).message + const url = `${APIUtils.DATA_ENDPOINT}/api/v1/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]}/stop` + // SDK-7061: the build-stop PUT is the signal that closes the TRA build. A single + // best-effort request means a transient failure/timeout leaves the build running + // until the ~60-min server-side inactivity timeout. Retry with backoff and treat a + // non-2xx response as a failure so the build reliably reaches a terminal state. + const maxAttempts = 3 + let lastError: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await fetch(url, { + method: 'PUT', + headers: { + ...DEFAULT_REQUEST_CONFIG.headers, + 'Authorization': `Bearer ${process.env[BROWSERSTACK_TESTHUB_JWT]}` + }, + body: JSON.stringify(data) + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`) + } + BStackLogger.debug(`[STOP_BUILD] Success response: ${await response.text()}`) + stopBuildUsage.success() + return { + status: 'success', + message: '' + } + } catch (error: unknown) { + lastError = error + BStackLogger.debug(`[STOP_BUILD] Attempt ${attempt}/${maxAttempts} failed. Error: ${error}`) + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)) + } } } + stopBuildUsage.failed(lastError) + BStackLogger.debug(`[STOP_BUILD] Failed after ${maxAttempts} attempts. Error: ${lastError}`) + return { + status: 'error', + message: (lastError as Error)?.message ?? 'stop build failed' + } })) export function getCiInfo () { diff --git a/packages/browserstack-service/tests/exitHandler.test.ts b/packages/browserstack-service/tests/exitHandler.test.ts new file mode 100644 index 0000000..bc93fcf --- /dev/null +++ b/packages/browserstack-service/tests/exitHandler.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID } from '../src/constants.js' + +// Stable mock fns (survive vi.resetModules, unlike fns created inside a mock factory). +const h = vi.hoisted(() => ({ + spawnSyncMock: vi.fn(), + spawnMock: vi.fn(), + getConfigMock: vi.fn(), + isRunningMock: vi.fn(), +})) + +vi.mock('node:child_process', () => ({ spawnSync: h.spawnSyncMock, spawn: h.spawnMock })) +vi.mock('../src/config.js', () => ({ default: { getInstance: h.getConfigMock } })) +vi.mock('../src/cli/index.js', () => ({ + BrowserstackCLI: { getInstance: () => ({ isRunning: h.isRunningMock, process: null }) }, +})) +vi.mock('../src/cli/apiUtils.js', () => ({ default: { DATA_ENDPOINT: 'https://collector-observability.browserstack.com' } })) +vi.mock('../src/bstackLogger.js', () => ({ BStackLogger: { debug: vi.fn(), error: vi.fn(), info: vi.fn() } })) +vi.mock('../src/instrumentation/funnelInstrumentation.js', () => ({ saveFunnelData: vi.fn() })) +vi.mock('../src/instrumentation/performance/performance-tester.js', () => ({ default: { isEnabled: vi.fn(() => false) } })) +vi.mock('../src/testOps/testOpsConfig.js', () => ({ default: { getInstance: vi.fn(() => ({ buildHashedId: 'x' })) } })) + +// SDK-7061: stopBuildSyncBlocking performs the synchronous, blocking build-stop on process +// shutdown. Fresh module per test resets the module-level buildStopSentOnSignal latch. +const loadStopBuildSyncBlocking = async () => { + vi.resetModules() + const mod = await import('../src/exitHandler.js') + return mod.stopBuildSyncBlocking +} + +describe('stopBuildSyncBlocking', () => { + beforeEach(() => { + vi.clearAllMocks() + h.isRunningMock.mockReturnValue(false) + h.spawnSyncMock.mockReturnValue({ status: 0, stdout: '', stderr: '', signal: null } as any) + process.env[BROWSERSTACK_TESTHUB_UUID] = 'uuid-123' + process.env[BROWSERSTACK_TESTHUB_JWT] = 'jwt-123' + }) + + it('early-returns without throwing or spawning when BrowserStackConfig is undefined', async () => { + h.getConfigMock.mockReturnValue(undefined) + const stopBuildSyncBlocking = await loadStopBuildSyncBlocking() + + expect(() => stopBuildSyncBlocking('uncaughtException')).not.toThrow() + expect(h.spawnSyncMock).not.toHaveBeenCalled() + }) + + it('spawns the blocking stop child exactly once and marks the build stopped on success', async () => { + const config: any = { testObservability: { buildStopped: false } } + h.getConfigMock.mockReturnValue(config) + const stopBuildSyncBlocking = await loadStopBuildSyncBlocking() + + stopBuildSyncBlocking('exit') + + expect(h.spawnSyncMock).toHaveBeenCalledTimes(1) + const [bin, args] = h.spawnSyncMock.mock.calls[0] + expect(bin).toEqual(process.execPath) + expect(args[0]).toEqual('-e') + expect(String(args[1])).toContain('/api/v1/builds/') + expect(config.testObservability.buildStopped).toBe(true) + }) + + it('does not mark the build stopped when the spawnSync child exits non-zero', async () => { + const config: any = { testObservability: { buildStopped: false } } + h.getConfigMock.mockReturnValue(config) + h.spawnSyncMock.mockReturnValue({ status: 1, stdout: '', stderr: 'fail', signal: null } as any) + const stopBuildSyncBlocking = await loadStopBuildSyncBlocking() + + stopBuildSyncBlocking('exit') + + expect(h.spawnSyncMock).toHaveBeenCalledTimes(1) + expect(config.testObservability.buildStopped).toBe(false) + }) + + it('skips the stop when the CLI is running (CLI owns its own build lifecycle)', async () => { + h.getConfigMock.mockReturnValue({ testObservability: { buildStopped: false } }) + h.isRunningMock.mockReturnValue(true) + const stopBuildSyncBlocking = await loadStopBuildSyncBlocking() + + stopBuildSyncBlocking('exit') + + expect(h.spawnSyncMock).not.toHaveBeenCalled() + }) + + it('skips the stop when the TestHub UUID/JWT are absent', async () => { + h.getConfigMock.mockReturnValue({ testObservability: { buildStopped: false } }) + delete process.env[BROWSERSTACK_TESTHUB_UUID] + delete process.env[BROWSERSTACK_TESTHUB_JWT] + const stopBuildSyncBlocking = await loadStopBuildSyncBlocking() + + stopBuildSyncBlocking('exit') + + expect(h.spawnSyncMock).not.toHaveBeenCalled() + }) + + it('skips the stop when the build was already stopped', async () => { + h.getConfigMock.mockReturnValue({ testObservability: { buildStopped: true } }) + const stopBuildSyncBlocking = await loadStopBuildSyncBlocking() + + stopBuildSyncBlocking('exit') + + expect(h.spawnSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index dff09f6..dbffb27 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -17,6 +17,7 @@ import * as FunnelInstrumentation from '../src/instrumentation/funnelInstrumenta import { RERUN_TESTS_ENV, BROWSERSTACK_TESTHUB_UUID, RERUN_ENV } from '../src/constants.js' import * as thUtils from '../src/testHub/utils.js' import TestOpsConfig from '../src/testOps/testOpsConfig.js' +import { BrowserstackCLI } from '../src/cli/index.js' vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('browserstack-local') @@ -728,6 +729,29 @@ describe('onComplete', () => { await service.onComplete() expect(stopBuildUpstreamSpy).toHaveBeenCalledTimes(1) }) + + // SDK-7061: on the direct-HTTP path buildStopped must reflect whether the stop PUT + // actually succeeded, so the process-exit cleanup re-stop can still close the build + // when the in-hook stop failed. + it('marks buildStopped true when the direct-HTTP stop succeeds', async () => { + vi.spyOn(BrowserstackCLI.getInstance(), 'isRunning').mockReturnValue(false) + vi.spyOn(utils, 'stopBuildUpstream').mockResolvedValue({ status: 'success', message: '' } as any) + + const service = new BrowserstackLauncher({} as any, [{}] as any, {} as any) + ;(service as any).browserStackConfig.testObservability.buildStopped = false + await service.onComplete() + expect((service as any).browserStackConfig.testObservability.buildStopped).toBe(true) + }) + + it('leaves buildStopped false when the direct-HTTP stop fails', async () => { + vi.spyOn(BrowserstackCLI.getInstance(), 'isRunning').mockReturnValue(false) + vi.spyOn(utils, 'stopBuildUpstream').mockResolvedValue({ status: 'error', message: 'boom' } as any) + + const service = new BrowserstackLauncher({} as any, [{}] as any, {} as any) + ;(service as any).browserStackConfig.testObservability.buildStopped = false + await service.onComplete() + expect((service as any).browserStackConfig.testObservability.buildStopped).toBe(false) + }) }) describe('constructor', () => { diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 1260156..af292e2 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -824,6 +824,17 @@ describe('getScenarioExamples', () => { }) describe('stopBuildUpstream', () => { + // Fake setTimeout (in addition to the module-level Date) so the SDK-7061 retry + // backoff can be advanced deterministically without real wall-clock delay. + // performance is intentionally NOT faked (negative perf.now() under the 2020 + // system clock breaks perf_hooks on Node 18) — mirrors the module-level config. + // useRealTimers() first is required: re-calling useFakeTimers over an already-active + // fake clock does NOT widen `toFake`, so setTimeout would otherwise stay real. + const useBackoffTimers = () => { + vi.useRealTimers() + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }).setSystemTime(new Date('2020-01-01')) + } + it('return error if completed but jwt token not present', async () => { process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' delete process.env[BROWSERSTACK_TESTHUB_JWT] @@ -839,10 +850,13 @@ describe('stopBuildUpstream', () => { process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' process.env[BROWSERSTACK_TESTHUB_JWT] = 'jwt' + // SDK-7061: the stop path now checks response.ok and reads response.text(), + // so the mock must yield an ok (2xx) response with a readable body. vi.mocked(fetch).mockReturnValueOnce(Promise.resolve(Response.json({}))) const result: any = await stopBuildUpstream() expect(vi.mocked(fetch).mock.calls[0][1]?.method).toEqual('PUT') + expect(vi.mocked(fetch).mock.calls.length).toEqual(1) expect(result.status).toEqual('success') }) @@ -850,15 +864,71 @@ describe('stopBuildUpstream', () => { process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' process.env[BROWSERSTACK_TESTHUB_JWT] = 'jwt' - vi.mocked(fetch).mockReturnValueOnce(Promise.reject(Response.json({}))) + // SDK-7061: the stop PUT is now retried up to 3x with a 500ms*attempt backoff. + // Reject every attempt so the loop exhausts and returns an error. Fake timers keep + // the ~1.5s of cumulative backoff out of wall-clock and make the run deterministic. + useBackoffTimers() + vi.mocked(fetch) + .mockImplementationOnce(() => Promise.reject(new Error('network'))) + .mockImplementationOnce(() => Promise.reject(new Error('network'))) + .mockImplementationOnce(() => Promise.reject(new Error('network'))) + + const promise = stopBuildUpstream() + await vi.advanceTimersByTimeAsync(2000) + const result: any = await promise - const result: any = await stopBuildUpstream() expect(vi.mocked(fetch).mock.calls[0][1]?.method).toEqual('PUT') + expect(vi.mocked(fetch).mock.calls.length).toEqual(3) + expect(result.status).toEqual('error') + }) + + it('retries on a non-2xx response and returns error when all attempts fail', async () => { + process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' + process.env[BROWSERSTACK_TESTHUB_JWT] = 'jwt' + + // SDK-7061: a non-2xx response is a failure and must be retried (previously a + // best-effort PUT logged any response as success). + useBackoffTimers() + vi.mocked(fetch) + .mockReturnValueOnce(Promise.resolve(Response.json({}, { status: 500, statusText: 'Server Error' }))) + .mockReturnValueOnce(Promise.resolve(Response.json({}, { status: 502, statusText: 'Bad Gateway' }))) + .mockReturnValueOnce(Promise.resolve(Response.json({}, { status: 503, statusText: 'Service Unavailable' }))) + + const promise = stopBuildUpstream() + await vi.advanceTimersByTimeAsync(2000) + const result: any = await promise + + expect(vi.mocked(fetch).mock.calls.length).toEqual(3) expect(result.status).toEqual('error') }) + it('returns success when a later retry attempt succeeds', async () => { + process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' + process.env[BROWSERSTACK_TESTHUB_JWT] = 'jwt' + + // SDK-7061: a transient failure followed by a 2xx must resolve to success without + // exhausting all attempts. + useBackoffTimers() + vi.mocked(fetch) + .mockReturnValueOnce(Promise.resolve(Response.json({}, { status: 500, statusText: 'Server Error' }))) + .mockReturnValueOnce(Promise.resolve(Response.json({}))) + + const promise = stopBuildUpstream() + await vi.advanceTimersByTimeAsync(2000) + const result: any = await promise + + expect(vi.mocked(fetch).mock.calls.length).toEqual(2) + expect(result.status).toEqual('success') + }) + afterEach(() => { + // mockClear (not mockReset) so the shared global fetch mock keeps its default + // implementation for the rest of the file; the *Once queues above are fully + // consumed within each test, so nothing leaks. vi.mocked(fetch).mockClear() + // Restore the module-level clock config (only Date faked) so later suites in this + // file are unaffected by the setTimeout faking these retry tests turn on. + vi.useFakeTimers({ toFake: ['Date'] }).setSystemTime(new Date('2020-01-01')) }) })