From 32d767c9374eb7813840192d4fc5e7ee83c1dcfd Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Sun, 23 Aug 2026 23:10:49 +0700 Subject: [PATCH 1/5] fix: halt test --- src/browser-pool/basic-pool.ts | 4 ++-- src/browser-pool/caching-pool.ts | 4 ++-- src/testplane.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/browser-pool/basic-pool.ts b/src/browser-pool/basic-pool.ts index b2725a145..2cd38d578 100644 --- a/src/browser-pool/basic-pool.ts +++ b/src/browser-pool/basic-pool.ts @@ -111,10 +111,10 @@ export class BasicPool implements Pool { }); } - cancel(): void { + cancel(err?: Error): void { this._cancelled = true; - _.forEach(this._activeSessions, browser => browser.quit()); + _.forEach(this._activeSessions, browser => browser.quit(err)); this._activeSessions = {}; } diff --git a/src/browser-pool/caching-pool.ts b/src/browser-pool/caching-pool.ts index 33a35f980..66fab6d62 100644 --- a/src/browser-pool/caching-pool.ts +++ b/src/browser-pool/caching-pool.ts @@ -115,9 +115,9 @@ export class CachingPool implements Pool { return cache.push(browser); } - cancel(): void { + cancel(err?: Error): void { this.log("cancel"); - this.underlyingPool.cancel(); + this.underlyingPool.cancel(err); } } diff --git a/src/testplane.ts b/src/testplane.ts index 7d03b760c..0e6e1413c 100644 --- a/src/testplane.ts +++ b/src/testplane.ts @@ -454,6 +454,8 @@ export class Testplane extends BaseTestplane { message: this._profiler.sanitizeMessage(err?.message ?? "Testplane run was aborted"), }); + signalHandler.emit(MasterEvents.EXIT, err); + if (timeout > 0) { setTimeout(() => { logger.error("Forcing shutdown..."); From c354b5b44630358875b33e9bc7076070673d9107 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Wed, 26 Aug 2026 04:06:04 +0700 Subject: [PATCH 2/5] fix: review --- src/browser/commands/assert-view/errors/no-ref-image-error.ts | 1 + src/runner/test-runner/regular-test-runner.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/browser/commands/assert-view/errors/no-ref-image-error.ts b/src/browser/commands/assert-view/errors/no-ref-image-error.ts index 1da3f7da6..4c942a3e7 100644 --- a/src/browser/commands/assert-view/errors/no-ref-image-error.ts +++ b/src/browser/commands/assert-view/errors/no-ref-image-error.ts @@ -30,5 +30,6 @@ export class NoRefImageError extends BaseStateError { super(stateName, currImg, refImg); this.message = `can not find reference image at ${this.refImg.path} for "${stateName}" state`; + this.stack = undefined; } } diff --git a/src/runner/test-runner/regular-test-runner.js b/src/runner/test-runner/regular-test-runner.js index c2305ee20..8d0143020 100644 --- a/src/runner/test-runner/regular-test-runner.js +++ b/src/runner/test-runner/regular-test-runner.js @@ -4,6 +4,7 @@ const crypto = require("crypto"); const _ = require("lodash"); const { RunnableEmitter } = require("../types"); const logger = require("../../utils/logger"); +const { formatError } = require("../../utils/format-error"); const { MasterEvents } = require("../../events"); const AssertViewResults = require("../../browser/commands/assert-view/assert-view-results"); const RuntimeConfig = require("../../config/runtime-config"); @@ -85,7 +86,7 @@ module.exports = class RegularTestRunner extends RunnableEmitter { this._emit(MasterEvents.TEST_PASS); } catch (error) { - this._test.err = this._browser?.exitError || error; + this._test.err = formatError(this._browser?.exitError || error); this._applyTestResults(this._test.err); From 412a5b95645a68a8633accfdc022b8fcffb4df0c Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 11 Sep 2026 06:53:58 +0700 Subject: [PATCH 3/5] fix: review 2 --- src/browser/commands/assert-view/errors/no-ref-image-error.ts | 1 - src/runner/test-runner/regular-test-runner.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/browser/commands/assert-view/errors/no-ref-image-error.ts b/src/browser/commands/assert-view/errors/no-ref-image-error.ts index 4c942a3e7..1da3f7da6 100644 --- a/src/browser/commands/assert-view/errors/no-ref-image-error.ts +++ b/src/browser/commands/assert-view/errors/no-ref-image-error.ts @@ -30,6 +30,5 @@ export class NoRefImageError extends BaseStateError { super(stateName, currImg, refImg); this.message = `can not find reference image at ${this.refImg.path} for "${stateName}" state`; - this.stack = undefined; } } diff --git a/src/runner/test-runner/regular-test-runner.js b/src/runner/test-runner/regular-test-runner.js index 8d0143020..c2305ee20 100644 --- a/src/runner/test-runner/regular-test-runner.js +++ b/src/runner/test-runner/regular-test-runner.js @@ -4,7 +4,6 @@ const crypto = require("crypto"); const _ = require("lodash"); const { RunnableEmitter } = require("../types"); const logger = require("../../utils/logger"); -const { formatError } = require("../../utils/format-error"); const { MasterEvents } = require("../../events"); const AssertViewResults = require("../../browser/commands/assert-view/assert-view-results"); const RuntimeConfig = require("../../config/runtime-config"); @@ -86,7 +85,7 @@ module.exports = class RegularTestRunner extends RunnableEmitter { this._emit(MasterEvents.TEST_PASS); } catch (error) { - this._test.err = formatError(this._browser?.exitError || error); + this._test.err = this._browser?.exitError || error; this._applyTestResults(this._test.err); From 7693581b608d6e053e0f9ee98b946aa7732abca6 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 11 Sep 2026 09:47:43 +0700 Subject: [PATCH 4/5] fix: review 3 --- src/browser/new-browser.ts | 25 +++++++++++++++++-- .../test-runner/insistant-test-runner.js | 6 ++++- src/runner/test-runner/regular-test-runner.js | 11 +++++++- src/testplane.ts | 12 ++++++--- test/src/browser/new-browser.ts | 19 ++++++++++++++ .../runner/test-runner/regular-test-runner.js | 14 +++++++++++ test/src/testplane.js | 17 ++++++++++--- 7 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/browser/new-browser.ts b/src/browser/new-browser.ts index c9c593d3c..a7d48600d 100644 --- a/src/browser/new-browser.ts +++ b/src/browser/new-browser.ts @@ -57,6 +57,8 @@ const headlessBrowserOptions: HeadlessBrowserOptions = { export class NewBrowser extends Browser { private _onExit: (err?: Error) => Promise = async () => {}; + private _quitPromise: Promise | null = null; + private _killPromise: Promise | null = null; constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -79,10 +81,19 @@ export class NewBrowser extends Browser { return Promise.resolve(); } - async quit(err?: Error): Promise { + quit(err?: Error): Promise { + if (this._quitPromise) { + return this._quitPromise; + } + this._exitError = err; signalHandler.off("exit", this._onExit); + this._quitPromise = this._quit(); + return this._quitPromise; + } + + private async _quit(): Promise { try { this.setHttpTimeout(this._config.sessionQuitTimeout); await this._session!.deleteSession(); @@ -95,7 +106,17 @@ export class NewBrowser extends Browser { } } - async kill(): Promise { + kill(): Promise { + if (this._killPromise) { + return this._killPromise; + } + + this._killPromise = this._kill(); + + return this._killPromise; + } + + private async _kill(): Promise { try { await this._session!.deleteSession(); this._wdProcess?.kill(); diff --git a/src/runner/test-runner/insistant-test-runner.js b/src/runner/test-runner/insistant-test-runner.js index 2a679656d..276b8e2de 100644 --- a/src/runner/test-runner/insistant-test-runner.js +++ b/src/runner/test-runner/insistant-test-runner.js @@ -21,6 +21,7 @@ module.exports = class InsistantTestRunner extends RunnableEmitter { this._retriesPerformed = 0; this._cancelled = false; + this._activeRunner = null; } async run(workers) { @@ -40,10 +41,12 @@ module.exports = class InsistantTestRunner extends RunnableEmitter { } }, ); + this._activeRunner = runner; passthroughEvent(runner, this, [MasterEvents.TEST_BEGIN, MasterEvents.TEST_PASS, MasterEvents.TEST_END]); await runner.run(workers, this._retriesPerformed); + this._activeRunner = null; if (retry) { ++this._retriesPerformed; @@ -78,7 +81,8 @@ module.exports = class InsistantTestRunner extends RunnableEmitter { return this._browserConfig.retry - this._retriesPerformed; } - cancel() { + cancel(error) { this._cancelled = true; + this._activeRunner?.cancel(error); } }; diff --git a/src/runner/test-runner/regular-test-runner.js b/src/runner/test-runner/regular-test-runner.js index c2305ee20..fdc8451bb 100644 --- a/src/runner/test-runner/regular-test-runner.js +++ b/src/runner/test-runner/regular-test-runner.js @@ -17,6 +17,7 @@ module.exports = class RegularTestRunner extends RunnableEmitter { this._test = test.clone(); this._browserAgent = browserAgent; this._browser = null; + this._cancelError = null; this._profiler = profiler || noopProfilerRuntime; this._profilerSanitizer = this._profiler.isEnabled(2) ? new ProfilerSanitizer() : null; } @@ -85,7 +86,7 @@ module.exports = class RegularTestRunner extends RunnableEmitter { this._emit(MasterEvents.TEST_PASS); } catch (error) { - this._test.err = this._browser?.exitError || error; + this._test.err = this._cancelError || this._browser?.exitError || error; this._applyTestResults(this._test.err); @@ -106,6 +107,10 @@ module.exports = class RegularTestRunner extends RunnableEmitter { } async _runTest(workers, attempt, attemptId, profileSessionId) { + if (this._cancelError) { + throw this._cancelError; + } + if (!this._browser) { throw this._test.err; } @@ -182,6 +187,10 @@ module.exports = class RegularTestRunner extends RunnableEmitter { .slice(0, 12)}`; } + cancel(error) { + this._cancelError = error; + } + async _getBrowser() { try { const state = { diff --git a/src/testplane.ts b/src/testplane.ts index 0e6e1413c..aa3409e23 100644 --- a/src/testplane.ts +++ b/src/testplane.ts @@ -454,7 +454,11 @@ export class Testplane extends BaseTestplane { message: this._profiler.sanitizeMessage(err?.message ?? "Testplane run was aborted"), }); - signalHandler.emit(MasterEvents.EXIT, err); + const cancelRunner = (): void => { + signalHandler.emit(MasterEvents.EXIT, err); + this.runner?.cancel(err); + }; + const hasRunningTests = this.testsTracker?.getAllTests().some(test => test.isRunning); if (timeout > 0) { setTimeout(() => { @@ -468,8 +472,10 @@ export class Testplane extends BaseTestplane { this.viteServer.close(); } - if (this.runner) { - this.runner.cancel(err); + if (timeout > 0 || hasRunningTests) { + cancelRunner(); + } else { + this.once(MasterEvents.TEST_BEGIN, cancelRunner); } } } diff --git a/test/src/browser/new-browser.ts b/test/src/browser/new-browser.ts index b576894c4..9502e8f49 100644 --- a/test/src/browser/new-browser.ts +++ b/test/src/browser/new-browser.ts @@ -493,6 +493,16 @@ describe("NewBrowser", () => { assert.called(session.deleteSession); }); + it("should finalize webdriver.io session only once", async () => { + const browser = await mkBrowser_().init(); + const error = new Error("Tests were stopped by the user"); + + await Promise.all([browser.quit(error), browser.quit(error)]); + + assert.calledOnce(session.deleteSession); + assert.strictEqual(browser.exitError, error); + }); + it("should finalize session on global exit event", async () => { await mkBrowser_().init(); @@ -565,6 +575,15 @@ describe("NewBrowser", () => { assert.notCalled(wdProcess.free); assert.calledOnce(wdProcess.kill); }); + + it("should kill webdriver.io session only once", async () => { + const browser = await mkBrowser_().init(); + + await browser.kill(); + await browser.kill(); + + assert.calledOnce(session.deleteSession); + }); }); describe("sessionId", () => { diff --git a/test/src/runner/test-runner/regular-test-runner.js b/test/src/runner/test-runner/regular-test-runner.js index 002638af3..24f0ed9a2 100644 --- a/test/src/runner/test-runner/regular-test-runner.js +++ b/test/src/runner/test-runner/regular-test-runner.js @@ -338,6 +338,20 @@ describe("runner/test-runner/regular-test-runner", () => { }); describe("TEST_FAIL event", () => { + it("should be emitted with cancel error without running test in worker", async () => { + const error = new Error("Tests were stopped by the user"); + const onFail = sinon.stub().named("onFail"); + const runner = mkRunner_() + .on(Events.TEST_BEGIN, () => runner.cancel(error)) + .on(Events.TEST_FAIL, onFail); + const workers = mkWorkers_(); + + await run_({ runner, workers }); + + assert.notCalled(workers.runTest); + assert.calledOnceWith(onFail, sinon.match({ err: error })); + }); + it("should be emitted on test fail with test data", async () => { const test = new Test({}); const onFail = sinon.stub().named("onFail"); diff --git a/test/src/testplane.js b/test/src/testplane.js index 6531b332b..c3f908d9f 100644 --- a/test/src/testplane.js +++ b/test/src/testplane.js @@ -934,7 +934,14 @@ describe("testplane", () => { testplane = await mkTestplane_(); sandbox.stub(process, "exit"); - sandbox.stub(MainRunner.prototype, "run").callsFake(() => testplane.emitAndWait(RunnerEvents.RUNNER_START)); + sandbox.stub(MainRunner.prototype, "run").callsFake(async function () { + await testplane.emitAndWait(RunnerEvents.RUNNER_START); + this.emit(RunnerEvents.TEST_BEGIN, { + fullTitle: () => "test", + browserId: "bro", + file: "test.js", + }); + }); sandbox.stub(MainRunner.prototype, "cancel"); }); @@ -966,13 +973,15 @@ describe("testplane", () => { assert.notCalled(MainRunner.prototype.cancel); }); - it("should cancel test runner", async () => { + it("should cancel test runner when the first test starts", async () => { + const err = new Error("Tests were stopped by the user"); testplane.on(RunnerEvents.RUNNER_START, () => { - testplane.halt(new Error("test error")); + testplane.halt(err, 0); + assert.notCalled(MainRunner.prototype.cancel); }); return testplane.run().finally(() => { - assert.calledOnce(MainRunner.prototype.cancel); + assert.calledOnceWith(MainRunner.prototype.cancel, err); }); }); From 5f28693e47a4d4a3e1e44312e6773ba1ec981ed2 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 11 Sep 2026 22:46:36 +0700 Subject: [PATCH 5/5] fix: review 4 --- src/browser-pool/basic-pool.ts | 11 ++++++++-- src/browser/new-browser.ts | 10 +++++++++- src/runner/browser-runner.ts | 7 +++++++ src/runner/index.ts | 17 +++++++++++++--- .../test-runner/insistant-test-runner.js | 6 ++++++ src/runner/test-runner/regular-test-runner.js | 19 +++++++++++++++--- src/testplane.ts | 20 +++++++++---------- test/src/browser-pool/basic-pool.js | 10 ++++++++++ test/src/browser/new-browser.ts | 16 ++++++++++++++- test/src/runner/browser-runner.js | 10 ++++++++++ test/src/runner/index.js | 11 +++++----- .../test-runner/insistant-test-runner.js | 11 ++++++++++ .../runner/test-runner/regular-test-runner.js | 18 +++++++++++++++++ test/src/testplane.js | 17 ++++------------ 14 files changed, 145 insertions(+), 38 deletions(-) diff --git a/src/browser-pool/basic-pool.ts b/src/browser-pool/basic-pool.ts index 2cd38d578..69069ac83 100644 --- a/src/browser-pool/basic-pool.ts +++ b/src/browser-pool/basic-pool.ts @@ -14,6 +14,7 @@ export class BasicPool implements Pool { private _emitter: AsyncEmitter; private _activeSessions: Record; private _cancelled: boolean; + private _cancelError: Error | null; private _wdPool: WebdriverPool; private _observer?: PoolObserver; log: debug.Debugger; @@ -29,11 +30,16 @@ export class BasicPool implements Pool { this._activeSessions = {}; this._cancelled = false; + this._cancelError = null; this._wdPool = new WebdriverPool(); this._observer = isPoolObserver(observer) ? observer : undefined; } async getBrowser(id: string, opts: BrowserOpts = {}): Promise { + if (this._cancelled) { + throw this._cancelError ?? new CancelledError(); + } + const operation = this._observer?.start("browser.session.create", { browserId: id }); let browser: NewBrowser | undefined; @@ -51,7 +57,7 @@ export class BasicPool implements Pool { await this._emit(MasterEvents.SESSION_START, browser); if (this._cancelled) { - throw new CancelledError(); + throw this._cancelError ?? new CancelledError(); } await browser.reset(); @@ -113,8 +119,9 @@ export class BasicPool implements Pool { cancel(err?: Error): void { this._cancelled = true; + this._cancelError ??= err ?? new CancelledError(); - _.forEach(this._activeSessions, browser => browser.quit(err)); + _.forEach(this._activeSessions, browser => browser.quit(this._cancelError!)); this._activeSessions = {}; } diff --git a/src/browser/new-browser.ts b/src/browser/new-browser.ts index a7d48600d..3acc10a3d 100644 --- a/src/browser/new-browser.ts +++ b/src/browser/new-browser.ts @@ -57,6 +57,7 @@ const headlessBrowserOptions: HeadlessBrowserOptions = { export class NewBrowser extends Browser { private _onExit: (err?: Error) => Promise = async () => {}; + private _initPromise: Promise | null = null; private _quitPromise: Promise | null = null; private _killPromise: Promise | null = null; @@ -67,7 +68,13 @@ export class NewBrowser extends Browser { signalHandler.on("exit", this._onExit); } - async init(): Promise { + init(): Promise { + this._initPromise ??= this._init(); + + return this._initPromise; + } + + private async _init(): Promise { this._session = await this._createSession(); this._addCommands(); @@ -95,6 +102,7 @@ export class NewBrowser extends Browser { private async _quit(): Promise { try { + await this._initPromise; this.setHttpTimeout(this._config.sessionQuitTimeout); await this._session!.deleteSession(); this._wdProcess?.free(); diff --git a/src/runner/browser-runner.ts b/src/runner/browser-runner.ts index c7d208038..c0b8eed31 100644 --- a/src/runner/browser-runner.ts +++ b/src/runner/browser-runner.ts @@ -26,6 +26,7 @@ export class BrowserRunner extends CancelableEmitter { private workers: Workers; private running: PromiseGroup; private profiler: ProfilerRuntimeLike; + private cancelError: Error | null; constructor( browserId: string, @@ -44,6 +45,7 @@ export class BrowserRunner extends CancelableEmitter { this.workers = workers; this.running = new PromiseGroup(); this.profiler = profiler; + this.cancelError = null; } get browserId(): string { @@ -72,6 +74,10 @@ export class BrowserRunner extends CancelableEmitter { }); const runner = TestRunner.create(test, this.config, browserAgent, this.profiler); + if (this.cancelError && typeof runner.cancel === "function") { + runner.cancel(this.cancelError); + } + runner.on(MasterEvents.TEST_BEGIN, (test: Test) => { this.suiteMonitor.testBegin(test); }); @@ -96,6 +102,7 @@ export class BrowserRunner extends CancelableEmitter { } cancel(error: Error): void { + this.cancelError ??= error; this.activeTestRunners.forEach(runner => runner.cancel(error)); } diff --git a/src/runner/index.ts b/src/runner/index.ts index d7710946d..05415e673 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -58,6 +58,7 @@ export class MainRunner extends RunnableEmitter { protected workersRegistry: WorkersRegistry; protected workers: Workers | null; protected profiler: ProfilerRuntimeLike; + private _cancelError: Error | null; constructor(config: Config, interceptors: Interceptor[], profiler: ProfilerRuntimeLike = noopProfilerRuntime) { super(); @@ -71,6 +72,7 @@ export class MainRunner extends RunnableEmitter { this.running = new PromiseGroup(); this.runned = false; this.cancelled = false; + this._cancelError = null; this.profiler = profiler; this.workersRegistry = WorkersRegistry.create(this.config, this.profiler); @@ -97,6 +99,10 @@ export class MainRunner extends RunnableEmitter { this.workers = this.registerWorkers(require.resolve("../worker"), ["runTest", "cancel"] as const) as Workers; this.browserPool = pool.create(this.config, this, this.profiler); + if (this._cancelError) { + this.browserPool.cancel(this._cancelError); + } + this.once(MasterEvents.EXIT, () => this.workersRegistry.shutdown()); eventsUtils.passthroughEvent(this, this.workersRegistry, MasterEvents.EXIT); @@ -112,7 +118,7 @@ export class MainRunner extends RunnableEmitter { try { await this.emitAndWait(MasterEvents.RUNNER_START, this); this.emit(MasterEvents.BEGIN); - !this.cancelled && (await this._runTests(testCollection, opts)); + await this._runTests(testCollection, opts); } finally { this.emit(MasterEvents.END); await this.emitAndWait(MasterEvents.RUNNER_END, stats.getResult()).catch(logger.warn); @@ -163,6 +169,10 @@ export class MainRunner extends RunnableEmitter { protected _createBrowserRunner(browserId: string): BrowserRunner { const runner = BrowserRunner.create(browserId, this.config, this.browserPool, this.workers, this.profiler); + if (this._cancelError) { + runner.cancel(this._cancelError); + } + eventsUtils.passthroughEvent(runner, this, this.getEventsToPassthrough()); this.interceptEvents(runner, this.getEventsToIntercept()); @@ -214,9 +224,10 @@ export class MainRunner extends RunnableEmitter { cancel(error: Error): void { this.cancelled = true; - this.browserPool?.cancel(error); + this._cancelError ??= error; + this.browserPool?.cancel(this._cancelError); - this.activeBrowserRunners.forEach(runner => runner.cancel(error)); + this.activeBrowserRunners.forEach(runner => runner.cancel(this._cancelError!)); this.workers?.cancel().catch(() => { /* we can just ignore the error thrown, because we don't care about cleanup at this point */ diff --git a/src/runner/test-runner/insistant-test-runner.js b/src/runner/test-runner/insistant-test-runner.js index 276b8e2de..226fd1af6 100644 --- a/src/runner/test-runner/insistant-test-runner.js +++ b/src/runner/test-runner/insistant-test-runner.js @@ -21,6 +21,7 @@ module.exports = class InsistantTestRunner extends RunnableEmitter { this._retriesPerformed = 0; this._cancelled = false; + this._cancelError = null; this._activeRunner = null; } @@ -43,6 +44,10 @@ module.exports = class InsistantTestRunner extends RunnableEmitter { ); this._activeRunner = runner; + if (this._cancelError) { + runner.cancel(this._cancelError); + } + passthroughEvent(runner, this, [MasterEvents.TEST_BEGIN, MasterEvents.TEST_PASS, MasterEvents.TEST_END]); await runner.run(workers, this._retriesPerformed); @@ -83,6 +88,7 @@ module.exports = class InsistantTestRunner extends RunnableEmitter { cancel(error) { this._cancelled = true; + this._cancelError = this._cancelError || error; this._activeRunner?.cancel(error); } }; diff --git a/src/runner/test-runner/regular-test-runner.js b/src/runner/test-runner/regular-test-runner.js index fdc8451bb..a1c42a7ce 100644 --- a/src/runner/test-runner/regular-test-runner.js +++ b/src/runner/test-runner/regular-test-runner.js @@ -18,6 +18,7 @@ module.exports = class RegularTestRunner extends RunnableEmitter { this._browserAgent = browserAgent; this._browser = null; this._cancelError = null; + this._cancelBrowserRequest = null; this._profiler = profiler || noopProfilerRuntime; this._profilerSanitizer = this._profiler.isEnabled(2) ? new ProfilerSanitizer() : null; } @@ -188,17 +189,27 @@ module.exports = class RegularTestRunner extends RunnableEmitter { } cancel(error) { - this._cancelError = error; + this._cancelError = this._cancelError || error; + this._cancelBrowserRequest?.(this._cancelError); } async _getBrowser() { + if (this._cancelError) { + this._test.err = this._cancelError; + return; + } + + const cancelPromise = new Promise((_, reject) => { + this._cancelBrowserRequest = reject; + }); + try { const state = { testXReqId: crypto.randomUUID(), traceparent: this._getTraceparent(), }; - this._browser = await this._browserAgent.getBrowser({ state }); + this._browser = await Promise.race([this._browserAgent.getBrowser({ state }), cancelPromise]); // TODO: move logic to caching pool (in order to use correct state for cached browsers) if ( @@ -212,7 +223,9 @@ module.exports = class RegularTestRunner extends RunnableEmitter { return this._browser; } catch (error) { - this._test.err = error; + this._test.err = this._cancelError || error; + } finally { + this._cancelBrowserRequest = null; } } diff --git a/src/testplane.ts b/src/testplane.ts index aa3409e23..aa16a1226 100644 --- a/src/testplane.ts +++ b/src/testplane.ts @@ -108,6 +108,7 @@ export class Testplane extends BaseTestplane { protected viteServer: ViteServer | null; private _filesToRemove: string[]; + private _haltError: Error | null; protected testsTracker: TestsTracker | null; constructor(config?: string | ConfigInput) { @@ -119,6 +120,7 @@ export class Testplane extends BaseTestplane { this.viteServer = null; this._filesToRemove = []; + this._haltError = null; this.testsTracker = null; @@ -190,6 +192,7 @@ export class Testplane extends BaseTestplane { reporters = [], }: Partial, ): Promise { + this._haltError = null; validateUnknownBrowsers(browsers!, _.keys(this._config.browsers)); RuntimeConfig.getInstance().extend({ @@ -210,6 +213,10 @@ export class Testplane extends BaseTestplane { const runner = RunnerClass.create(this._config, this._interceptors, this._profiler.runtime); this.runner = runner; + if (this._haltError) { + runner.cancel(this._haltError); + } + this.on(MasterEvents.TEST_FAIL, res => { this._fail(); this._addFailedTest(res); @@ -454,11 +461,8 @@ export class Testplane extends BaseTestplane { message: this._profiler.sanitizeMessage(err?.message ?? "Testplane run was aborted"), }); - const cancelRunner = (): void => { - signalHandler.emit(MasterEvents.EXIT, err); - this.runner?.cancel(err); - }; - const hasRunningTests = this.testsTracker?.getAllTests().some(test => test.isRunning); + this._haltError = err; + signalHandler.emit(MasterEvents.EXIT, err); if (timeout > 0) { setTimeout(() => { @@ -472,10 +476,6 @@ export class Testplane extends BaseTestplane { this.viteServer.close(); } - if (timeout > 0 || hasRunningTests) { - cancelRunner(); - } else { - this.once(MasterEvents.TEST_BEGIN, cancelRunner); - } + this.runner?.cancel(err); } } diff --git a/test/src/browser-pool/basic-pool.js b/test/src/browser-pool/basic-pool.js index 3fe5fc427..bc037897b 100644 --- a/test/src/browser-pool/basic-pool.js +++ b/test/src/browser-pool/basic-pool.js @@ -227,6 +227,16 @@ describe("browser-pool/basic-pool", () => { await assert.isRejected(pool.getBrowser(), CancelledError); }); + it("should reject subsequent browser requests with passed cancel error", async () => { + const error = new Error("Tests were stopped by the user"); + const pool = mkPool_(); + + pool.cancel(error); + + await assert.isRejected(pool.getBrowser(), error); + assert.notCalled(NewBrowser.create); + }); + it("should quit browser once if it was launched after cancel", async () => { const browser = stubBrowser(); NewBrowser.create.returns(browser); diff --git a/test/src/browser/new-browser.ts b/test/src/browser/new-browser.ts index 9502e8f49..bd61508b6 100644 --- a/test/src/browser/new-browser.ts +++ b/test/src/browser/new-browser.ts @@ -503,10 +503,24 @@ describe("NewBrowser", () => { assert.strictEqual(browser.exitError, error); }); + it("should wait for session creation before finalizing it", async () => { + let resolveSession: (browserSession: unknown) => void; + webdriverioRemoteStub.returns(new Promise(resolve => (resolveSession = resolve))); + const browser = mkBrowser_(); + const initPromise = browser.init(); + const quitPromise = browser.quit(new Error("Tests were stopped by the user")); + + assert.notCalled(session.deleteSession); + resolveSession!(session); + await Promise.all([initPromise, quitPromise]); + + assert.calledOnce(session.deleteSession); + }); + it("should finalize session on global exit event", async () => { await mkBrowser_().init(); - signalHandler.emitAndWait("exit"); + await signalHandler.emitAndWait("exit"); assert.called(session.deleteSession); }); diff --git a/test/src/runner/browser-runner.js b/test/src/runner/browser-runner.js index f073305ed..52f4fdfd8 100644 --- a/test/src/runner/browser-runner.js +++ b/test/src/runner/browser-runner.js @@ -154,6 +154,16 @@ describe("runner/browser-runner", () => { }); describe("cancel", () => { + it("should cancel a test runner created after browser runner cancellation", async () => { + const error = new Error("Tests were stopped by the user"); + const runner = mkRunner_(); + + runner.cancel(error); + await run_({ runner }); + + assert.calledOnceWith(TestRunner.prototype.cancel, error); + }); + it("should cancel all executing test runners", async () => { stubTestCollection_([Test.create({}), Test.create({})]); diff --git a/test/src/runner/index.js b/test/src/runner/index.js index 08f54a204..5cb933d6b 100644 --- a/test/src/runner/index.js +++ b/test/src/runner/index.js @@ -704,14 +704,15 @@ describe("NodejsEnvRunner", () => { assert.notCalled(BrowserRunner.prototype.cancel); }); - it("shuld not run tests in browser runners if cancelled", async () => { - const runner = new Runner(makeConfigStub()).on(RunnerEvents.RUNNER_START, () => runner.cancel()); + it("should pass cancelled tests to browser runners to emit test results", async () => { + const error = new Error("Tests were stopped by the user"); + const runner = new Runner(makeConfigStub()).on(RunnerEvents.RUNNER_START, () => runner.cancel(error)); await run_({ runner }); - assert.notCalled(BrowserRunner.prototype.addTestToRun); - assert.notCalled(BrowserRunner.prototype.waitTestsCompletion); - assert.notCalled(BrowserRunner.prototype.cancel); + assert.calledOnce(BrowserRunner.prototype.addTestToRun); + assert.calledOnce(BrowserRunner.prototype.waitTestsCompletion); + assert.calledOnceWith(BrowserRunner.prototype.cancel, error); }); it("should cancel all executing workers", async () => { diff --git a/test/src/runner/test-runner/insistant-test-runner.js b/test/src/runner/test-runner/insistant-test-runner.js index d7b58a4fe..6459e8625 100644 --- a/test/src/runner/test-runner/insistant-test-runner.js +++ b/test/src/runner/test-runner/insistant-test-runner.js @@ -66,6 +66,17 @@ describe("runner/test-runner/insistant-test-runner", () => { afterEach(() => sandbox.restore()); describe("run", () => { + it("should pass cancel error to a regular runner created later", async () => { + const error = new Error("Tests were stopped by the user"); + const cancelSpy = sandbox.spy(RegularTestRunner.prototype, "cancel"); + const runner = mkRunner_(); + + runner.cancel(error); + await run_({ runner }); + + assert.calledOnceWith(cancelSpy, error); + }); + it("should run test in regular test runner", async () => { const test = new Test({}); const config = makeConfigStub(); diff --git a/test/src/runner/test-runner/regular-test-runner.js b/test/src/runner/test-runner/regular-test-runner.js index 24f0ed9a2..3544a9e94 100644 --- a/test/src/runner/test-runner/regular-test-runner.js +++ b/test/src/runner/test-runner/regular-test-runner.js @@ -338,6 +338,24 @@ describe("runner/test-runner/regular-test-runner", () => { }); describe("TEST_FAIL event", () => { + it("should stop waiting for browser and emit cancel error", async () => { + const error = new Error("Tests were stopped by the user"); + const onFail = sinon.stub().named("onFail"); + let resolveBrowser; + BrowserAgent.prototype.getBrowser.returns(new Promise(resolve => (resolveBrowser = resolve))); + const runner = mkRunner_().on(Events.TEST_FAIL, onFail); + const workers = mkWorkers_(); + const runPromise = run_({ runner, workers }); + + await Promise.resolve(); + runner.cancel(error); + await runPromise; + resolveBrowser(stubBrowser_()); + + assert.notCalled(workers.runTest); + assert.calledOnceWith(onFail, sinon.match({ err: error })); + }); + it("should be emitted with cancel error without running test in worker", async () => { const error = new Error("Tests were stopped by the user"); const onFail = sinon.stub().named("onFail"); diff --git a/test/src/testplane.js b/test/src/testplane.js index c3f908d9f..ab7762406 100644 --- a/test/src/testplane.js +++ b/test/src/testplane.js @@ -934,14 +934,7 @@ describe("testplane", () => { testplane = await mkTestplane_(); sandbox.stub(process, "exit"); - sandbox.stub(MainRunner.prototype, "run").callsFake(async function () { - await testplane.emitAndWait(RunnerEvents.RUNNER_START); - this.emit(RunnerEvents.TEST_BEGIN, { - fullTitle: () => "test", - browserId: "bro", - file: "test.js", - }); - }); + sandbox.stub(MainRunner.prototype, "run").callsFake(() => testplane.emitAndWait(RunnerEvents.RUNNER_START)); sandbox.stub(MainRunner.prototype, "cancel"); }); @@ -973,16 +966,14 @@ describe("testplane", () => { assert.notCalled(MainRunner.prototype.cancel); }); - it("should cancel test runner when the first test starts", async () => { + it("should cancel test runner immediately", async () => { const err = new Error("Tests were stopped by the user"); testplane.on(RunnerEvents.RUNNER_START, () => { testplane.halt(err, 0); - assert.notCalled(MainRunner.prototype.cancel); - }); - - return testplane.run().finally(() => { assert.calledOnceWith(MainRunner.prototype.cancel, err); }); + + return testplane.run(); }); it("should mark test run as failed", async () => {