diff --git a/.github/workflows/actions/set-screen-resolution/action.yml b/.github/workflows/actions/set-screen-resolution/action.yml new file mode 100644 index 0000000..facb73f --- /dev/null +++ b/.github/workflows/actions/set-screen-resolution/action.yml @@ -0,0 +1,41 @@ +name: 'vscode-webdriverio Set screen resolution' +description: 'Set screen resolution' +inputs: + width: + description: 'screen width' + default: '1920' + height: + description: 'screen height' + default: '1080' +runs: + using: 'composite' + steps: + # https://github.com/actions/runner-images/issues/2935 + - name: Set display resolution on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-DisplayResolution -Width ${{ inputs.width }} -Height ${{ inputs.height }} -Force + + # I don't know the details, but it appears that it needs to be maximized at the Webdriver level + # because it does not launch in full screen on Linux. + # However, as the following Issue states, Electron(base of vscode) can not be muximize by webdriver protocol. + # https://github.com/electron/electron/issues/33942 + # Therefore, GUI-based methods are used to maximize the screen. + # The process of maximizing screen size using xdotool is done in the before hook of wdio.conf.ts. + - name: Set display resolution on Linux + if: runner.os == 'Linux' + shell: bash + run: | + pnpm --filter @vscode-wdio/xvfb-patch run patch -w ${{ inputs.width }} -h ${{ inputs.height }} + echo "::group::apt install -y xdotool" + sudo apt install -y xdotool + echo "::endgroup::" + + # already use FHD resolution + - name: Set display resolution on MacOS + if: runner.os == 'macOS' + shell: bash + run: | + brew install displayplacer + displayplacer list diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 2b7e14a..4ee481e 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -15,13 +15,14 @@ env: VSCODE_WDIO_E2E_COMPATIBILITY_MODE: ${{ inputs.compatibility-mode }} jobs: - unit: - name: E2E Tests (${{ matrix.os }}.${{ matrix.node-version }}) + e2e: + name: E2E Tests - ${{ matrix.scenario }} (${{ matrix.os }}.${{ matrix.node-version }}) strategy: fail-fast: false matrix: node-version: ['20'] os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] + scenario: ['basic', 'workspace'] runs-on: ${{ matrix.os }} steps: - name: ๐Ÿ‘ท Checkout @@ -46,16 +47,21 @@ jobs: with: path: e2e/.wdio-vscode-service + - name: ๐Ÿ–ฅ๏ธ Set screen resolution + uses: ./.github/workflows/actions/set-screen-resolution + - name: ๐Ÿงช Run the e2e test - run: pnpm run test:e2e + env: + E2E_SCENARIO: ${{ matrix.scenario }} + run: pnpm --filter @vscode-wdio/e2e run test:e2e:${E2E_SCENARIO} shell: bash - name: ๐Ÿ“ฆ Upload Test Logs on Failure uses: ./.github/workflows/actions/upload-archive if: failure() with: - name: ${{ inputs.compatibility-mode == 'yes' && 'compatibility' || 'e2e' }}-logs-${{ matrix.os }} - output: ${{ inputs.compatibility-mode == 'yes' && 'compatibility' || 'e2e' }}-logs-${{ matrix.os }}.zip + name: ${{ inputs.compatibility-mode == 'yes' && 'compatibility' || 'e2e' }}-${{ matrix.scenario }}-logs-${{ matrix.os }} + output: ${{ inputs.compatibility-mode == 'yes' && 'compatibility' || 'e2e' }}-${{ matrix.scenario }}-logs-${{ matrix.os }}.zip paths: e2e/logs - name: ๐Ÿ› Debug Build diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index 6624ade..97bf024 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -1,4 +1,4 @@ -name: Lint +name: Static code analysis on: workflow_call: @@ -11,12 +11,7 @@ env: jobs: lint: name: Lint - strategy: - fail-fast: false - matrix: - node-version: ['20'] - os: ['ubuntu-latest'] - runs-on: ${{ matrix.os }} + runs-on: 'ubuntu-latest' steps: - name: ๐Ÿ‘ท Checkout uses: actions/checkout@v4.2.2 @@ -26,14 +21,7 @@ jobs: - name: ๐Ÿ› ๏ธ Setup workspace uses: ./.github/workflows/actions/setup-workspace with: - node-version: ${{ matrix.node-version }} - - - name: โฌ‡๏ธ Download Build Archive - uses: ./.github/workflows/actions/download-archive - with: - name: vscode-webdriverio - path: . - filename: vscode-webdriverio-build.zip + node-version: '20' - name: ๐Ÿ“ƒ Run the lint run: pnpm run style:fix diff --git a/.github/workflows/ci-smoke.yml b/.github/workflows/ci-smoke.yml index c9bec43..1acc5df 100644 --- a/.github/workflows/ci-smoke.yml +++ b/.github/workflows/ci-smoke.yml @@ -4,12 +4,16 @@ on: workflow_call: # Make this a reusable workflow, no value needed # https://docs.github.com/en/actions/using-workflows/reusing-workflows + inputs: + scenario: + description: 'Smoke scenario' + type: string env: TURBO_TELEMETRY_DISABLED: 1 jobs: - unit: + smoke: name: Smoke Tests (${{ matrix.os }}.${{ matrix.node-version }}) strategy: fail-fast: false @@ -40,16 +44,21 @@ jobs: with: path: e2e/.wdio-vscode-service + - name: ๐Ÿ–ฅ๏ธ Set screen resolution + uses: ./.github/workflows/actions/set-screen-resolution + - name: ๐Ÿš‚ Run the smoke test - run: pnpm run test:smoke + env: + E2E_SCENARIO: ${{ inputs.scenario }} + run: pnpm --filter @vscode-wdio/e2e run test:smoke:${E2E_SCENARIO} shell: bash - name: ๐Ÿ“ฆ Upload Test Logs on Failure uses: ./.github/workflows/actions/upload-archive if: failure() with: - name: smoke-logs-${{ matrix.os }} - output: smoke-logs-${{ matrix.os }}.zip + name: smoke-${{ inputs.scenario }}--logs-${{ matrix.os }} + output: smoke-${{ inputs.scenario }}-logs-${{ matrix.os }}.zip paths: e2e/logs - name: ๐Ÿ› Debug Build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2326474..6fca503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,33 +21,41 @@ jobs: os: 'ubuntu-latest' lint: - name: Lint - needs: [build] + name: Static code analysis uses: ./.github/workflows/ci-lint.yml typecheck: name: Typecheck - needs: [build] + needs: [lint, build] uses: ./.github/workflows/ci-typecheck.yml unit: name: Unit - needs: [build] + needs: [lint, build] uses: ./.github/workflows/ci-unit.yml e2e: name: E2E - needs: [build] + needs: [lint, build] uses: ./.github/workflows/ci-e2e.yml compatibility: name: Compatibility - needs: [build] + needs: [lint, build] uses: ./.github/workflows/ci-e2e.yml with: compatibility-mode: 'yes' - smoke: - name: Smoke - needs: [build, e2e] + smoke-config: + name: Smoke - Update Config + needs: [lint, build, e2e] uses: ./.github/workflows/ci-smoke.yml + with: + scenario: 'config' + + smoke-timeout: + name: Smoke - Worker idle timeout + needs: [lint, build, e2e] + uses: ./.github/workflows/ci-smoke.yml + with: + scenario: 'timeout' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a9c92c..31f3712 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,7 +132,7 @@ This Extension consists of several packages. Eventually, these packages will be
**Service Layer**
@vscode-wdio/config (constants, logger, utils)
-โ””โ”€โ”€ @vscode-wdio/api (constants, logger, utils)
+โ””โ”€โ”€ @vscode-wdio/server (constants, logger, utils)

**Integration Layer**
โ”œโ”€โ”€ @vscode-wdio/worker (constants, utils)
diff --git a/README.md b/README.md index 1fd9206..82e3598 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ This extension contributes the following settings: - `webdriverio.nodeExecutable`: The path to the Node.js executable. If not assigned, WebdriverIO try to resolve the node path from environment valuables of `PATH`. - `webdriverio.configFilePattern`: Glob pattern for WebdriverIO configuration file +- `webdriverio.workerIdleTimeout`: If no processing is performed in the Worker for the set amount of time(defined by seconds), the Worker is terminated. If processing is requested again, it will be started automatically. - `webdriverio.logLevel`: Set the logLevel - `webdriverio.showOutput`: Show WebdriverIO output in the test result when set `true` this option diff --git a/assets/build.png b/assets/build.png index dba9630..e287fdb 100644 Binary files a/assets/build.png and b/assets/build.png differ diff --git a/e2e/assertions/index.ts b/e2e/assertions/index.ts index 3cee567..8d9d398 100644 --- a/e2e/assertions/index.ts +++ b/e2e/assertions/index.ts @@ -1,5 +1,5 @@ import type { MatcherContext } from 'expect' -import type { TreeItem } from 'wdio-vscode-service' +import type { BottomBarPanel, TreeItem, Workbench } from 'wdio-vscode-service' import type { STATUS } from '../helpers/index.ts' export interface ExpectedTreeItem { @@ -92,8 +92,38 @@ try { async toMatchTreeStructure(tree: TreeItem[], expectedStructure: ExpectedTreeItem[]) { return await expectTreeToMatchStructure.call(this as unknown as MatcherContext, tree, expectedStructure) }, + async hasExpectedLog(workbench: Workbench, expectedLog: RegExp | string) { + const bottomBar = workbench.getBottomBar() + + const outputView = await bottomBar.openOutputView() + await outputView.selectChannel('WebdriverIO') + await clickGlobalAction(bottomBar, bottomBar.locators.maximize) + const logs = await outputView.getText() + + const regexp = typeof expectedLog === 'string' ? new RegExp(expectedLog) : expectedLog + + const pass = logs.some((log) => regexp.test(log)) + + await clickGlobalAction(bottomBar, bottomBar.locators.restore) + const message = pass ? 'The log outputs include expected text.' : 'The expected text is not included' + return { pass, message: () => message } + }, }) } } catch (error) { console.warn('Failed to extend expect:', error) } + +async function clickGlobalAction(bottomBar: BottomBarPanel, label: string) { + let action + try { + action = (await bottomBar.elem + .$(bottomBar.locators.globalActions) + .$(`.//a[contains(@aria-label, '${label}') and @role='checkbox']`)) as WebdriverIO.Element + } catch { + // the panel is already maximized + } + if (action) { + await action.click({}) + } +} diff --git a/e2e/assertions/wdio.shim.d.ts b/e2e/assertions/wdio.shim.d.ts index 917ba54..09e1558 100644 --- a/e2e/assertions/wdio.shim.d.ts +++ b/e2e/assertions/wdio.shim.d.ts @@ -4,6 +4,7 @@ declare global { namespace ExpectWebdriverIO { interface Matchers { toMatchTreeStructure(expectedStructure: ExpectedTreeItem[]): R + hasExpectedLog(expectedLog: RegExp | string): R } } } diff --git a/e2e/package.json b/e2e/package.json index 80eb67c..9d74cb3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -10,13 +10,15 @@ } }, "scripts": { - "test:e2e": "run-s test:e2e:*", - "test:e2e:mocha": "cross-env VSCODE_WDIO_E2E_FRAMEWORK=mocha xvfb-maybe pnpm run wdio", - "test:e2e:jasmine": "cross-env VSCODE_WDIO_E2E_FRAMEWORK=jasmine xvfb-maybe pnpm run wdio", - "test:e2e:cucumber": "cross-env VSCODE_WDIO_E2E_FRAMEWORK=cucumber xvfb-maybe pnpm run wdio", - "test:e2e:workspace": "cross-env VSCODE_WDIO_E2E_FRAMEWORK=workspace xvfb-maybe pnpm run wdio", + "test:e2e": "run-s test:e2e:basic test:e2e:workspace", + "test:e2e:basic": "run-s test:e2e:basic:*", + "test:e2e:basic:mocha": "cross-env VSCODE_WDIO_E2E_SCENARIO=mocha xvfb-maybe pnpm run wdio", + "test:e2e:basic:jasmine": "cross-env VSCODE_WDIO_E2E_SCENARIO=jasmine xvfb-maybe pnpm run wdio", + "test:e2e:basic:cucumber": "cross-env VSCODE_WDIO_E2E_SCENARIO=cucumber xvfb-maybe pnpm run wdio", + "test:e2e:workspace": "cross-env VSCODE_WDIO_E2E_SCENARIO=workspace xvfb-maybe pnpm run wdio", "test:smoke": "run-s test:smoke:*", - "test:smoke:update-config": "xvfb-maybe wdio run ./wdioSmoke.conf.ts", + "test:smoke:config": "cross-env VSCODE_WDIO_E2E_SCENARIO=config xvfb-maybe wdio run ./wdioSmoke.conf.ts", + "test:smoke:timeout": "cross-env VSCODE_WDIO_E2E_SCENARIO=timeout xvfb-maybe wdio run ./wdioSmoke.conf.ts", "wdio": "wdio run ./wdio.conf.ts" }, "devDependencies": { diff --git a/e2e/tests/basic.spec.ts b/e2e/tests/basic.spec.ts index e579cab..ab958fc 100644 --- a/e2e/tests/basic.spec.ts +++ b/e2e/tests/basic.spec.ts @@ -15,7 +15,7 @@ import { import type { SideBarView, ViewControl, Workbench } from 'wdio-vscode-service' -const targetFramework = (process.env.VSCODE_WDIO_E2E_FRAMEWORK || 'mocha') as 'mocha' | 'jasmine' +const targetFramework = (process.env.VSCODE_WDIO_E2E_SCENARIO || 'mocha') as 'mocha' | 'jasmine' const expected = createExpected(targetFramework) diff --git a/e2e/tests/basicCucumber.spec.ts b/e2e/tests/basicCucumber.spec.ts index 4b95c8d..60e5227 100644 --- a/e2e/tests/basicCucumber.spec.ts +++ b/e2e/tests/basicCucumber.spec.ts @@ -15,7 +15,7 @@ import { import type { SideBarView, ViewControl, Workbench } from 'wdio-vscode-service' -const targetFramework = process.env.VSCODE_WDIO_E2E_FRAMEWORK || 'mocha' +const targetFramework = process.env.VSCODE_WDIO_E2E_SCENARIO || 'mocha' const expected = createCucumberExpected() diff --git a/e2e/tests/workerIdleTimeout.spec.ts b/e2e/tests/workerIdleTimeout.spec.ts new file mode 100644 index 0000000..267a10f --- /dev/null +++ b/e2e/tests/workerIdleTimeout.spec.ts @@ -0,0 +1,83 @@ +import { browser, expect } from '@wdio/globals' + +import { createCucumberExpected } from '../helpers/cucumber.ts' +import { + STATUS, + clearAllTestResults, + clickTitleActionButton, + collapseAllTests, + getTestingSection, + openTestingView, + waitForResolved, + waitForTestStatus, +} from '../helpers/index.ts' + +import type { SideBarView, ViewControl, Workbench } from 'wdio-vscode-service' + +const targetFramework = process.env.VSCODE_WDIO_E2E_SCENARIO || 'mocha' + +const expected = createCucumberExpected() + +describe(`VS Code Extension Testing with ${targetFramework}`, function () { + this.retries(3) + let workbench: Workbench + let testingViewControl: ViewControl + let sideBarView: SideBarView + + beforeEach(async function () { + workbench = await browser.getWorkbench() + testingViewControl = await openTestingView(workbench) + sideBarView = workbench.getSideBar() + + const testingSection = await getTestingSection(sideBarView.getContent()) + await collapseAllTests(testingSection) + + await browser.waitUntil(async () => (await testingSection.getVisibleItems()).length === 1) + }) + + afterEach(async function () { + await clearAllTestResults(workbench) + }) + + it('should be displayed the testing screen at the sideBar', async function () { + expect(await testingViewControl.getTitle()).toBe('Testing') + expect(await sideBarView.getTitlePart().getTitle()).toBe('TESTING') + }) + + it('should resolve defined tests correctly', async function () { + const testingSection = await getTestingSection(sideBarView.getContent()) + const items = await testingSection.getVisibleItems() + + await waitForResolved(browser, items[0]) + + await expect(items).toMatchTreeStructure(expected.notRun) + }) + + it('should shutdown the work process after idle timeout was reached', async function () { + await new Promise((resolve) => setTimeout(resolve, 2000)) + + await expect(workbench).hasExpectedLog(/Worker#0 process shutdown gracefully/) + + const bottomBar = workbench.getBottomBar() + const outputView = await bottomBar.openOutputView() + await outputView.selectChannel('WebdriverIO') + await outputView.clearText() + }) + + it('should start work process and run test successfully', async function () { + const testingSection = await getTestingSection(sideBarView.getContent()) + const items = await testingSection.getVisibleItems() + + await waitForResolved(browser, items[0]) + + await clickTitleActionButton(sideBarView.getTitlePart(), 'Run Tests') + + await waitForTestStatus(browser, items[0], STATUS.PASSED) + + // assert that start work process + await expect(workbench).hasExpectedLog(/\[#1\] Worker process started successfully/) + + // assert that run test successfully + await expect(items).toMatchTreeStructure(expected.runAll) + }) +}) diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts index f088bd1..95771c0 100644 --- a/e2e/wdio.conf.ts +++ b/e2e/wdio.conf.ts @@ -2,6 +2,7 @@ import * as path from 'node:path' import * as url from 'node:url' import { minVersion } from 'semver' +import shell from 'shelljs' import pkg from '../packages/vscode-webdriverio/package.json' with { type: 'json' } import type { Frameworks } from '@wdio/types' @@ -9,7 +10,7 @@ import type { Frameworks } from '@wdio/types' type TestTargets = 'workspace' | 'mocha' | 'jasmine' | 'cucumber' const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) -const target = (process.env.VSCODE_WDIO_E2E_FRAMEWORK || 'mocha') as TestTargets +const target = (process.env.VSCODE_WDIO_E2E_SCENARIO || 'mocha') as TestTargets const minimumVersion = minVersion(pkg.engines.vscode)?.version || 'stable' @@ -33,7 +34,15 @@ function defineSpecs(target: TestTargets) { const specs = defineSpecs(target) let screenshotCount = 0 -export function createBaseConfig(workspacePath: string): WebdriverIO.Config { +export function createBaseConfig(workspacePath: string, userSettings = {}): WebdriverIO.Config { + const resolvedUserSettings = Object.assign( + {}, + { + 'webdriverio.logLevel': 'trace', + }, + userSettings + ) + return { runner: 'local', tsConfigPath: './tsconfig.json', @@ -47,9 +56,7 @@ export function createBaseConfig(workspacePath: string): WebdriverIO.Config { // points to directory where extension package.json is located extensionPath: path.resolve('../packages/vscode-webdriverio'), // optional VS Code settings - userSettings: { - 'webdriverio.logLevel': 'trace', - }, + userSettings: resolvedUserSettings, workspacePath: path.resolve(workspacePath), }, 'wdio:enforceWebDriverClassic': true, @@ -70,6 +77,14 @@ export function createBaseConfig(workspacePath: string): WebdriverIO.Config { timeout: 6000000, require: ['assertions/index.ts'], }, + before: async function (_capabilities, _specs, _browser) { + if (process.platform === 'linux') { + const result = shell.exec('xdotool search --onlyvisible --name code') + const windowId = result.stdout.trim() + shell.exec(`xdotool windowmove ${windowId} 0 0`) + shell.exec(`xdotool windowsize ${windowId} 100% 100%`) + } + }, afterTest: async function (_test: unknown, _context: unknown, result: Frameworks.TestResult) { if (!result.passed) { await browser.saveScreenshot(path.join(outputDir, `screenshot-${screenshotCount++}.png`)) diff --git a/e2e/wdioSmoke.conf.ts b/e2e/wdioSmoke.conf.ts index c8d0cbf..802ff7a 100644 --- a/e2e/wdioSmoke.conf.ts +++ b/e2e/wdioSmoke.conf.ts @@ -1,14 +1,31 @@ import { createBaseConfig } from './wdio.conf.ts' -const specs = [ - './tests/updateConfig.spec.ts', - './tests/updateSpec.spec.ts', - './tests/updateErrorSpec.spec.ts', - './tests/updateErrorConfig.spec.ts', -] +type TestTargets = 'config' | 'timeout' + +const target = (process.env.VSCODE_WDIO_E2E_SCENARIO || 'config') as TestTargets + +const workspace = target === 'config' ? '../samples/smoke/update-config' : '../samples/e2e/cucumber' + +function defineSpecs(target: TestTargets) { + switch (target) { + case 'config': + return [ + './tests/updateConfig.spec.ts', + './tests/updateSpec.spec.ts', + './tests/updateErrorSpec.spec.ts', + './tests/updateErrorConfig.spec.ts', + ] + default: + return ['./tests/workerIdleTimeout.spec.ts'] + } +} + +const specs = defineSpecs(target) + +const settings = target === 'timeout' ? { 'webdriverio.logLevel': 'debug', 'webdriverio.workerIdleTimeout': 2 } : {} export const config: WebdriverIO.Config = { - ...createBaseConfig('../samples/smoke/update-config'), + ...createBaseConfig(workspace, settings), specs, maxInstances: 1, } diff --git a/infra/xvfb-patch/package.json b/infra/xvfb-patch/package.json new file mode 100644 index 0000000..541e5d7 --- /dev/null +++ b/infra/xvfb-patch/package.json @@ -0,0 +1,8 @@ +{ + "name": "@vscode-wdio/xvfb-patch", + "private": true, + "type": "module", + "scripts": { + "patch": "tsx ./src/index.ts" + } +} diff --git a/infra/xvfb-patch/src/index.ts b/infra/xvfb-patch/src/index.ts new file mode 100644 index 0000000..d9cb5ba --- /dev/null +++ b/infra/xvfb-patch/src/index.ts @@ -0,0 +1,48 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '../../..') +const filePath = path.join(rootDir, 'node_modules', 'xvfb-maybe', 'src', 'xvfb-maybe.js') + +const args = process.argv.slice(2) +const optionsDef = { + width: { + type: 'string', + short: 'w', + }, + height: { + type: 'string', + short: 'h', + }, +} as const + +const { values: options } = parseArgs({ args, options: optionsDef }) + +console.log('Adjust screen resolution') +console.log(` Width : ${options.width}`) +console.log(` Height: ${options.height}`) + +const insertBefore = "const dblDashPos = args.indexOf('--')," +const codeToInsert = ` args.unshift('--server-args=-screen 0 ${options.width}x${options.height}x24', '--');` + +const sourceCode = fs.readFileSync(filePath, 'utf-8') + +if (sourceCode.includes(codeToInsert)) { + console.log('๐Ÿ”ง xvfb-maybe is already patched') + process.exit(0) +} + +const lines = sourceCode.split('\n') +const index = lines.findIndex((line) => line.includes(insertBefore)) + +if (index !== -1) { + lines.splice(index, 0, codeToInsert) + const newCode = lines.join('\n') + fs.writeFileSync(filePath, newCode, 'utf-8') + console.log('\nโœ… xvfb-maybe is patched successfully\n\n') +} else { + console.log('\n๐Ÿ’ฅ could not find the target line.\n\n') +} diff --git a/infra/xvfb-patch/tsconfig.json b/infra/xvfb-patch/tsconfig.json new file mode 100644 index 0000000..ea50023 --- /dev/null +++ b/infra/xvfb-patch/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "resolveJsonModule": true + } +} diff --git a/packages/vscode-wdio-config/src/index.ts b/packages/vscode-wdio-config/src/index.ts index 5246eef..2381c9c 100644 --- a/packages/vscode-wdio-config/src/index.ts +++ b/packages/vscode-wdio-config/src/index.ts @@ -6,14 +6,9 @@ import { convertUriToPath, normalizePath } from '@vscode-wdio/utils' import * as vscode from 'vscode' import { findWdioConfig } from './find.js' -import type { - WebdriverIOConfig, - ConfigPropertyNames, - WorkspaceData, - ExtensionConfigManagerInterface, -} from '@vscode-wdio/types' - -export class ExtensionConfigManager extends EventEmitter implements ExtensionConfigManagerInterface { +import type { WebdriverIOConfig, ConfigPropertyNames, WorkspaceData, IExtensionConfigManager } from '@vscode-wdio/types' + +export class ExtensionConfigManager extends EventEmitter implements IExtensionConfigManager { private _isInitialized = false private _isMultiWorkspace = false private _globalConfig: WebdriverIOConfig @@ -33,6 +28,7 @@ export class ExtensionConfigManager extends EventEmitter implements ExtensionCon configFilePattern && configFilePattern.length > 0 ? configFilePattern : [...DEFAULT_CONFIG_VALUES.configFilePattern], + workerIdleTimeout: config.get('workerIdleTimeout', DEFAULT_CONFIG_VALUES.workerIdleTimeout), showOutput: this.resolveBooleanConfig(config, 'showOutput', DEFAULT_CONFIG_VALUES.showOutput), logLevel: config.get('logLevel', DEFAULT_CONFIG_VALUES.logLevel), } diff --git a/packages/vscode-wdio-config/src/watcher.ts b/packages/vscode-wdio-config/src/watcher.ts index 5bcac02..44f9b6f 100644 --- a/packages/vscode-wdio-config/src/watcher.ts +++ b/packages/vscode-wdio-config/src/watcher.ts @@ -1,14 +1,14 @@ import { convertUriToPath, normalizePath, FileWatcherManager, type WatchPattern } from '@vscode-wdio/utils' -import type { ServerManagerInterface } from '@vscode-wdio/types/api' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' -import type { RepositoryManagerInterface } from '@vscode-wdio/types/test' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' +import type { IWorkerManager } from '@vscode-wdio/types/server' +import type { IRepositoryManager } from '@vscode-wdio/types/test' import type * as vscode from 'vscode' export class ConfigFileWatcher extends FileWatcherManager { constructor( - public readonly configManager: ExtensionConfigManagerInterface, - private readonly serverManager: ServerManagerInterface, - private readonly repositoryManager: RepositoryManagerInterface, + public readonly configManager: IExtensionConfigManager, + private readonly workerManager: IWorkerManager, + private readonly repositoryManager: IRepositoryManager, private readonly testfileWatcher: FileWatcherManager ) { super() @@ -53,7 +53,7 @@ export class ConfigFileWatcher extends FileWatcherManager { const workspaceUris = this.configManager.removeWdioConfig(wdioConfigPath) for (const workspaceUri of workspaceUris) { this.repositoryManager.removeWdioConfig(workspaceUri, wdioConfigPath) - await this.serverManager.reorganize(this.configManager.getWdioConfigPaths()) + await this.workerManager.reorganize(this.configManager.getWdioConfigPaths()) } this.testfileWatcher.refreshWatchers() } diff --git a/packages/vscode-wdio-config/tests/index.test.ts b/packages/vscode-wdio-config/tests/index.test.ts index b16f6e3..a2effef 100644 --- a/packages/vscode-wdio-config/tests/index.test.ts +++ b/packages/vscode-wdio-config/tests/index.test.ts @@ -87,8 +87,10 @@ describe('ExtensionConfigManager', () => { // Verify expect(instance.globalConfig).toEqual({ configFilePattern: customConfigFilePattern, + nodeExecutable: undefined, showOutput: customShowOutput, logLevel: customLogLevel, + workerIdleTimeout: 600, }) }) diff --git a/packages/vscode-wdio-config/tests/watcher.test.ts b/packages/vscode-wdio-config/tests/watcher.test.ts index c0da93d..d890914 100644 --- a/packages/vscode-wdio-config/tests/watcher.test.ts +++ b/packages/vscode-wdio-config/tests/watcher.test.ts @@ -5,8 +5,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import * as vscode from 'vscode' import { ConfigFileWatcher } from '../src/watcher.js' -import type { ServerManagerInterface } from '@vscode-wdio/types/api' -import type { RepositoryManagerInterface } from '@vscode-wdio/types/test' +import type { IWorkerManager } from '@vscode-wdio/types/server' +import type { IRepositoryManager } from '@vscode-wdio/types/test' import type { ExtensionConfigManager } from '../src/index.js' // Mock dependencies @@ -53,8 +53,8 @@ class MockTestFileWatcher extends FileWatcherManager { describe('ConfigFileWatcher', () => { let watcher: ConfigFileWatcher let mockConfigManager: ExtensionConfigManager - let mockServerManager: ServerManagerInterface - let mockRepositoryManager: RepositoryManagerInterface + let mockServerManager: IWorkerManager + let mockRepositoryManager: IRepositoryManager let mockRepo1: any let mockRepo2: any let mockUri: vscode.Uri @@ -81,12 +81,12 @@ describe('ConfigFileWatcher', () => { repos: [mockRepo1, mockRepo2], addWdioConfig: vi.fn(), removeWdioConfig: vi.fn(), - } as unknown as RepositoryManagerInterface + } as unknown as IRepositoryManager // Create mock server manager mockServerManager = { reorganize: vi.fn().mockResolvedValue(undefined), - } as unknown as ServerManagerInterface + } as unknown as IWorkerManager // Create mock config manager mockConfigManager = { diff --git a/packages/vscode-wdio-constants/src/index.ts b/packages/vscode-wdio-constants/src/index.ts index 662eadc..70bff5d 100644 --- a/packages/vscode-wdio-constants/src/index.ts +++ b/packages/vscode-wdio-constants/src/index.ts @@ -1,7 +1,9 @@ /* c8 ignore start */ + export interface WebdriverIOConfig { nodeExecutable: string | undefined configFilePattern: string[] + workerIdleTimeout: number showOutput: boolean logLevel: string } @@ -11,6 +13,7 @@ export const EXTENSION_ID = 'webdriverio' export const DEFAULT_CONFIG_VALUES: WebdriverIOConfig = { nodeExecutable: undefined, configFilePattern: ['**/*wdio*.conf*.{ts,js,mjs,cjs,cts,mts}'], + workerIdleTimeout: 600, showOutput: true, logLevel: 'info', } as const diff --git a/packages/vscode-wdio-logger/src/logger.ts b/packages/vscode-wdio-logger/src/logger.ts index a6f86b8..68852ff 100644 --- a/packages/vscode-wdio-logger/src/logger.ts +++ b/packages/vscode-wdio-logger/src/logger.ts @@ -2,7 +2,7 @@ import { EXTENSION_ID, LOG_LEVEL } from '@vscode-wdio/constants' import * as vscode from 'vscode' import { FileLogger } from './fileLogger.js' -import type { LoggerInterface, WdioLogLevel } from '@vscode-wdio/types' +import type { ILogger, WdioLogLevel } from '@vscode-wdio/types' export const LOG_LEVEL_NAMES: Record = { [LOG_LEVEL.TRACE]: 'TRACE', @@ -13,7 +13,7 @@ export const LOG_LEVEL_NAMES: Record = { [LOG_LEVEL.SILENT]: 'SILENT ', } as const -export class VscodeWdioLogger implements LoggerInterface, vscode.Disposable { +export class VscodeWdioLogger implements ILogger, vscode.Disposable { private _timezoneString: string | undefined private _disposables: vscode.Disposable[] = [] private _logLevel: LOG_LEVEL diff --git a/packages/vscode-wdio-api/package.json b/packages/vscode-wdio-server/package.json similarity index 95% rename from packages/vscode-wdio-api/package.json rename to packages/vscode-wdio-server/package.json index f952d9a..849c28d 100644 --- a/packages/vscode-wdio-api/package.json +++ b/packages/vscode-wdio-server/package.json @@ -1,5 +1,5 @@ { - "name": "@vscode-wdio/api", + "name": "@vscode-wdio/server", "version": "0.3.2", "private": true, "type": "module", diff --git a/packages/vscode-wdio-api/src/debug.ts b/packages/vscode-wdio-server/src/debug.ts similarity index 97% rename from packages/vscode-wdio-api/src/debug.ts rename to packages/vscode-wdio-server/src/debug.ts index 0c50b7c..8dbc2ca 100644 --- a/packages/vscode-wdio-api/src/debug.ts +++ b/packages/vscode-wdio-server/src/debug.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode' import { TestRunner } from './run.js' import { WdioExtensionWorker } from './worker.js' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' import type { TestItemMetadata } from '@vscode-wdio/types/test' let debuggerId = 0 @@ -23,7 +23,7 @@ export class DebugRunner extends TestRunner { private _runController: AbortController | null = null constructor( - configManager: ExtensionConfigManagerInterface, + configManager: IExtensionConfigManager, workspaceFolder: vscode.WorkspaceFolder | undefined, token: vscode.CancellationToken, workerCwd: string, @@ -80,7 +80,7 @@ export class WdioExtensionDebugWorker extends WdioExtensionWorker { private _debugTerminationCallback: (() => void) | null = null constructor( - configManager: ExtensionConfigManagerInterface, + configManager: IExtensionConfigManager, cid: string = '#0', cwd: string, private _workspaceFolder: vscode.WorkspaceFolder | undefined, diff --git a/packages/vscode-wdio-server/src/idleMonitor.ts b/packages/vscode-wdio-server/src/idleMonitor.ts new file mode 100644 index 0000000..a11519c --- /dev/null +++ b/packages/vscode-wdio-server/src/idleMonitor.ts @@ -0,0 +1,171 @@ +import EventEmitter from 'node:events' + +import { log } from '@vscode-wdio/logger' + +import type { WorkerIdleMonitorOptions, IWorkerIdleMonitor } from '@vscode-wdio/types/server' + +/** + * Monitor worker idle state and emit timeout events + */ +export class WorkerIdleMonitor extends EventEmitter implements IWorkerIdleMonitor { + private _timer: NodeJS.Timeout | null = null + private _isActive = false + private _pauseCounter = 0 + private _idleTimeout: number + private _isTimeoutDisabled = false + private readonly _workerId: string + + constructor(workerId: string, options: WorkerIdleMonitorOptions) { + super() + this._workerId = workerId + const timeoutSeconds = options.idleTimeout + this._isTimeoutDisabled = timeoutSeconds <= 0 + this._idleTimeout = this._isTimeoutDisabled ? 0 : timeoutSeconds * 1000 // Convert seconds to milliseconds + + if (this._isTimeoutDisabled) { + log.debug(`[${this._workerId}] IdleMonitor created with timeout disabled`) + } else { + log.debug(`[${this._workerId}] IdleMonitor created with timeout: ${this._idleTimeout}ms`) + } + } + + /** + * Start monitoring for idle timeout + */ + public start(): void { + if (this._isActive) { + log.debug(`[${this._workerId}] IdleMonitor already active`) + return + } + + this._isActive = true + this.resetTimer() + log.debug(`[${this._workerId}] IdleMonitor started`) + } + + /** + * Stop monitoring and clear any pending timeout + */ + public stop(): void { + if (!this._isActive) { + return + } + + this._isActive = false + this._pauseCounter = 0 + this.clearTimer() + log.debug(`[${this._workerId}] IdleMonitor stopped`) + } + + /** + * Reset the idle timer (called when worker is accessed) + */ + public resetTimer(): void { + if (!this._isActive || this._pauseCounter > 0 || this._isTimeoutDisabled) { + return + } + + this.clearTimer() + this.startTimer() + log.trace(`[${this._workerId}] IdleMonitor timer reset`) + } + + /** + * Pause the idle timer (called when RPC operation starts) + */ + public pauseTimer(): void { + if (!this._isActive || this._isTimeoutDisabled) { + return + } + + this._pauseCounter++ + this.clearTimer() + log.trace(`[${this._workerId}] IdleMonitor timer paused`) + } + + /** + * Resume the idle timer (called when RPC operation completes) + */ + public resumeTimer(): void { + if (!this._isActive || this._isTimeoutDisabled) { + return + } + + this._pauseCounter = this._pauseCounter - 1 < 0 ? 0 : this._pauseCounter - 1 + if (this._pauseCounter < 1 && !this._timer) { + this.startTimer() + log.trace(`[${this._workerId}] IdleMonitor timer resumed`) + } + } + + /** + * Update the idle timeout configuration + * @param timeout New timeout value in milliseconds + */ + public updateTimeout(timeout: number): void { + const newTimeout = timeout * 1000 + if (newTimeout === this._idleTimeout) { + return + } + + const oldTimeout = this._idleTimeout + const timeoutSeconds = timeout + this._isTimeoutDisabled = timeoutSeconds <= 0 + this._idleTimeout = this._isTimeoutDisabled ? 0 : timeoutSeconds * 1000 + + if (this._isTimeoutDisabled) { + log.debug(`[${this._workerId}] IdleMonitor updated with timeout disabled`) + } else { + log.debug(`[${this._workerId}] IdleMonitor timeout updated: ${oldTimeout}ms -> ${timeout}ms`) + } + + // Restart timer with new timeout if currently active + if (this._isActive) { + this.resetTimer() + } + } + + /** + * Check if monitoring is currently active + */ + public isActive(): boolean { + return this._isActive + } + + /** + * Clear the current timer + */ + private clearTimer(): void { + if (this._timer) { + clearTimeout(this._timer) + this._timer = null + } + } + + /** + * Start a new timer with current timeout value + */ + private startTimer(): void { + if (this._pauseCounter > 0 || this._isTimeoutDisabled) { + return + } + + this._timer = setTimeout(() => { + log.debug(`[${this._workerId}] Worker idle timeout reached after ${this._idleTimeout}ms`) + this.handleIdleTimeout() + }, this._idleTimeout) + } + + /** + * Handle idle timeout event + */ + private handleIdleTimeout(): void { + // Stop monitoring first to prevent multiple timeout events + this.stop() + + log.info(`[${this._workerId}] Worker idle timeout triggered`) + + // Emit idle timeout event + this.emit('idleTimeout') + } +} diff --git a/packages/vscode-wdio-api/src/index.ts b/packages/vscode-wdio-server/src/index.ts similarity index 61% rename from packages/vscode-wdio-api/src/index.ts rename to packages/vscode-wdio-server/src/index.ts index ef1ce9d..6f82d5b 100644 --- a/packages/vscode-wdio-api/src/index.ts +++ b/packages/vscode-wdio-server/src/index.ts @@ -1,3 +1,3 @@ -export { ServerManager } from './manager.js' +export { WdioWorkerManager } from './manager.js' export { TestRunner } from './run.js' export { DebugRunner } from './debug.js' diff --git a/packages/vscode-wdio-api/src/manager.ts b/packages/vscode-wdio-server/src/manager.ts similarity index 64% rename from packages/vscode-wdio-api/src/manager.ts rename to packages/vscode-wdio-server/src/manager.ts index 68eb903..2af26f8 100644 --- a/packages/vscode-wdio-api/src/manager.ts +++ b/packages/vscode-wdio-server/src/manager.ts @@ -4,24 +4,24 @@ import { log } from '@vscode-wdio/logger' import * as vscode from 'vscode' import { WdioExtensionWorker } from './worker.js' -import type { ServerManagerInterface, WdioExtensionWorkerInterface } from '@vscode-wdio/types/api' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' +import type { IWorkerManager, IWdioExtensionWorker } from '@vscode-wdio/types/server' -export class ServerManager implements ServerManagerInterface { - private _serverPool = new Map() - private _pendingOperations = new Map>() +export class WdioWorkerManager implements IWorkerManager { + private _workerPool = new Map() + private _pendingOperations = new Map>() private latestId = 0 // Semaphore to track the overall operation (for complete sequential execution) private _operationLock = false private _operationQueue: (() => Promise)[] = [] - constructor(private readonly configManager: ExtensionConfigManagerInterface) { - configManager.on('update:nodeExecutable', async (nodeExecutable: string) => { + constructor(private readonly configManager: IExtensionConfigManager) { + configManager.on('update:nodeExecutable', async (nodeExecutable: string | undefined) => { log.debug(`Restart worker using webdriverio.nodeExecutable: ${nodeExecutable}`) - const cwds = Array.from(this._serverPool.keys()) + const cwds = Array.from(this._workerPool.keys()) await Promise.all( cwds.map(async (cwd) => { - const worker = this._serverPool.get(cwd) + const worker = this._workerPool.get(cwd) if (worker) { await this.stopWorker(cwd, worker) } @@ -35,6 +35,12 @@ export class ServerManager implements ServerManagerInterface { vscode.window.showErrorMessage(errorMessage) } }) + + // Listen for worker idle timeout configuration changes + configManager.on('update:workerIdleTimeout', async (workerIdleTimeout: number) => { + log.debug(`Update worker idle timeout: ${workerIdleTimeout}s`) + await this.updateWorkersIdleTimeout(workerIdleTimeout) + }) } /** @@ -73,7 +79,7 @@ export class ServerManager implements ServerManagerInterface { const normalizedConfigPath = normalize(configPaths) const wdioDirName = dirname(normalizedConfigPath) log.debug(`[server manager] detecting server: ${wdioDirName}`) - const server = this._serverPool.get(wdioDirName) + const server = this._workerPool.get(wdioDirName) if (server) { return server } @@ -98,7 +104,7 @@ export class ServerManager implements ServerManagerInterface { // Find workers that need to be stopped const stoppingPromises: Promise[] = [] - for (const [cwd, worker] of this._serverPool.entries()) { + for (const [cwd, worker] of this._workerPool.entries()) { if (!newConfigDirs.has(cwd)) { log.debug(`[server manager] stopping unnecessary worker: ${cwd}`) stoppingPromises.push(this.stopWorker(cwd, worker)) @@ -111,10 +117,10 @@ export class ServerManager implements ServerManagerInterface { } // Start new workers - const startingPromises: Promise[] = [] + const startingPromises: Promise[] = [] const workerCwds = Array.from(newConfigDirs) for (const cwd of workerCwds) { - if (!this._serverPool.has(cwd)) { + if (!this._workerPool.has(cwd)) { this.latestId++ startingPromises.push(this.startWorker(this.latestId, cwd)) } @@ -127,6 +133,59 @@ export class ServerManager implements ServerManagerInterface { }) } + /** + * Update idle timeout configuration for all active workers + * @param idleTimeout Idle timeout in milliseconds + */ + private async updateWorkersIdleTimeout(idleTimeout: number): Promise { + const updatePromises: Promise[] = [] + + for (const [cwd, worker] of this._workerPool.entries()) { + if (worker.isConnected()) { + log.debug(`[server manager] updating idle timeout for worker: ${cwd}`) + const updatePromise = this.updateWorkerIdleTimeout(worker, idleTimeout) + updatePromises.push(updatePromise) + } + } + + if (updatePromises.length > 0) { + await Promise.all(updatePromises) + } + } + + /** + * Update idle timeout configuration for a specific worker + * @param worker Worker to update + * @param idleTimeout Idle timeout in milliseconds + */ + private async updateWorkerIdleTimeout(worker: IWdioExtensionWorker, idleTimeout: number): Promise { + try { + worker.idleMonitor.updateTimeout(idleTimeout) + log.debug(`[server manager] successfully updated idle timeout for worker ${worker.cid}`) + } catch (error) { + const errorMessage = `Failed to update idle timeout for worker ${worker.cid}: ${error instanceof Error ? error.message : String(error)}` + log.error(errorMessage) + // Don't throw error to prevent stopping other workers from being updated + } + } + + /** + * Handle worker idle timeout notification + * This method is called when a worker sends an idle timeout notification + * @param workerCwd Worker's current working directory + */ + public async handleWorkerIdleTimeout(workerCwd: string): Promise { + log.debug(`[server manager] received idle timeout notification for worker: ${workerCwd}`) + + const worker = this._workerPool.get(workerCwd) + if (worker) { + await this.stopWorker(workerCwd, worker) + log.debug(`[server manager] worker stopped due to idle timeout: ${workerCwd}`) + } else { + log.warn(`[server manager] received idle timeout for unknown worker: ${workerCwd}`) + } + } + private async queueOperation(operation: () => Promise): Promise { // Execute immediately if no operation is in progress if (!this._operationLock) { @@ -170,9 +229,9 @@ export class ServerManager implements ServerManagerInterface { } } - private async startWorker(id: number, workerCwd: string): Promise { + private async startWorker(id: number, workerCwd: string): Promise { // Return existing server if already created - const existingServer = this._serverPool.get(workerCwd) + const existingServer = this._workerPool.get(workerCwd) if (existingServer) { return existingServer } @@ -180,7 +239,7 @@ export class ServerManager implements ServerManagerInterface { // Return pending operation if one is in progress const pendingOperation = this._pendingOperations.get(`start:${workerCwd}`) if (pendingOperation) { - return pendingOperation as Promise + return pendingOperation as Promise } // Start a new process and track it @@ -198,15 +257,32 @@ export class ServerManager implements ServerManagerInterface { private async createWorker(id: number, configPaths: string): Promise { const strId = `#${String(id)}` - const server = new WdioExtensionWorker(this.configManager, strId, configPaths) - await server.start() - await server.waitForStart() + const worker = new WdioExtensionWorker(this.configManager, strId, configPaths) + + // Set up idle timeout notification handler + worker.on('idleTimeout', () => { + this.handleWorkerIdleTimeout(configPaths) + }) + + await worker.start() + await worker.waitForStart() + + // Send initial idle timeout configuration + const idleTimeout = this.configManager.globalConfig.workerIdleTimeout + if ( + idleTimeout !== undefined && + 'updateIdleTimeout' in worker && + typeof worker.updateIdleTimeout === 'function' + ) { + worker.updateIdleTimeout(idleTimeout) + } + log.debug(`[server manager] server was registered: ${configPaths}`) - this._serverPool.set(configPaths, server) - return server + this._workerPool.set(configPaths, worker) + return worker } - private async stopWorker(configPath: string, worker: WdioExtensionWorkerInterface): Promise { + private async stopWorker(configPath: string, worker: IWdioExtensionWorker): Promise { // Return pending stop operation if one is in progress const pendingOperation = this._pendingOperations.get(`stop:${configPath}`) if (pendingOperation) { @@ -225,16 +301,16 @@ export class ServerManager implements ServerManagerInterface { } } - private async executeStopWorker(configPath: string, worker: WdioExtensionWorkerInterface): Promise { + private async executeStopWorker(configPath: string, worker: IWdioExtensionWorker): Promise { log.trace(`shutdown the worker ${worker.cid} for ${configPath}`) await worker.stop() - this._serverPool.delete(configPath) + this._workerPool.delete(configPath) } public async dispose() { return this.queueOperation(async () => { const stopPromises: Promise[] = [] - for (const [cwd, worker] of this._serverPool.entries()) { + for (const [cwd, worker] of this._workerPool.entries()) { log.trace(`shutdown the worker ${worker.cid} for ${cwd}`) stopPromises.push(this.stopWorker(cwd, worker)) } diff --git a/packages/vscode-wdio-api/src/run.ts b/packages/vscode-wdio-server/src/run.ts similarity index 95% rename from packages/vscode-wdio-api/src/run.ts rename to packages/vscode-wdio-server/src/run.ts index 88f968f..4468d2c 100644 --- a/packages/vscode-wdio-api/src/run.ts +++ b/packages/vscode-wdio-server/src/run.ts @@ -2,7 +2,7 @@ import { log } from '@vscode-wdio/logger' import { getGrep, getRange, getCucumberSpec, getSpec } from './utils.js' -import type { RunTestOptions, WdioExtensionWorkerInterface } from '@vscode-wdio/types/api' +import type { RunTestOptions, IWdioExtensionWorker } from '@vscode-wdio/types/server' import type { TestItemMetadata, TestItemMetadataWithRepository } from '@vscode-wdio/types/test' import type * as vscode from 'vscode' @@ -16,7 +16,9 @@ export class TestRunner implements vscode.Disposable { private _stderr = '' private _listeners: Listeners | undefined - constructor(protected worker: WdioExtensionWorkerInterface) {} + constructor(protected worker: IWdioExtensionWorker) { + worker.idleMonitor.pauseTimer() + } public get stdout() { return this._stdout @@ -153,6 +155,6 @@ export class TestRunner implements vscode.Disposable { } async dispose() { - // noting to do + this.worker.idleMonitor.resumeTimer() } } diff --git a/packages/vscode-wdio-api/src/utils.ts b/packages/vscode-wdio-server/src/utils.ts similarity index 96% rename from packages/vscode-wdio-api/src/utils.ts rename to packages/vscode-wdio-server/src/utils.ts index bb157b6..fc256a7 100644 --- a/packages/vscode-wdio-api/src/utils.ts +++ b/packages/vscode-wdio-server/src/utils.ts @@ -6,7 +6,7 @@ import which from 'which' import { WebSocketServer } from 'ws' import type { Server } from 'node:http' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' import type { TestItemMetadataWithRepository } from '@vscode-wdio/types/test' import type { NumericLogLevel } from '@vscode-wdio/types/utils' import type * as vscode from 'vscode' @@ -95,7 +95,7 @@ export function getCucumberSpec(testItem: vscode.TestItem, metadata: TestItemMet return baseSpec } -export async function resolveNodePath(configManager: ExtensionConfigManagerInterface) { +export async function resolveNodePath(configManager: IExtensionConfigManager) { log.debug('Resolving the Node executable path') const configuredPath = configManager.globalConfig.nodeExecutable if (configuredPath && (await checkExistence(configuredPath))) { diff --git a/packages/vscode-wdio-api/src/worker.ts b/packages/vscode-wdio-server/src/worker.ts similarity index 77% rename from packages/vscode-wdio-api/src/worker.ts rename to packages/vscode-wdio-server/src/worker.ts index 8be7f96..107efa4 100644 --- a/packages/vscode-wdio-api/src/worker.ts +++ b/packages/vscode-wdio-server/src/worker.ts @@ -1,25 +1,34 @@ import { spawn, type ChildProcess } from 'node:child_process' -import EventEmitter from 'node:events' import { createServer as createHttpServer, type Server } from 'node:http' import { resolve } from 'node:path' import * as v8 from 'node:v8' import { log } from '@vscode-wdio/logger' +import { TypedEventEmitter } from '@vscode-wdio/utils' import { createBirpc } from 'birpc' import getPort from 'get-port' +import { WorkerIdleMonitor } from './idleMonitor.js' import { createWss, loggingFn, resolveNodePath } from './utils.js' -import type { ExtensionApi, WdioExtensionWorkerInterface, WorkerApi } from '@vscode-wdio/types/api' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' +import type { + ExtensionApi, + WdioExtensionWorkerEvents, + IWdioExtensionWorker, + WorkerApi, + IWorkerIdleMonitor, +} from '@vscode-wdio/types/server' import type * as vscode from 'vscode' import type { WebSocketServer } from 'ws' const WORKER_PATH = resolve(__dirname, 'worker.cjs') -export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWorkerInterface { - protected configManager: ExtensionConfigManagerInterface + +export class WdioExtensionWorker extends TypedEventEmitter implements IWdioExtensionWorker { + protected configManager: IExtensionConfigManager public cid: string protected cwd: string + public idleMonitor: IWorkerIdleMonitor protected disposables: vscode.Disposable[] = [] private _workerProcess: ChildProcess | null = null private _workerRpc: WorkerApi | null = null @@ -28,12 +37,21 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo private _server: Server | null = null private _wss: WebSocketServer | null = null - constructor(configManager: ExtensionConfigManagerInterface, cid: string = '#0', cwd: string) { + constructor(configManager: IExtensionConfigManager, cid: string = '#0', cwd: string) { super() this.cid = cid this.cwd = cwd this.configManager = configManager + // Initialize idle monitor + const idleTimeout = this.configManager.globalConfig.workerIdleTimeout + this.idleMonitor = new WorkerIdleMonitor(this.cid, { idleTimeout }) + + // Forward idle timeout events + this.idleMonitor.on('idleTimeout', () => { + this.emit('idleTimeout', undefined) + }) + const psListener = () => { if (this._workerProcess && !this._workerProcess.killed) { log.debug('Extension host exiting - ensuring worker process is terminated') @@ -112,10 +130,13 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo // Handle process exit wp.on('exit', (code) => { - log.debug(`Worker process exited with code ${code}`) + log.debug(`Worker${this.cid} process exited with code ${code}`) this._workerProcess = null this._workerRpc = null this._workerConnected = false + + // Stop idle monitoring when worker process exits + this.idleMonitor.stop() }) } @@ -129,7 +150,6 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo /** * Connect to worker via WebSocket */ - public async waitForStart(): Promise { if (!this._workerPort) { throw new Error('Worker port not set') @@ -168,6 +188,9 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo log.debug('Worker connection closed') this._workerConnected = false this._workerRpc = null + + // Stop idle monitoring when connection is closed + this.idleMonitor.stop() } }) @@ -180,14 +203,20 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo resolve() }) }).then(() => { + // Start idle monitoring after successful connection + this.idleMonitor.start() this.startHealthCheck() - log.debug('Worker process started successfully') + log.debug(`[${this.cid}] Worker process started successfully`) }) } + /** * Stop the worker process */ public async stop(): Promise { + // Stop idle monitoring first + this.idleMonitor.stop() + let shutdownSucceeded = false // Set a timeout for graceful shutdown @@ -203,9 +232,11 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo await Promise.race([this._workerRpc.shutdown(), timeoutPromise]) shutdownSucceeded = true - log.debug('Worker process shutdown gracefully') + log.debug(`Worker${this.cid} process shutdown gracefully`) } catch (error) { - log.debug(`Error during worker shutdown: ${error instanceof Error ? error.message : String(error)}`) + log.debug( + `Error during worker${this.cid} shutdown: ${error instanceof Error ? error.message : String(error)}` + ) } } @@ -256,17 +287,38 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo }) this._server = null } - log.debug('Extension worker stopped completely') + this.emit('shutdown', undefined) + log.debug(`Extension worker${this.cid} stopped completely`) } /** * Get worker RPC interface + * This getter resets the idle timer when accessed */ public get rpc(): WorkerApi { if (!this._workerRpc || !this._workerConnected) { throw new Error('Worker not connected') } - return this._workerRpc + + // Reset idle timer when RPC is accessed + this.idleMonitor.resetTimer() + + return new Proxy(this._workerRpc, { + get: (target: WorkerApi, prop: K): any => { + const originalMethod = target[prop] + if (typeof originalMethod === 'function') { + return (async (...args: any[]) => { + this.idleMonitor.pauseTimer() + try { + return await (originalMethod as Function).apply(target, args) + } finally { + this.idleMonitor.resumeTimer() + } + }) as WorkerApi[K] + } + return originalMethod + }, + }) } /** @@ -286,6 +338,14 @@ export class WdioExtensionWorker extends EventEmitter implements WdioExtensionWo } } + /** + * Update idle timeout configuration + * @param timeout Idle timeout in milliseconds + */ + public updateIdleTimeout(timeout: number): void { + this.idleMonitor.updateTimeout(timeout) + } + private startHealthCheck() { const interval = setInterval(async () => { if (!this.isConnected()) { diff --git a/packages/vscode-wdio-api/tests/debug.test.ts b/packages/vscode-wdio-server/tests/debug.test.ts similarity index 97% rename from packages/vscode-wdio-api/tests/debug.test.ts rename to packages/vscode-wdio-server/tests/debug.test.ts index d47d9a5..61f4435 100644 --- a/packages/vscode-wdio-api/tests/debug.test.ts +++ b/packages/vscode-wdio-server/tests/debug.test.ts @@ -7,7 +7,7 @@ import { DebugRunner, DebugSessionTerminatedError, WdioExtensionDebugWorker } fr import * as runModule from '../src/run.js' import * as workerModule from '../src/worker.js' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' // Mock VSCode vi.mock('vscode', async () => { @@ -26,7 +26,11 @@ vi.mock('vscode', async () => { // Mock logger vi.mock('@vscode-wdio/logger', () => import('../../../tests/__mocks__/logger.js')) -const mockConfigManager = {} as unknown as ExtensionConfigManagerInterface +const mockConfigManager = { + globalConfig: { + workerIdleTimeout: 600, + }, +} as unknown as IExtensionConfigManager describe('DebugRunner', () => { let workspaceFolder: vscode.WorkspaceFolder @@ -60,6 +64,10 @@ describe('DebugRunner', () => { setDebugTerminationCallback: vi.fn().mockImplementation((callback: () => void) => { terminationCallback = callback }), + idleMonitor: { + pauseTimer: vi.fn(), + resumeTimer: vi.fn(), + }, } as unknown as WdioExtensionDebugWorker mockWorkerResult = { diff --git a/packages/vscode-wdio-server/tests/idleMonitor.test.ts b/packages/vscode-wdio-server/tests/idleMonitor.test.ts new file mode 100644 index 0000000..8794b8f --- /dev/null +++ b/packages/vscode-wdio-server/tests/idleMonitor.test.ts @@ -0,0 +1,554 @@ +import { log } from '@vscode-wdio/logger' +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest' + +import { WorkerIdleMonitor } from '../src/idleMonitor.js' + +// Mock the logger module +vi.mock('@vscode-wdio/logger', () => ({ + log: { + debug: vi.fn(), + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +describe('WorkerIdleMonitor', () => { + let monitor: WorkerIdleMonitor + let mockLoggerDebug: ReturnType + let mockLoggerTrace: ReturnType + let mockLoggerInfo: ReturnType + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks() + vi.useFakeTimers() + + // Get references to mocked logger functions + mockLoggerDebug = vi.mocked(log.debug) + mockLoggerTrace = vi.mocked(log.trace) + mockLoggerInfo = vi.mocked(log.info) + }) + + afterEach(() => { + // Clean up after each test + if (monitor) { + monitor.stop() + } + vi.useRealTimers() + vi.restoreAllMocks() + }) + + describe('Constructor', () => { + it('should create monitor with valid timeout', () => { + // Arrange & Act + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 300 }) + + // Assert + expect(monitor.isActive()).toBe(false) + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor created with timeout: 300000ms') + }) + + it('should create monitor with disabled timeout when timeout is 0', () => { + // Arrange & Act + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 0 }) + + // Assert + expect(monitor.isActive()).toBe(false) + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor created with timeout disabled') + }) + + it('should create monitor with disabled timeout when timeout is negative', () => { + // Arrange & Act + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: -10 }) + + // Assert + expect(monitor.isActive()).toBe(false) + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor created with timeout disabled') + }) + }) + + describe('start()', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should start monitoring and set active state', () => { + // Arrange & Act + monitor.start() + + // Assert + expect(monitor.isActive()).toBe(true) + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor started') + }) + + it('should not start monitoring if already active', () => { + // Arrange + monitor.start() + vi.clearAllMocks() + + // Act + monitor.start() + + // Assert + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor already active') + }) + + it('should start timer when timeout is enabled', () => { + // Arrange + const timeoutSpy = vi.spyOn(global, 'setTimeout') + + // Act + monitor.start() + + // Assert + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000) + }) + + it('should not start timer when timeout is disabled', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 0 }) + const timeoutSpy = vi.spyOn(global, 'setTimeout') + + // Act + monitor.start() + + // Assert + expect(timeoutSpy).not.toHaveBeenCalled() + }) + }) + + describe('stop()', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should stop monitoring and clear timer', () => { + // Arrange + monitor.start() + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + // Act + monitor.stop() + + // Assert + expect(monitor.isActive()).toBe(false) + expect(clearTimeoutSpy).toHaveBeenCalled() + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor stopped') + }) + + it('should not do anything if already stopped', () => { + // Arrange + vi.clearAllMocks() + + // Act + monitor.stop() + + // Assert + expect(mockLoggerDebug).not.toHaveBeenCalled() + }) + + it('should reset pause counter when stopped', () => { + // Arrange + monitor.start() + monitor.pauseTimer() + monitor.pauseTimer() // Multiple pauses + + // Act + monitor.stop() + + // Assert - Should be able to resume properly after restart + monitor.start() + monitor.pauseTimer() + monitor.resumeTimer() + expect(mockLoggerTrace).toHaveBeenCalledWith('[test-worker] IdleMonitor timer resumed') + }) + }) + + describe('resetTimer()', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should reset timer when active and not paused', () => { + // Arrange + monitor.start() + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.clearAllMocks() + + // Act + monitor.resetTimer() + + // Assert + expect(clearTimeoutSpy).toHaveBeenCalled() + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000) + expect(mockLoggerTrace).toHaveBeenCalledWith('[test-worker] IdleMonitor timer reset') + }) + + it('should not reset timer when not active', () => { + // Arrange + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + + // Act + monitor.resetTimer() + + // Assert + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + + it('should not reset timer when paused', () => { + // Arrange + monitor.start() + monitor.pauseTimer() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.clearAllMocks() + + // Act + monitor.resetTimer() + + // Assert + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + + it('should not reset timer when timeout is disabled', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 0 }) + monitor.start() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + + // Act + monitor.resetTimer() + + // Assert + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + }) + + describe('pauseTimer()', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should pause timer and increment pause counter', () => { + // Arrange + monitor.start() + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + vi.clearAllMocks() + + // Act + monitor.pauseTimer() + + // Assert + expect(clearTimeoutSpy).toHaveBeenCalled() + expect(mockLoggerTrace).toHaveBeenCalledWith('[test-worker] IdleMonitor timer paused') + }) + + it('should handle multiple pauses correctly', () => { + // Arrange + monitor.start() + vi.clearAllMocks() + + // Act + monitor.pauseTimer() + monitor.pauseTimer() + monitor.pauseTimer() + + // Assert + expect(mockLoggerTrace).toHaveBeenCalledTimes(3) + }) + + it('should not pause when not active', () => { + // Arrange + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + // Act + monitor.pauseTimer() + + // Assert + expect(clearTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + + it('should not pause when timeout is disabled', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 0 }) + monitor.start() + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + + // Act + monitor.pauseTimer() + + // Assert + expect(clearTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + }) + + describe('resumeTimer()', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should resume timer after single pause', () => { + // Arrange + monitor.start() + monitor.pauseTimer() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.clearAllMocks() + + // Act + monitor.resumeTimer() + + // Assert + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000) + expect(mockLoggerTrace).toHaveBeenCalledWith('[test-worker] IdleMonitor timer resumed') + }) + + it('should handle multiple pause/resume correctly', () => { + // Arrange + monitor.start() + monitor.pauseTimer() + monitor.pauseTimer() + monitor.pauseTimer() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.clearAllMocks() + + // Act - First two resumes should not start timer + monitor.resumeTimer() + monitor.resumeTimer() + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + + // Third resume should start timer + monitor.resumeTimer() + + // Assert + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000) + expect(mockLoggerTrace).toHaveBeenCalledWith('[test-worker] IdleMonitor timer resumed') + }) + + it('should not resume when not active', () => { + // Arrange + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + + // Act + monitor.resumeTimer() + + // Assert + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + + it('should not resume when timeout is disabled', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 0 }) + monitor.start() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + + // Act + monitor.resumeTimer() + + // Assert + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(mockLoggerTrace).not.toHaveBeenCalled() + }) + }) + + describe('updateTimeout()', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should update timeout to new value', () => { + // Arrange + monitor.start() + vi.clearAllMocks() + + // Act + monitor.updateTimeout(10) + + // Assert + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor timeout updated: 5000ms -> 10ms') + }) + + it('should disable timeout when set to 0', () => { + // Arrange + monitor.start() + vi.clearAllMocks() + + // Act + monitor.updateTimeout(0) + + // Assert + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor updated with timeout disabled') + }) + + it('should disable timeout when set to negative value', () => { + // Arrange + monitor.start() + vi.clearAllMocks() + + // Act + monitor.updateTimeout(-5) + + // Assert + expect(mockLoggerDebug).toHaveBeenCalledWith('[test-worker] IdleMonitor updated with timeout disabled') + }) + + it('should not update when same timeout value', () => { + // Arrange + monitor.start() + vi.clearAllMocks() + + // Act + monitor.updateTimeout(5) + + // Assert + expect(mockLoggerDebug).not.toHaveBeenCalled() + }) + + it('should reset timer when active and timeout changes', () => { + // Arrange + monitor.start() + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + vi.clearAllMocks() + + // Act + monitor.updateTimeout(10) + + // Assert + expect(clearTimeoutSpy).toHaveBeenCalled() + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10000) + }) + }) + + describe('Timeout behavior', () => { + beforeEach(() => { + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + }) + + it('should emit idleTimeout event when timer expires', () => { + // Arrange + monitor.start() + const idleTimeoutHandler = vi.fn() + monitor.on('idleTimeout', idleTimeoutHandler) + + // Act + vi.advanceTimersByTime(5000) + + // Assert + expect(idleTimeoutHandler).toHaveBeenCalledTimes(1) + expect(mockLoggerInfo).toHaveBeenCalledWith('[test-worker] Worker idle timeout triggered') + }) + + it('should stop monitoring after timeout event', () => { + // Arrange + monitor.start() + const idleTimeoutHandler = vi.fn() + monitor.on('idleTimeout', idleTimeoutHandler) + + // Act + vi.advanceTimersByTime(5000) + + // Assert + expect(monitor.isActive()).toBe(false) + }) + + it('should not timeout when paused', () => { + // Arrange + monitor.start() + monitor.pauseTimer() + const idleTimeoutHandler = vi.fn() + monitor.on('idleTimeout', idleTimeoutHandler) + + // Act + vi.advanceTimersByTime(10000) + + // Assert + expect(idleTimeoutHandler).not.toHaveBeenCalled() + }) + + it('should timeout after resuming from pause', () => { + // Arrange + monitor.start() + monitor.pauseTimer() + const idleTimeoutHandler = vi.fn() + monitor.on('idleTimeout', idleTimeoutHandler) + + // Act + vi.advanceTimersByTime(3000) // Should not timeout while paused + monitor.resumeTimer() + vi.advanceTimersByTime(5000) // Should timeout after resume + + // Assert + expect(idleTimeoutHandler).toHaveBeenCalledTimes(1) + }) + + it('should not timeout when timeout is disabled', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 0 }) + monitor.start() + const idleTimeoutHandler = vi.fn() + monitor.on('idleTimeout', idleTimeoutHandler) + + // Act + vi.advanceTimersByTime(100000) + + // Assert + expect(idleTimeoutHandler).not.toHaveBeenCalled() + }) + }) + + describe('Edge cases', () => { + it('should handle rapid start/stop cycles', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + + // Act & Assert - Should not throw errors + expect(() => { + monitor.start() + monitor.stop() + monitor.start() + monitor.stop() + monitor.start() + }).not.toThrow() + }) + + it('should handle rapid pause/resume cycles', () => { + // Arrange + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + monitor.start() + + // Act & Assert - Should not throw errors + expect(() => { + monitor.pauseTimer() + monitor.resumeTimer() + monitor.pauseTimer() + monitor.pauseTimer() + monitor.resumeTimer() + monitor.resumeTimer() + }).not.toThrow() + }) + + it('should handle excessive resume calls gracefully', () => { + // Arrange + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + monitor = new WorkerIdleMonitor('test-worker', { idleTimeout: 5 }) + monitor.start() + + // Act + monitor.resumeTimer() // Resume without pause + monitor.resumeTimer() // Resume again + monitor.resumeTimer() // Resume again + + // Assert - Should only start timer once + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/vscode-wdio-api/tests/manager.test.ts b/packages/vscode-wdio-server/tests/manager.test.ts similarity index 82% rename from packages/vscode-wdio-api/tests/manager.test.ts rename to packages/vscode-wdio-server/tests/manager.test.ts index 07615d6..e083f3e 100644 --- a/packages/vscode-wdio-api/tests/manager.test.ts +++ b/packages/vscode-wdio-server/tests/manager.test.ts @@ -2,9 +2,9 @@ import { dirname, join, normalize } from 'node:path' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { ServerManager } from '../src/manager.js' +import { WdioWorkerManager } from '../src/manager.js' import { WdioExtensionWorker } from '../src/worker.js' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' vi.mock('vscode', () => import('../../../tests/__mocks__/vscode.cjs')) @@ -19,6 +19,7 @@ vi.mock('../src/worker.js', () => { WdioExtensionWorker.prototype.start = vi.fn().mockResolvedValue(undefined) WdioExtensionWorker.prototype.waitForStart = vi.fn().mockResolvedValue(undefined) WdioExtensionWorker.prototype.stop = vi.fn().mockResolvedValue(undefined) + WdioExtensionWorker.prototype.on = vi.fn() return { WdioExtensionWorker } }) @@ -35,16 +36,19 @@ vi.mock('../src/utils.js', async (importActual) => { }) const mockConfigManager = { + globalConfig: { + workerIdleTimeout: 600, + }, on: vi.fn(), -} as unknown as ExtensionConfigManagerInterface +} as unknown as IExtensionConfigManager describe('ServerManager', () => { - let serverManager: ServerManager + let workerManager: WdioWorkerManager // Create a fresh instance of ServerManager before each test beforeEach(() => { vi.resetAllMocks() - serverManager = new ServerManager(mockConfigManager) + workerManager = new WdioWorkerManager(mockConfigManager) }) afterEach(() => { @@ -57,7 +61,7 @@ describe('ServerManager', () => { const configPaths = ['/path/to/wdio.config.js', '/path/to/wdio.config.ts', '/another/path/wdio.config.js'] // Execute - await serverManager.start(configPaths) + await workerManager.start(configPaths) // Assert expect(WdioExtensionWorker).toHaveBeenCalledTimes(2) @@ -70,7 +74,7 @@ describe('ServerManager', () => { }) it('should handle empty config paths array', async () => { - await serverManager.start([]) + await workerManager.start([]) expect(WdioExtensionWorker).not.toHaveBeenCalled() }) @@ -82,13 +86,13 @@ describe('ServerManager', () => { const configPath = join(process.cwd(), 'path', 'to', '/wdio.config.js') // First call to create server - const result = await serverManager.getConnection(configPath) + const result = await workerManager.getConnection(configPath) // Reset mocks before second call vi.clearAllMocks() // Execute - second call should use existing server - const cachedResult = await serverManager.getConnection(configPath) + const cachedResult = await workerManager.getConnection(configPath) // Assert - WdioExtensionWorker constructor should not be called again expect(WdioExtensionWorker).not.toHaveBeenCalled() @@ -102,7 +106,7 @@ describe('ServerManager', () => { const wdioDirName = dirname(configPath) // Execute - const result = await serverManager.getConnection(configPath) + const result = await workerManager.getConnection(configPath) // Assert expect(WdioExtensionWorker).toHaveBeenCalledTimes(1) @@ -113,10 +117,10 @@ describe('ServerManager', () => { it('should increment the id for each new worker', async () => { // Setup - create first worker - await serverManager.getConnection('/path/to/wdio.config.js') + await workerManager.getConnection('/path/to/wdio.config.js') // Execute - create second worker with different path - await serverManager.getConnection('/another/path/wdio.config.js') + await workerManager.getConnection('/another/path/wdio.config.js') // Assert expect(WdioExtensionWorker).toHaveBeenCalledTimes(2) @@ -130,10 +134,10 @@ describe('ServerManager', () => { // Setup - create multiple workers const configPaths = ['/path/to/wdio.config.js', '/another/path/wdio.config.js'] - await serverManager.start(configPaths) + await workerManager.start(configPaths) // Execute - await serverManager.dispose() + await workerManager.dispose() // Assert expect(vi.mocked(WdioExtensionWorker).mock.instances.length).toBe(2) @@ -142,7 +146,7 @@ describe('ServerManager', () => { it('should handle empty server pool', async () => { // Execute - await serverManager.dispose() + await workerManager.dispose() // Assert - should not throw an error expect(WdioExtensionWorker).not.toHaveBeenCalled() @@ -152,12 +156,12 @@ describe('ServerManager', () => { describe('reorganize', () => { it('should stop unnecessary workers and start new ones', async () => { // Setup - create initial workers - await serverManager.start(['/path/to/wdio.config.js', '/another/path/wdio.config.js']) + await workerManager.start(['/path/to/wdio.config.js', '/another/path/wdio.config.js']) vi.clearAllMocks() // Execute - reorganize with different paths - await serverManager.reorganize(['/path/to/wdio.config.js', '/new/path/wdio.config.js']) + await workerManager.reorganize(['/path/to/wdio.config.js', '/new/path/wdio.config.js']) // Assert // Should stop one worker @@ -178,8 +182,8 @@ describe('ServerManager', () => { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) // Mock createWorker to add delay and tracking - const originalCreateWorker = (serverManager as any).createWorker.bind(serverManager) - vi.spyOn(serverManager as any, 'createWorker').mockImplementation((async ( + const originalCreateWorker = (workerManager as any).createWorker.bind(workerManager) + vi.spyOn(workerManager as any, 'createWorker').mockImplementation((async ( id: number, configPath: string ) => { @@ -189,9 +193,9 @@ describe('ServerManager', () => { }) as any) // Execute multiple operations concurrently - const promise1 = serverManager.getConnection('/path/1/wdio.config.js') - const promise2 = serverManager.getConnection('/path/2/wdio.config.js') - const promise3 = serverManager.getConnection('/path/3/wdio.config.js') + const promise1 = workerManager.getConnection('/path/1/wdio.config.js') + const promise2 = workerManager.getConnection('/path/2/wdio.config.js') + const promise3 = workerManager.getConnection('/path/3/wdio.config.js') // Wait for all operations to complete await Promise.all([promise1, promise2, promise3]) @@ -208,8 +212,8 @@ describe('ServerManager', () => { const startCount = { count: 0 } // Mock createWorker to track calls - const originalCreateWorker = (serverManager as any).createWorker.bind(serverManager) - vi.spyOn(serverManager as any, 'createWorker').mockImplementation((async ( + const originalCreateWorker = (workerManager as any).createWorker.bind(workerManager) + vi.spyOn(workerManager as any, 'createWorker').mockImplementation((async ( id: number, configPath: string ) => { @@ -221,7 +225,7 @@ describe('ServerManager', () => { // Execute multiple operations for the same path concurrently const promises = Array(5) .fill(null) - .map(() => serverManager.getConnection('/same/path/wdio.config.js')) + .map(() => workerManager.getConnection('/same/path/wdio.config.js')) // Wait for all operations to complete const results = await Promise.all(promises) @@ -244,7 +248,7 @@ describe('ServerManager', () => { const configPath = '/path/to/wdio.config.js' // Access private method using any cast - const startWorker = (serverManager as any).startWorker.bind(serverManager) + const startWorker = (workerManager as any).startWorker.bind(workerManager) // Execute const result = await startWorker(id, configPath) @@ -264,11 +268,11 @@ describe('ServerManager', () => { const configPath = '/path/to/wdio.config.js' // Access private method using any cast - const startWorker = (serverManager as any).startWorker.bind(serverManager) + const startWorker = (workerManager as any).startWorker.bind(workerManager) // Add tracking to see if createWorker is called multiple times let createWorkerCalls = 0 - vi.spyOn(serverManager as any, 'createWorker').mockImplementation((async (id: number, path: string) => { + vi.spyOn(workerManager as any, 'createWorker').mockImplementation((async (id: number, path: string) => { createWorkerCalls++ // Mock delay to ensure operations overlap await new Promise((resolve) => setTimeout(resolve, 50)) @@ -291,35 +295,35 @@ describe('ServerManager', () => { describe('stopWorker', () => { it('should stop a worker and remove it from the server pool', async () => { // Setup - create a worker first - await serverManager.getConnection('/path/to/wdio.config.js') + await workerManager.getConnection('/path/to/wdio.config.js') // Access private methods using any cast - const stopWorker = (serverManager as any).stopWorker.bind(serverManager) + const stopWorker = (workerManager as any).stopWorker.bind(workerManager) // Get the worker from the server pool - const worker = (serverManager as any)._serverPool.get('/path/to') + const worker = (workerManager as any)._workerPool.get('/path/to') // Execute await stopWorker('/path/to', worker) // Assert expect(worker.stop).toHaveBeenCalledTimes(1) - expect((serverManager as any)._serverPool.has('/path/to')).toBe(false) + expect((workerManager as any)._workerPool.has('/path/to')).toBe(false) }) it('should return the same promise for concurrent stop calls', async () => { // Setup - create a worker first - await serverManager.getConnection('/path/to/wdio.config.js') + await workerManager.getConnection('/path/to/wdio.config.js') // Access private methods using any cast - const stopWorker = (serverManager as any).stopWorker.bind(serverManager) + const stopWorker = (workerManager as any).stopWorker.bind(workerManager) // Get the worker from the server pool - const worker = (serverManager as any)._serverPool.get('/path/to') + const worker = (workerManager as any)._workerPool.get('/path/to') // Add tracking let executeStopWorkerCalls = 0 - vi.spyOn(serverManager as any, 'executeStopWorker').mockImplementation(async () => { + vi.spyOn(workerManager as any, 'executeStopWorker').mockImplementation(async () => { executeStopWorkerCalls++ // Mock delay to ensure operations overlap await new Promise((resolve) => setTimeout(resolve, 50)) diff --git a/packages/vscode-wdio-api/tests/run.test.ts b/packages/vscode-wdio-server/tests/run.test.ts similarity index 97% rename from packages/vscode-wdio-api/tests/run.test.ts rename to packages/vscode-wdio-server/tests/run.test.ts index 165bb7b..c7fb259 100644 --- a/packages/vscode-wdio-api/tests/run.test.ts +++ b/packages/vscode-wdio-server/tests/run.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createTestItem } from '../../../tests/utils.js' import { TestRunner } from '../src/run.js' -import type { WdioExtensionWorkerInterface } from '@vscode-wdio/types' +import type { IWdioExtensionWorker } from '@vscode-wdio/types' import type { ResultSet } from '@vscode-wdio/types/reporter' // Mock dependencies @@ -14,7 +14,7 @@ vi.mock('../src/debug.js', () => ({})) describe('TestRunner', () => { let testRunner: TestRunner - let mockWorker: WdioExtensionWorkerInterface + let mockWorker: IWdioExtensionWorker let mockRpc: any beforeEach(() => { @@ -35,7 +35,11 @@ describe('TestRunner', () => { removeListener: vi.fn(), ensureConnected: vi.fn().mockResolvedValue(undefined), rpc: mockRpc, - } as unknown as WdioExtensionWorkerInterface + idleMonitor: { + pauseTimer: vi.fn(), + resumeTimer: vi.fn(), + }, + } as unknown as IWdioExtensionWorker // Create test runner instance testRunner = new TestRunner(mockWorker) diff --git a/packages/vscode-wdio-api/tests/utils.test.ts b/packages/vscode-wdio-server/tests/utils.test.ts similarity index 98% rename from packages/vscode-wdio-api/tests/utils.test.ts rename to packages/vscode-wdio-server/tests/utils.test.ts index d093a83..0b69927 100644 --- a/packages/vscode-wdio-api/tests/utils.test.ts +++ b/packages/vscode-wdio-server/tests/utils.test.ts @@ -6,7 +6,7 @@ import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest' import which from 'which' import { loggingFn, resolveNodePath } from '../src/utils.js' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' vi.mock('vscode', async () => import('../../../tests/__mocks__/vscode.cjs')) vi.mock('@vscode-wdio/logger', () => import('../../../tests/__mocks__/logger.js')) @@ -51,7 +51,7 @@ describe('resolveNodePath', () => { globalConfig: { nodeExecutable: '', }, - } as unknown as ExtensionConfigManagerInterface + } as unknown as IExtensionConfigManager // Import which module after mocking diff --git a/packages/vscode-wdio-api/tests/worker.test.ts b/packages/vscode-wdio-server/tests/worker.test.ts similarity index 97% rename from packages/vscode-wdio-api/tests/worker.test.ts rename to packages/vscode-wdio-server/tests/worker.test.ts index cfadc66..f92780c 100644 --- a/packages/vscode-wdio-api/tests/worker.test.ts +++ b/packages/vscode-wdio-server/tests/worker.test.ts @@ -8,8 +8,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createWss } from '../src/utils.js' import { WdioExtensionWorker } from '../src/worker.js' -import type { ExtensionApi } from '@vscode-wdio/types/api' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' +import type { ExtensionApi } from '@vscode-wdio/types/server' import type * as WebSocket from 'ws' // Mock dependencies @@ -25,6 +25,9 @@ vi.mock('birpc', () => { } }) vi.mock('get-port') + +vi.mock('vscode', () => import('../../../tests/__mocks__/vscode.cjs')) + vi.mock('@vscode-wdio/logger', () => import('../../../tests/__mocks__/logger.js')) vi.mock('../src/utils.js', () => { @@ -90,7 +93,11 @@ describe('WdioExtensionWorker', () => { vi.mocked(createBirpc).mockReturnValue(mockBirpc) // Create worker instance - worker = new WdioExtensionWorker({} as unknown as ExtensionConfigManagerInterface, '#1', '/test/path') + worker = new WdioExtensionWorker( + { globalConfig: { workerIdleTimeout: 600 } } as unknown as IExtensionConfigManager, + '#1', + '/test/path' + ) }) afterEach(() => { @@ -387,7 +394,7 @@ describe('WdioExtensionWorker', () => { ;(worker as any)._workerConnected = true // Verify RPC is returned - expect(worker.rpc).toBe(mockBirpc) + expect(worker.rpc.loadWdioConfig).toBe(mockBirpc.loadWdioConfig) }) it('should throw error when not connected', () => { diff --git a/packages/vscode-wdio-api/tsconfig.json b/packages/vscode-wdio-server/tsconfig.json similarity index 100% rename from packages/vscode-wdio-api/tsconfig.json rename to packages/vscode-wdio-server/tsconfig.json diff --git a/packages/vscode-wdio-test/package.json b/packages/vscode-wdio-test/package.json index c0ee549..3a34372 100644 --- a/packages/vscode-wdio-test/package.json +++ b/packages/vscode-wdio-test/package.json @@ -15,7 +15,7 @@ "clean": "shx rm -rf out dist coverage" }, "dependencies": { - "@vscode-wdio/api": "workspace:*", + "@vscode-wdio/server": "workspace:*", "@vscode-wdio/config": "workspace:*", "@vscode-wdio/constants": "workspace:*", "@vscode-wdio/logger": "workspace:*", diff --git a/packages/vscode-wdio-test/src/converter.ts b/packages/vscode-wdio-test/src/converter.ts index 88bb483..197894a 100644 --- a/packages/vscode-wdio-test/src/converter.ts +++ b/packages/vscode-wdio-test/src/converter.ts @@ -1,7 +1,7 @@ import path from 'node:path' import * as vscode from 'vscode' -import type { ReadSpecsResult } from '@vscode-wdio/types/api' +import type { ReadSpecsResult } from '@vscode-wdio/types/server' import type { TestData, SourceRange, VscodeTestData } from '@vscode-wdio/types/test' /** * Convert the parser's TestData to VSCode compatible TestData diff --git a/packages/vscode-wdio-test/src/manager.ts b/packages/vscode-wdio-test/src/manager.ts index 0b2fd27..198ace7 100644 --- a/packages/vscode-wdio-test/src/manager.ts +++ b/packages/vscode-wdio-test/src/manager.ts @@ -9,11 +9,8 @@ import { MetadataRepository } from './metadata.js' import { TestRepository } from './repository.js' import { createRunProfile } from './utils.js' import type { ExtensionConfigManager } from '@vscode-wdio/config' -import type { ServerManagerInterface as ServerManager } from '@vscode-wdio/types/api' -import type { - RepositoryManagerInterface, - TestRepositoryInterface as TestRepositoryInterface, -} from '@vscode-wdio/types/test' +import type { IWorkerManager } from '@vscode-wdio/types/server' +import type { IRepositoryManager, ITestRepository } from '@vscode-wdio/types/test' /** * workspace -- managed by this class @@ -25,8 +22,8 @@ import type { const LOADING_TEST_ITEM_ID = '_resolving' -export class RepositoryManager extends MetadataRepository implements RepositoryManagerInterface { - private readonly _repos = new Set() +export class RepositoryManager extends MetadataRepository implements IRepositoryManager { + private readonly _repos = new Set() private _loadingTestItem: vscode.TestItem private _workspaceTestItems: vscode.TestItem[] = [] private _wdioConfigTestItems: vscode.TestItem[] = [] @@ -36,7 +33,7 @@ export class RepositoryManager extends MetadataRepository implements RepositoryM constructor( public readonly controller: vscode.TestController, public readonly configManager: ExtensionConfigManager, - private readonly serverManager: ServerManager + private readonly workerManager: IWorkerManager ) { super() this._loadingTestItem = this.controller.createTestItem(LOADING_TEST_ITEM_ID, 'Resolving WebdriverIO Tests...') @@ -66,7 +63,7 @@ export class RepositoryManager extends MetadataRepository implements RepositoryM ) ) .then(() => this.registerToTestController()) - .then(() => this.serverManager.reorganize(configManager.getWdioConfigPaths())) + .then(() => this.workerManager.reorganize(configManager.getWdioConfigPaths())) }) } @@ -188,8 +185,8 @@ export class RepositoryManager extends MetadataRepository implements RepositoryM workspaceTestItem.children.add(configItem) this._wdioConfigTestItems.push(configItem) - const worker = await this.serverManager.getConnection(wdioConfigPath) - const repo = new TestRepository(this.controller, worker, wdioConfigPath, configItem) + const worker = await this.workerManager.getConnection(wdioConfigPath) + const repo = new TestRepository(this.controller, worker, wdioConfigPath, configItem, this.workerManager) this._repos.add(repo) configItem.description = relative(workspaceTestItem.uri!.fsPath, dirname(wdioConfigPath)) diff --git a/packages/vscode-wdio-test/src/metadata.ts b/packages/vscode-wdio-test/src/metadata.ts index 03c3c06..2c597b6 100644 --- a/packages/vscode-wdio-test/src/metadata.ts +++ b/packages/vscode-wdio-test/src/metadata.ts @@ -1,7 +1,7 @@ -import type { MetadataRepositoryInterface, TestItemMetadata } from '@vscode-wdio/types/test' +import type { IMetadataRepository, TestItemMetadata } from '@vscode-wdio/types/test' import type * as vscode from 'vscode' -export class MetadataRepository implements MetadataRepositoryInterface { +export class MetadataRepository implements IMetadataRepository { private static testMetadataRepository = new WeakMap() public getMetadata(testItem: vscode.TestItem) { const metadata = MetadataRepository.testMetadataRepository.get(testItem) diff --git a/packages/vscode-wdio-test/src/reporter.ts b/packages/vscode-wdio-test/src/reporter.ts index cdb0b40..5906bce 100644 --- a/packages/vscode-wdio-test/src/reporter.ts +++ b/packages/vscode-wdio-test/src/reporter.ts @@ -2,7 +2,7 @@ import { log } from '@vscode-wdio/logger' import * as vscode from 'vscode' import type { ResultSet, TestSuite, Test } from '@vscode-wdio/types/reporter' -import type { TestRepositoryInterface } from '@vscode-wdio/types/test' +import type { ITestRepository } from '@vscode-wdio/types/test' /** * TestReporter class for handling WebdriverIO test results and updating VSCode TestItems @@ -15,7 +15,7 @@ export class TestReporter { * @param _run The current test run */ constructor( - private readonly _repository: TestRepositoryInterface, + private readonly _repository: ITestRepository, private readonly _run: vscode.TestRun ) {} diff --git a/packages/vscode-wdio-test/src/repository.ts b/packages/vscode-wdio-test/src/repository.ts index 0febe7d..9d0fa00 100644 --- a/packages/vscode-wdio-test/src/repository.ts +++ b/packages/vscode-wdio-test/src/repository.ts @@ -9,26 +9,49 @@ import { convertPathToUri, convertTestData } from './converter.js' import { MetadataRepository } from './metadata.js' import { filterSpecsByPaths } from './utils.js' -import type { WdioExtensionWorkerInterface } from '@vscode-wdio/types/api' -import type { VscodeTestData, TestRepositoryInterface } from '@vscode-wdio/types/test' +import type { IWorkerManager, IWdioExtensionWorker } from '@vscode-wdio/types/server' +import type { VscodeTestData, ITestRepository } from '@vscode-wdio/types/test' import type * as vscode from 'vscode' +class WorkerProxy extends MetadataRepository { + private _worker: IWdioExtensionWorker | undefined + constructor( + private readonly _wdioConfigPath: string, + worker: IWdioExtensionWorker, + private workerManager: IWorkerManager + ) { + super() + this._worker = worker + this._worker.on('shutdown', () => { + this._worker = undefined + }) + } + + async getWorker() { + if (!this._worker) { + this._worker = await this.workerManager.getConnection(this._wdioConfigPath) + } + return this._worker + } +} + /** * TestRepository class that manages all WebdriverIO tests at * the single WebdriverIO configuration file */ -export class TestRepository extends MetadataRepository implements TestRepositoryInterface { +export class TestRepository extends WorkerProxy implements ITestRepository { private _specPatterns: string[] = [] private _fileMap = new Map() private _framework: string | undefined = undefined constructor( public readonly controller: vscode.TestController, - public readonly worker: WdioExtensionWorkerInterface, + _worker: IWdioExtensionWorker, public readonly wdioConfigPath: string, - private _wdioConfigTestItem: vscode.TestItem + private _wdioConfigTestItem: vscode.TestItem, + workerManager: IWorkerManager ) { - super() + super(wdioConfigPath, _worker, workerManager) } public get specPatterns() { @@ -47,7 +70,8 @@ export class TestRepository extends MetadataRepository implements TestRepository */ public async discoverAllTests(): Promise { try { - const config = await this.worker.rpc.loadWdioConfig({ configFilePath: this.wdioConfigPath }) + const worker = await this.getWorker() + const config = await worker.rpc.loadWdioConfig({ configFilePath: this.wdioConfigPath }) if (!config) { return @@ -80,7 +104,8 @@ export class TestRepository extends MetadataRepository implements TestRepository */ public async reloadSpecFiles(filePaths: string[] = []): Promise { try { - const config = await this.worker.rpc.loadWdioConfig({ configFilePath: this.wdioConfigPath }) + const worker = await this.getWorker() + const config = await worker.rpc.loadWdioConfig({ configFilePath: this.wdioConfigPath }) if (!config) { return } @@ -166,7 +191,8 @@ export class TestRepository extends MetadataRepository implements TestRepository this._fileMap.clear() } log.debug(`Spec files registration is started for: ${specs.length} files.`) - const testData = await this.worker.rpc.readSpecs({ specs }) + const worker = await this.getWorker() + const testData = await worker.rpc.readSpecs({ specs }) const fileTestItems = ( await Promise.all( @@ -323,7 +349,7 @@ export class TestRepository extends MetadataRepository implements TestRepository // The path of the Spec file is the third one, as it is the next level after Workspace,WdioConfig. const candidatePath = key.split(TEST_ID_SEPARATOR)[2] if (normalizedSpecFilePath === normalizePath(candidatePath)) { - log.trace(`Detected spec file :${value}`) + log.trace(`Detected spec file :${normalizedSpecFilePath}`) return value } } diff --git a/packages/vscode-wdio-test/src/runHandler.ts b/packages/vscode-wdio-test/src/runHandler.ts index fdb4d5e..9ff67b3 100644 --- a/packages/vscode-wdio-test/src/runHandler.ts +++ b/packages/vscode-wdio-test/src/runHandler.ts @@ -1,13 +1,13 @@ import { dirname } from 'node:path' -import { DebugRunner, TestRunner } from '@vscode-wdio/api' import { log } from '@vscode-wdio/logger' +import { DebugRunner, TestRunner } from '@vscode-wdio/server' import * as vscode from 'vscode' import { TestReporter } from './reporter.js' import { getRootTestItem } from './utils.js' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' import type { RepositoryManager } from './manager.js' class TestQueue { @@ -27,7 +27,7 @@ class TestQueue { } export function createHandler( - configManager: ExtensionConfigManagerInterface, + configManager: IExtensionConfigManager, repositoryManager: RepositoryManager, isDebug = false ) { @@ -78,7 +78,7 @@ export function createHandler( try { const runner = !isDebug - ? new TestRunner(testData.repository.worker) + ? new TestRunner(await testData.repository.getWorker()) : new DebugRunner( configManager, getWorkspaceFolder.call(repositoryManager, configManager, testData.testItem), @@ -132,7 +132,7 @@ function conversionCucumberStep(this: RepositoryManager, testItem: vscode.TestIt function getWorkspaceFolder( this: RepositoryManager, - configManager: ExtensionConfigManagerInterface, + configManager: IExtensionConfigManager, testItem: vscode.TestItem ) { if (!configManager.isMultiWorkspace) { diff --git a/packages/vscode-wdio-test/tests/converter.test.ts b/packages/vscode-wdio-test/tests/converter.test.ts index 8f8ddb6..98f598e 100644 --- a/packages/vscode-wdio-test/tests/converter.test.ts +++ b/packages/vscode-wdio-test/tests/converter.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi } from 'vitest' import * as vscode from 'vscode' import { convertPathToUri, convertTestData, isCucumberFeatureFile } from '../src/converter.js' -import type { ReadSpecsResult } from '@vscode-wdio/types/api' +import type { ReadSpecsResult } from '@vscode-wdio/types/server' // Mock dependencies vi.mock('vscode', () => import('../../../tests/__mocks__/vscode.cjs')) diff --git a/packages/vscode-wdio-test/tests/manager.test.ts b/packages/vscode-wdio-test/tests/manager.test.ts index a5f4d50..4378c33 100644 --- a/packages/vscode-wdio-test/tests/manager.test.ts +++ b/packages/vscode-wdio-test/tests/manager.test.ts @@ -1,8 +1,8 @@ import { join } from 'node:path' -import { ServerManager } from '@vscode-wdio/api' import { ExtensionConfigManager } from '@vscode-wdio/config' import { TEST_ID_SEPARATOR } from '@vscode-wdio/constants' +import { WdioWorkerManager } from '@vscode-wdio/server' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import * as vscode from 'vscode' @@ -10,8 +10,8 @@ import { mockCreateTestItem, MockTestItemCollection } from '../../../tests/utils import { RepositoryManager } from '../src/manager.js' import { TestRepository } from '../src/repository.js' -import type { WdioExtensionWorkerInterface } from '@vscode-wdio/types/api' import type { WorkspaceData } from '@vscode-wdio/types/config' +import type { IWdioExtensionWorker } from '@vscode-wdio/types/server' // Mock dependencies vi.mock('vscode', async () => { @@ -38,7 +38,7 @@ vi.mock('../src/utils.js', () => { describe('RepositoryManager', () => { let fakeWorkspaceFolder: vscode.WorkspaceFolder let fakeWorkspaces: WorkspaceData[] - let serverManager: ServerManager + let workerManager: WdioWorkerManager let clearTestsStub: ReturnType let discoverAllTestsStub: ReturnType let controller: vscode.TestController @@ -77,8 +77,10 @@ describe('RepositoryManager', () => { ] // Setup ServerManager mock - serverManager = new ServerManager(configManager) - vi.spyOn(serverManager, 'getConnection').mockResolvedValue({} as unknown as WdioExtensionWorkerInterface) + workerManager = new WdioWorkerManager(configManager) + vi.spyOn(workerManager, 'getConnection').mockResolvedValue({ + on: vi.fn(), + } as unknown as IWdioExtensionWorker) // Stub configManager vi.spyOn(configManager, 'workspaces', 'get').mockReturnValue(fakeWorkspaces) @@ -90,7 +92,7 @@ describe('RepositoryManager', () => { vi.spyOn(TestRepository.prototype, 'discoverAllTests').mockImplementation(discoverAllTestsStub) vi.spyOn(TestRepository.prototype, 'clearTests').mockImplementation(clearTestsStub) - repositoryManager = new RepositoryManager(controller, configManager, serverManager) + repositoryManager = new RepositoryManager(controller, configManager, workerManager) }) afterEach(() => { @@ -113,7 +115,7 @@ describe('RepositoryManager', () => { expect((repositoryManager as any)._workspaceTestItems.length).toBe(1) expect((repositoryManager as any)._wdioConfigTestItems.length).toBe(1) - expect(serverManager.getConnection).toHaveBeenCalledWith(fakeWorkspaces[0].wdioConfigFiles[0]) + expect(workerManager.getConnection).toHaveBeenCalledWith(fakeWorkspaces[0].wdioConfigFiles[0]) }) }) @@ -159,7 +161,9 @@ describe('RepositoryManager', () => { }, ] vi.spyOn(configManager, 'workspaces', 'get').mockReturnValue(fakeWorkspaces) - vi.spyOn(serverManager, 'getConnection').mockResolvedValue({} as unknown as WdioExtensionWorkerInterface) + vi.spyOn(workerManager, 'getConnection').mockResolvedValue({ + on: vi.fn(), + } as unknown as IWdioExtensionWorker) await repositoryManager.initialize() repositoryManager.registerToTestController() diff --git a/packages/vscode-wdio-test/tests/repository.test.ts b/packages/vscode-wdio-test/tests/repository.test.ts index b871d98..5d7728e 100644 --- a/packages/vscode-wdio-test/tests/repository.test.ts +++ b/packages/vscode-wdio-test/tests/repository.test.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode' import { mockCreateTestItem, MockTestItemCollection } from '../../../tests/utils.js' import { TestRepository } from '../src/repository.js' -import type { WdioConfig, WdioExtensionWorkerInterface } from '@vscode-wdio/types/api' +import type { IWorkerManager, WdioConfig, IWdioExtensionWorker } from '@vscode-wdio/types/server' // Mock dependencies vi.mock('vscode', async () => import('../../../tests/__mocks__/vscode.cjs')) @@ -26,10 +26,11 @@ describe('TestRepository', () => { let testController: vscode.TestController let wdioConfigTestItem: vscode.TestItem let testRepository: TestRepository - let mockWorker: WdioExtensionWorkerInterface + let mockWorker: IWdioExtensionWorker let readFile: ReturnType let readSpecsStub: ReturnType let runProfileDisposeStub: ReturnType + let workerManager: IWorkerManager beforeEach(() => { vi.resetAllMocks() @@ -54,6 +55,7 @@ describe('TestRepository', () => { ]) mockWorker = { + on: vi.fn(), rpc: { loadWdioConfig: vi.fn().mockResolvedValue({ framework: 'mocha', @@ -61,7 +63,7 @@ describe('TestRepository', () => { }), readSpecs: readSpecsStub, }, - } as unknown as WdioExtensionWorkerInterface + } as unknown as IWdioExtensionWorker readFile = vi.fn() class MockTestRepository extends TestRepository { @@ -70,8 +72,16 @@ describe('TestRepository', () => { } } + workerManager = vi.fn() as unknown as IWorkerManager + // Create repository with mocked dependencies - testRepository = new MockTestRepository(testController, mockWorker, mockWdioConfigPath, wdioConfigTestItem) + testRepository = new MockTestRepository( + testController, + mockWorker, + mockWdioConfigPath, + wdioConfigTestItem, + workerManager + ) testRepository.setMetadata(wdioConfigTestItem, { uri: mockWdioConfigUri, @@ -94,10 +104,10 @@ describe('TestRepository', () => { // Group 1: Initialization and basic functionality describe('Initialization and Resource Management', () => { - it('should initialize with provided dependencies', () => { + it('should initialize with provided dependencies', async () => { // Verify expect(testRepository.controller).toBe(testController) - expect(testRepository.worker).toBe(mockWorker) + expect(await testRepository.getWorker()).toBe(mockWorker) expect(testRepository.wdioConfigPath).toBe(mockWdioConfigPath) }) @@ -126,7 +136,13 @@ describe('TestRepository', () => { it('should throw error if framework is accessed before loading config', () => { // Create new instance without loading config - const repo = new TestRepository(testController, mockWorker, mockWdioConfigPath, wdioConfigTestItem) + const repo = new TestRepository( + testController, + mockWorker, + mockWdioConfigPath, + wdioConfigTestItem, + workerManager + ) // Verify expect(() => repo.framework).toThrow('The configuration for WebdriverIO is not loaded') diff --git a/packages/vscode-wdio-test/tests/runHandler.test.ts b/packages/vscode-wdio-test/tests/runHandler.test.ts index fa9bcbd..b2bf3ba 100644 --- a/packages/vscode-wdio-test/tests/runHandler.test.ts +++ b/packages/vscode-wdio-test/tests/runHandler.test.ts @@ -1,11 +1,11 @@ -import { TestRunner } from '@vscode-wdio/api' import { log } from '@vscode-wdio/logger' +import { TestRunner } from '@vscode-wdio/server' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createTestItem } from '../../../tests/utils.js' import { TestReporter } from '../src/reporter.js' import { createHandler } from '../src/runHandler.js' -import type { ExtensionConfigManagerInterface } from '@vscode-wdio/types/config' +import type { IExtensionConfigManager } from '@vscode-wdio/types/config' import type { TestItemMetadata } from '@vscode-wdio/types/test' import type * as vscode from 'vscode' import type { RepositoryManager } from '../src/index.js' @@ -50,7 +50,7 @@ vi.mock('../src/utils.js', async (importActual) => { } }) -vi.mock('@vscode-wdio/api', () => { +vi.mock('@vscode-wdio/server', () => { const TestRunner = vi.fn() TestRunner.prototype.run = vi.fn() TestRunner.prototype.stdout = null @@ -70,7 +70,7 @@ vi.mock('../../src/config/index.js', () => ({ describe('Run Handler', () => { let mockTestRun: vscode.TestRun let mockToken: vscode.CancellationToken - let mockConfigManager: ExtensionConfigManagerInterface + let mockConfigManager: IExtensionConfigManager let runHandler: ReturnType let testItemMap: WeakMap let mockRepositoryManager: RepositoryManager @@ -97,7 +97,7 @@ describe('Run Handler', () => { globalConfig: { showOutput: true, }, - } as unknown as ExtensionConfigManagerInterface + } as unknown as IExtensionConfigManager testItemMap = new WeakMap() const mockGetMetadata = vi.fn().mockImplementation((testItem: vscode.TestItem) => testItemMap.get(testItem)) @@ -242,7 +242,7 @@ describe('Run Handler', () => { // Setup const mockSpecTestData = createTestItem('spec1', { isConfigFile: true, - repository: { worker: {}, framework: 'mocha' }, + repository: { getWorker: vi.fn(), framework: 'mocha' }, }) testItemMap.set(mockSpecTestData.testItem, mockSpecTestData.metadata) @@ -277,7 +277,7 @@ describe('Run Handler', () => { // Setup const mockSpecTestData = createTestItem('spec1', { isSpecFile: true, - repository: { worker: {} }, + repository: { getWorker: vi.fn() }, }) testItemMap.set(mockSpecTestData.testItem, mockSpecTestData.metadata) @@ -332,7 +332,7 @@ describe('Run Handler', () => { const mockParentTestData = createTestItem('scenario1', { type: 'scenario', isTestcase: true, - repository: { framework: 'cucumber', worker: {} }, + repository: { framework: 'cucumber', getWorker: vi.fn() }, }) const mockStepTestData = createTestItem( @@ -340,7 +340,7 @@ describe('Run Handler', () => { { type: 'step', isTestcase: true, - repository: { framework: 'cucumber', worker: {} }, + repository: { framework: 'cucumber', getWorker: vi.fn() }, }, mockParentTestData.testItem ) @@ -371,7 +371,7 @@ describe('Run Handler', () => { // Setup const mockTestData = createTestItem('test1', { isSpecFile: true, - repository: { framework: 'mocha', worker: {} }, + repository: { framework: 'mocha', getWorker: vi.fn() }, }) testItemMap.set(mockTestData.testItem, mockTestData.metadata) diff --git a/packages/vscode-wdio-types/package.json b/packages/vscode-wdio-types/package.json index 52378b8..f8ef287 100644 --- a/packages/vscode-wdio-types/package.json +++ b/packages/vscode-wdio-types/package.json @@ -8,10 +8,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./api": { - "importSource": "./src/api.ts", - "types": "./dist/api.d.ts", - "import": "./dist/api.js" + "./server": { + "importSource": "./src/server.ts", + "types": "./dist/server.d.ts", + "import": "./dist/server.js" }, "./config": { "importSource": "./src/config.ts", diff --git a/packages/vscode-wdio-types/src/config.ts b/packages/vscode-wdio-types/src/config.ts index 8ed5a38..7a1a50c 100644 --- a/packages/vscode-wdio-types/src/config.ts +++ b/packages/vscode-wdio-types/src/config.ts @@ -1,7 +1,6 @@ -import type EventEmitter from 'node:events' import type { DEFAULT_CONFIG_VALUES } from '@vscode-wdio/constants' import type * as vscode from 'vscode' -import type { WebdriverIOConfig } from './utils.js' +import type { ITypedEventEmitter, WebdriverIOConfig } from './utils.js' export type ConfigPropertyNames = typeof DEFAULT_CONFIG_VALUES extends Record ? K[] : never @@ -10,7 +9,13 @@ export type WorkspaceData = { wdioConfigFiles: string[] } -export interface ExtensionConfigManagerInterface extends EventEmitter, vscode.Disposable { +type ToEventConfig = { + [K in keyof T as `update:${string & K}`]: T[K] +} + +export type WebdriverIOConfigEvent = ToEventConfig + +export interface IExtensionConfigManager extends ITypedEventEmitter, vscode.Disposable { isMultiWorkspace: boolean globalConfig: WebdriverIOConfig workspaces: WorkspaceData[] diff --git a/packages/vscode-wdio-types/src/index.ts b/packages/vscode-wdio-types/src/index.ts index e1fb76f..07b5a77 100644 --- a/packages/vscode-wdio-types/src/index.ts +++ b/packages/vscode-wdio-types/src/index.ts @@ -1,4 +1,4 @@ -export type * from './api.js' +export type * from './server.js' export type * from './config.js' export type * from './reporter.js' export type * from './test.js' diff --git a/packages/vscode-wdio-types/src/api.ts b/packages/vscode-wdio-types/src/server.ts similarity index 65% rename from packages/vscode-wdio-types/src/api.ts rename to packages/vscode-wdio-types/src/server.ts index d382a8e..79b229e 100644 --- a/packages/vscode-wdio-types/src/api.ts +++ b/packages/vscode-wdio-types/src/server.ts @@ -1,8 +1,7 @@ -import type EventEmitter from 'node:events' import type * as vscode from 'vscode' import type { ResultSet } from './reporter.js' import type { TestData } from './test.js' -import type { NumericLogLevel } from './utils.js' +import type { NumericLogLevel, ITypedEventEmitter } from './utils.js' export type WdioConfig = { specs: string[] @@ -120,28 +119,79 @@ export interface WorkerRunnerOptions { astCollect: boolean } -export interface WdioExtensionWorkerInterface extends EventEmitter { +export interface IWdioExtensionWorker extends ITypedEventEmitter { cid: string rpc: WorkerApi + idleMonitor: IWorkerIdleMonitor start(): Promise waitForStart(): Promise stop(): Promise isConnected(): boolean ensureConnected(): Promise - emit(event: K, data: WdioExtensionWorkerEvents[K]): boolean - on( - event: K, - listener: (data: WdioExtensionWorkerEvents[K]) => void - ): this } export interface WdioExtensionWorkerEvents { stdout: string stderr: string + idleTimeout: undefined + shutdown: undefined } -export interface ServerManagerInterface extends vscode.Disposable { +export interface IWorkerManager extends vscode.Disposable { start(configPaths: string[]): Promise - getConnection(configPaths: string): Promise + getConnection(configPaths: string): Promise reorganize(configPaths: string[]): Promise } + +export interface IWorkerIdleMonitor { + /** + * Start monitoring for idle timeout + */ + start(): void + + /** + * Stop monitoring and clear any pending timeout + */ + stop(): void + + /** + * Reset the idle timer (called when worker is accessed) + */ + resetTimer(): void + + /** + * Update the idle timeout configuration + * @param timeout New timeout value in seconds (0 or negative to disable) + */ + updateTimeout(timeout: number): void + + /** + * Pause the idle timer (called when RPC operation starts) + */ + pauseTimer(): void + + /** + * Resume the idle timer (called when RPC operation completes) + */ + resumeTimer(): void + + /** + * Check if monitoring is currently active + */ + isActive(): boolean + + /** + * Add event listener for idle timeout events + * @param event Event name ('idleTimeout') + * @param listener Event listener function + */ + on(event: 'idleTimeout', listener: () => void): this +} + +export interface WorkerIdleMonitorOptions { + /** + * Idle timeout in seconds + * Set to 0 or negative value to disable timeout + */ + idleTimeout: number +} diff --git a/packages/vscode-wdio-types/src/test.ts b/packages/vscode-wdio-types/src/test.ts index f57d949..55d6e2e 100644 --- a/packages/vscode-wdio-types/src/test.ts +++ b/packages/vscode-wdio-types/src/test.ts @@ -1,11 +1,11 @@ import type * as vscode from 'vscode' -import type { WdioExtensionWorkerInterface } from './api.js' -import type { ExtensionConfigManagerInterface } from './config.js' +import type { IExtensionConfigManager } from './config.js' +import type { IWdioExtensionWorker } from './server.js' -export interface RepositoryManagerInterface extends vscode.Disposable { +export interface IRepositoryManager extends vscode.Disposable { readonly controller: vscode.TestController - readonly configManager: ExtensionConfigManagerInterface - readonly repos: TestRepositoryInterface[] + readonly configManager: IExtensionConfigManager + readonly repos: ITestRepository[] initialize(): Promise addWdioConfig(workspaceUri: vscode.Uri, wdioConfigPath: string): Promise @@ -14,12 +14,12 @@ export interface RepositoryManagerInterface extends vscode.Disposable { refreshTests(): Promise } -export interface TestRepositoryInterface extends MetadataRepositoryInterface, vscode.Disposable { +export interface ITestRepository extends IMetadataRepository, vscode.Disposable { readonly controller: vscode.TestController - readonly worker: WdioExtensionWorkerInterface readonly wdioConfigPath: string specPatterns: string[] framework: string + getWorker(): Promise discoverAllTests(): Promise reloadSpecFiles(filePaths?: string[]): Promise removeSpecFile(specPath: string): void @@ -87,7 +87,7 @@ export type TestItemMetadata = { isConfigFile: boolean isSpecFile: boolean isTestcase: boolean - repository?: TestRepositoryInterface // only workspace dose not have repository + repository?: ITestRepository // only workspace dose not have repository runProfiles?: vscode.TestRunProfile[] type?: TestType } @@ -95,8 +95,8 @@ export type TestItemMetadata = { export type TestItemMetadataWithRepository = Omit & Required> -export interface MetadataRepositoryInterface { +export interface IMetadataRepository { getMetadata(testItem: vscode.TestItem): TestItemMetadata - getRepository(testItem: vscode.TestItem): TestRepositoryInterface + getRepository(testItem: vscode.TestItem): ITestRepository setMetadata(testItem: vscode.TestItem, metadata: TestItemMetadata): void } diff --git a/packages/vscode-wdio-types/src/utils.ts b/packages/vscode-wdio-types/src/utils.ts index 70fd70d..2d2e330 100644 --- a/packages/vscode-wdio-types/src/utils.ts +++ b/packages/vscode-wdio-types/src/utils.ts @@ -1,9 +1,10 @@ +import type { EventEmitter } from 'node:events' import type { LOG_LEVEL } from '@vscode-wdio/constants' export type { WebdriverIOConfig } from '@vscode-wdio/constants' export type WdioLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' -export interface LoggerInterface { +export interface ILogger { trace(message: unknown): void debug(message: unknown): void info(message: unknown): void @@ -14,3 +15,8 @@ export interface LoggerInterface { type ValueOf = T[keyof T] export type NumericLogLevel = ValueOf + +export interface ITypedEventEmitter> extends EventEmitter { + emit(event: K, data: Events[K]): boolean + on(event: K, listener: (data: Events[K]) => void | Promise): this +} diff --git a/packages/vscode-wdio-types/src/worker.ts b/packages/vscode-wdio-types/src/worker.ts index 2b6b649..8eaf279 100644 --- a/packages/vscode-wdio-types/src/worker.ts +++ b/packages/vscode-wdio-types/src/worker.ts @@ -1,6 +1,6 @@ import type { WebSocket } from 'ws' import type { SourceRange, TestData } from './test.js' -import type { LoggerInterface } from './utils.js' +import type { ILogger } from './utils.js' export type TestCodeParser = (fileContent: string, uri: string) => TestData[] @@ -31,7 +31,7 @@ export type { SourceRange, TestData } export interface WorkerMetaContext { cwd: string - log: LoggerInterface + log: ILogger ws: WebSocket shutdownRequested: boolean pendingCalls: Array<() => void> diff --git a/packages/vscode-wdio-utils/src/index.ts b/packages/vscode-wdio-utils/src/index.ts index 5bc87de..cba5f39 100644 --- a/packages/vscode-wdio-utils/src/index.ts +++ b/packages/vscode-wdio-utils/src/index.ts @@ -1,2 +1,18 @@ +import { EventEmitter } from 'node:events' + +import type { ITypedEventEmitter } from '@vscode-wdio/types' + export * from './normalize.js' export * from './watcher.js' + +export class TypedEventEmitter> + extends EventEmitter + implements ITypedEventEmitter { + emit(event: K, data: Events[K]): boolean { + return super.emit(event as string, data) + } + + on(event: K, listener: (data: Events[K]) => void | Promise): this { + return super.on(event as string, listener) as this + } +} diff --git a/packages/vscode-wdio-worker/src/client.ts b/packages/vscode-wdio-worker/src/client.ts index 69d56f5..d4d0de1 100644 --- a/packages/vscode-wdio-worker/src/client.ts +++ b/packages/vscode-wdio-worker/src/client.ts @@ -6,7 +6,7 @@ import { WebSocket } from 'ws' import { createWorker } from './handler.js' import { getLogger } from './logger.js' import type { NumericLogLevel } from '@vscode-wdio/types' -import type { ExtensionApi, WorkerApi } from '@vscode-wdio/types/api' +import type { ExtensionApi, WorkerApi } from '@vscode-wdio/types/server' export function createRpcClient(cid: string, url: string) { let rpc: ExtensionApi | null = null diff --git a/packages/vscode-wdio-worker/src/handler.ts b/packages/vscode-wdio-worker/src/handler.ts index a18c3f7..94c569f 100644 --- a/packages/vscode-wdio-worker/src/handler.ts +++ b/packages/vscode-wdio-worker/src/handler.ts @@ -3,10 +3,11 @@ import { normalizePath } from '@vscode-wdio/utils/node' import { getLauncherInstance } from './cli.js' import { parse } from './parsers/index.js' import { runTest } from './test.js' -import type { LoadConfigOptions, WdioConfig, WorkerApi } from '@vscode-wdio/types/api' +import type { LoadConfigOptions, WdioConfig, WorkerApi } from '@vscode-wdio/types/server' import type { WorkerMetaContext } from '@vscode-wdio/types/worker' export function createWorker(context: WorkerMetaContext): WorkerApi { + let _shutdownRequest: Promise | null return { /** * Run WebdriverIO tests @@ -31,25 +32,18 @@ export function createWorker(context: WorkerMetaContext): WorkerApi { context.log.info('Shutting down worker process') context.shutdownRequested = true context.log.info('Worker received shutdown request') - - // Implement safe shutdown procedure - try { - // Give pending tasks a chance to complete - if (context.pendingCalls.length > 0) { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - // Close WebSocket connection - context.ws.close() - - // Set safety timeout in case WebSocket doesn't close + if (context.pendingCalls.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + _shutdownRequest = new Promise((resolve) => { + // close after this request is returned. setTimeout(() => { + context.ws.close() + resolve() process.exit(0) - }, 2000) - } catch (error) { - console.error('Error during shutdown:', error) - process.exit(1) - } + }, 500) + }) + context.log.info('Worker shutdown requested!') }, } } diff --git a/packages/vscode-wdio-worker/src/logger.ts b/packages/vscode-wdio-worker/src/logger.ts index 4348a5a..ccdb8c9 100644 --- a/packages/vscode-wdio-worker/src/logger.ts +++ b/packages/vscode-wdio-worker/src/logger.ts @@ -1,15 +1,15 @@ import { LOG_LEVEL } from '@vscode-wdio/constants' -import type { LoggerInterface, NumericLogLevel } from '@vscode-wdio/types' -import type { ExtensionApi } from '@vscode-wdio/types/api' +import type { ILogger, NumericLogLevel } from '@vscode-wdio/types' +import type { ExtensionApi } from '@vscode-wdio/types/server' -const weakLoggers = new WeakMap() +const weakLoggers = new WeakMap() export function getLogger(client: ExtensionApi) { const logger = weakLoggers.get(client) if (logger) { return logger } - class Logger implements LoggerInterface { + class Logger implements ILogger { constructor(private readonly _client: ExtensionApi) {} private log(loglevel: NumericLogLevel, message: unknown) { diff --git a/packages/vscode-wdio-worker/src/parsers/index.ts b/packages/vscode-wdio-worker/src/parsers/index.ts index b39340f..12150dc 100644 --- a/packages/vscode-wdio-worker/src/parsers/index.ts +++ b/packages/vscode-wdio-worker/src/parsers/index.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises' import path from 'node:path' import { getAstParser, getCucumberParser } from './utils.js' -import type { ReadSpecsOptions } from '@vscode-wdio/types/api' +import type { ReadSpecsOptions } from '@vscode-wdio/types/server' import type { WorkerMetaContext } from '@vscode-wdio/types/worker' async function parseFeatureFile(context: WorkerMetaContext, contents: string, normalizeSpecPath: string) { diff --git a/packages/vscode-wdio-worker/src/test.ts b/packages/vscode-wdio-worker/src/test.ts index f71b055..b524376 100644 --- a/packages/vscode-wdio-worker/src/test.ts +++ b/packages/vscode-wdio-worker/src/test.ts @@ -4,9 +4,9 @@ import { dirname, isAbsolute, join, resolve } from 'node:path' import { getLauncherInstance } from './cli.js' import { getTempConfigCreator, isWindows } from './utils.js' -import type { RunTestOptions, TestResultData } from '@vscode-wdio/types/api' import type { ResultSet } from '@vscode-wdio/types/reporter' -import type { LoggerInterface } from '@vscode-wdio/types/utils' +import type { RunTestOptions, TestResultData } from '@vscode-wdio/types/server' +import type { ILogger } from '@vscode-wdio/types/utils' import type { WorkerMetaContext } from '@vscode-wdio/types/worker' import type { RunCommandArguments } from '@wdio/cli' @@ -149,7 +149,7 @@ async function getOutputDir(this: WorkerMetaContext) { } } -async function extractResultJson(log: LoggerInterface, outputDir: string | undefined): Promise { +async function extractResultJson(log: ILogger, outputDir: string | undefined): Promise { if (outputDir) { try { await fs.access(outputDir, fs.constants.R_OK) @@ -174,7 +174,7 @@ async function extractResultJson(log: LoggerInterface, outputDir: string | undef return [] } -async function removeResultDir(log: LoggerInterface, outputDir: string) { +async function removeResultDir(log: ILogger, outputDir: string) { try { log.debug('Remove all files...') await fs.rm(outputDir, { recursive: true, force: true }) diff --git a/packages/vscode-wdio-worker/tests/handler.test.ts b/packages/vscode-wdio-worker/tests/handler.test.ts index b8f23d5..4996471 100644 --- a/packages/vscode-wdio-worker/tests/handler.test.ts +++ b/packages/vscode-wdio-worker/tests/handler.test.ts @@ -4,7 +4,7 @@ import { getLauncherInstance } from '../src/cli.js' import { createWorker } from '../src/handler.js' import * as parsers from '../src/parsers/index.js' import * as test from '../src/test.js' -import type { LoadConfigOptions, WorkerApi } from '@vscode-wdio/types/api' +import type { LoadConfigOptions, WorkerApi } from '@vscode-wdio/types/server' import type { WorkerMetaContext } from '@vscode-wdio/types/worker' import type { WebSocket } from 'ws' @@ -143,11 +143,14 @@ describe('handler', () => { }) it('should close WebSocket connection during shutdown', async () => { + vi.useFakeTimers() + // Act await workerApi.shutdown() - + vi.advanceTimersByTime(1000) // Assert expect(mockContext.ws.close).toHaveBeenCalled() + vi.useRealTimers() }) it('should set a safety timeout during shutdown', async () => { @@ -155,10 +158,10 @@ describe('handler', () => { await workerApi.shutdown() // Assert - expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 2000) + expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 500) // Verify the safety timeout callback will exit with code 0 - const safetyCallback = timeoutCallbacks.find((tc) => tc.ms === 2000)?.callback + const safetyCallback = timeoutCallbacks.find((tc) => tc.ms === 500)?.callback if (safetyCallback) { safetyCallback() expect(mockExit).toHaveBeenCalledWith(0) @@ -167,20 +170,6 @@ describe('handler', () => { } }) - it('should handle errors during shutdown', async () => { - // Arrange - Make ws.close throw an error - mockContext.ws.close = vi.fn().mockImplementation(() => { - throw new Error('WebSocket close error') - }) - - // Act - await workerApi.shutdown() - - // Assert - expect(mockConsoleError).toHaveBeenCalledWith('Error during shutdown:', expect.any(Error)) - expect(mockExit).toHaveBeenCalledWith(1) - }) - describe('loadWdioConfig', () => { // Create a mock context const mockContext: WorkerMetaContext = { diff --git a/packages/vscode-wdio-worker/tests/index.test.ts b/packages/vscode-wdio-worker/tests/index.test.ts index fc4170d..f2d1a4e 100644 --- a/packages/vscode-wdio-worker/tests/index.test.ts +++ b/packages/vscode-wdio-worker/tests/index.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createRpcClient } from '../src/client.js' import { startWorker } from '../src/index.js' -import type { ExtensionApi } from '@vscode-wdio/types/api' -import type { LoggerInterface } from '@vscode-wdio/types/utils' +import type { ExtensionApi } from '@vscode-wdio/types/server' +import type { ILogger } from '@vscode-wdio/types/utils' import type { WebSocket } from 'ws' // Mock the modules @@ -53,7 +53,7 @@ describe('worker/index', () => { vi.mocked(createRpcClient).mockReturnValue({ ws: mockWs as unknown as WebSocket, client: vi.fn() as unknown as ExtensionApi, - log: mockLog as unknown as LoggerInterface, + log: mockLog as unknown as ILogger, }) }) diff --git a/packages/vscode-wdio-worker/tests/logger.test.ts b/packages/vscode-wdio-worker/tests/logger.test.ts index e42c0ad..1e7e4d7 100644 --- a/packages/vscode-wdio-worker/tests/logger.test.ts +++ b/packages/vscode-wdio-worker/tests/logger.test.ts @@ -2,7 +2,7 @@ import { LOG_LEVEL } from '@vscode-wdio/constants' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { getLogger } from '../src/logger.js' -import type { ExtensionApi } from '@vscode-wdio/types/api' +import type { ExtensionApi } from '@vscode-wdio/types/server' describe('Logger', () => { // Mock the ExtensionApi client diff --git a/packages/vscode-wdio-worker/tests/parsers/index.test.ts b/packages/vscode-wdio-worker/tests/parsers/index.test.ts index 2a11eb6..28d15c3 100644 --- a/packages/vscode-wdio-worker/tests/parsers/index.test.ts +++ b/packages/vscode-wdio-worker/tests/parsers/index.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { parse } from '../../src/parsers/index.js' import { getAstParser, getCucumberParser } from '../../src/parsers/utils.js' -import type { ReadSpecsOptions } from '@vscode-wdio/types/api' +import type { ReadSpecsOptions } from '@vscode-wdio/types/server' import type { WorkerMetaContext, TestData, CucumberTestData } from '@vscode-wdio/types/worker' // Mock fs module diff --git a/packages/vscode-webdriverio/package.json b/packages/vscode-webdriverio/package.json index 125630c..9d1fc8e 100644 --- a/packages/vscode-webdriverio/package.json +++ b/packages/vscode-webdriverio/package.json @@ -45,7 +45,7 @@ "clean": "shx rm -rf out dist coverage" }, "devDependencies": { - "@vscode-wdio/api": "workspace:*", + "@vscode-wdio/server": "workspace:*", "@vscode-wdio/config": "workspace:*", "@vscode-wdio/constants": "workspace:*", "@vscode-wdio/logger": "workspace:*", @@ -100,7 +100,12 @@ "default": [ "**/*wdio*.conf*.{ts,js,mjs,cjs,cts,mts}" ], - "description": "Glob pattern for WebdriverIO configuration file" + "markdownDescription": "Glob pattern for WebdriverIO configuration file" + }, + "webdriverio.workerIdleTimeout": { + "markdownDescription": "If no processing is performed in the Worker for the set amount of time(defined by seconds), the Worker is terminated. If processing is requested again, it will be started automatically.", + "type": "number", + "default": 600 }, "webdriverio.logLevel": { "type": "string", @@ -113,12 +118,12 @@ "silent" ], "default": "info", - "description": "Set the logLevel" + "markdownDescription": "Set the logLevel" }, "webdriverio.showOutput": { "type": "boolean", "default": true, - "description": "Show WebdriverIO output in the test result" + "markdownDescription": "Show WebdriverIO output in the test result" } } } diff --git a/packages/vscode-webdriverio/src/extension.ts b/packages/vscode-webdriverio/src/extension.ts index 2bceaf3..fc515ca 100644 --- a/packages/vscode-webdriverio/src/extension.ts +++ b/packages/vscode-webdriverio/src/extension.ts @@ -1,7 +1,7 @@ -import { ServerManager } from '@vscode-wdio/api' import { ConfigFileWatcher, ExtensionConfigManager } from '@vscode-wdio/config' import { EXTENSION_ID } from '@vscode-wdio/constants' import { log } from '@vscode-wdio/logger' +import { WdioWorkerManager } from '@vscode-wdio/server' import { RepositoryManager, TestfileWatcher } from '@vscode-wdio/test' import * as vscode from 'vscode' @@ -25,7 +25,7 @@ class WdioExtension implements vscode.Disposable { constructor( private controller = vscode.tests.createTestController(EXTENSION_ID, 'WebdriverIO'), private configManager = new ExtensionConfigManager(), - private serverManager = new ServerManager(configManager) + private workerManager = new WdioWorkerManager(configManager) ) {} async activate() { @@ -35,14 +35,14 @@ class WdioExtension implements vscode.Disposable { const configPaths = this.configManager.getWdioConfigPaths() // Start worker process asynchronously - const starting = this.serverManager.start(configPaths) + const starting = this.workerManager.start(configPaths) // Create Manages and watchers - const repositoryManager = new RepositoryManager(this.controller, this.configManager, this.serverManager) + const repositoryManager = new RepositoryManager(this.controller, this.configManager, this.workerManager) const testfileWatcher = new TestfileWatcher(repositoryManager) const configFileWatcher = new ConfigFileWatcher( this.configManager, - this.serverManager, + this.workerManager, repositoryManager, testfileWatcher ) @@ -52,7 +52,7 @@ class WdioExtension implements vscode.Disposable { testfileWatcher, configFileWatcher, repositoryManager, - this.serverManager, + this.workerManager, this.configManager, this.controller, ] diff --git a/packages/vscode-webdriverio/tests/extension.test.ts b/packages/vscode-webdriverio/tests/extension.test.ts index 2fefc62..450550e 100644 --- a/packages/vscode-webdriverio/tests/extension.test.ts +++ b/packages/vscode-webdriverio/tests/extension.test.ts @@ -1,6 +1,6 @@ -import { ServerManager } from '@vscode-wdio/api' import { ExtensionConfigManager } from '@vscode-wdio/config' import { log } from '@vscode-wdio/logger' +import { WdioWorkerManager } from '@vscode-wdio/server' import { RepositoryManager } from '@vscode-wdio/test' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import * as vscode from 'vscode' @@ -25,12 +25,12 @@ vi.mock('vscode', async () => { }) vi.mock('@vscode-wdio/logger', () => import('../../../tests/__mocks__/logger.js')) -vi.mock('@vscode-wdio/api', () => { - const ServerManager = vi.fn() - ServerManager.prototype.start = vi.fn(() => Promise.resolve()) - ServerManager.prototype.dispose = vi.fn(() => Promise.resolve()) +vi.mock('@vscode-wdio/server', () => { + const WdioWorkerManager = vi.fn() + WdioWorkerManager.prototype.start = vi.fn(() => Promise.resolve()) + WdioWorkerManager.prototype.dispose = vi.fn(() => Promise.resolve()) - return { ServerManager } + return { WdioWorkerManager } }) vi.mock('@vscode-wdio/config', () => { const ExtensionConfigManager = vi.fn() @@ -95,7 +95,7 @@ describe('extension', () => { // vi.mocked(TestRunner).mock.instances[0].run // expect(vi.mocked(ExtensionConfigManager).mock.instances[0].initialize).toHaveBeenCalled() - expect(vi.mocked(ServerManager).mock.instances[0].start).toHaveBeenCalled() + expect(vi.mocked(WdioWorkerManager).mock.instances[0].start).toHaveBeenCalled() expect(vi.mocked(RepositoryManager).mock.instances[0].initialize).toHaveBeenCalled() expect(vi.mocked(RepositoryManager).mock.instances[0].registerToTestController).toHaveBeenCalled() @@ -106,7 +106,7 @@ describe('extension', () => { it('should handle server start error gracefully', async () => { // Arrange const errorMessage = 'Failed to start server' - vi.mocked(ServerManager.prototype.start).mockRejectedValueOnce(new Error(errorMessage)) + vi.mocked(WdioWorkerManager.prototype.start).mockRejectedValueOnce(new Error(errorMessage)) // Act await activate(fakeContext) @@ -118,9 +118,9 @@ describe('extension', () => { ) }) - it('should continue activation even when serverManager.start rejects', async () => { + it('should continue activation even when workerManager.start rejects', async () => { // Arrange - vi.mocked(ServerManager.prototype.start).mockRejectedValueOnce(new Error('Server failed')) + vi.mocked(WdioWorkerManager.prototype.start).mockRejectedValueOnce(new Error('Server failed')) // Act await activate(fakeContext) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e5c839..ff4ad68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,39 +159,7 @@ importers: specifier: ^7.7.2 version: 7.7.2 - packages/vscode-wdio-api: - dependencies: - '@vscode-wdio/constants': - specifier: workspace:* - version: link:../vscode-wdio-constants - '@vscode-wdio/logger': - specifier: workspace:* - version: link:../vscode-wdio-logger - '@vscode-wdio/utils': - specifier: workspace:* - version: link:../vscode-wdio-utils - birpc: - specifier: ^2.3.0 - version: 2.4.0 - get-port: - specifier: ^7.1.0 - version: 7.1.0 - which: - specifier: ^5.0.0 - version: 5.0.0 - ws: - specifier: ^8.18.1 - version: 8.18.2 - devDependencies: - '@types/which': - specifier: ^3.0.4 - version: 3.0.4 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 - '@vscode-wdio/types': - specifier: workspace:* - version: link:../vscode-wdio-types + infra/xvfb-patch: {} packages/vscode-wdio-config: dependencies: @@ -240,11 +208,42 @@ importers: specifier: ^9.13.0 version: 9.15.0 - packages/vscode-wdio-test: + packages/vscode-wdio-server: dependencies: - '@vscode-wdio/api': + '@vscode-wdio/constants': specifier: workspace:* - version: link:../vscode-wdio-api + version: link:../vscode-wdio-constants + '@vscode-wdio/logger': + specifier: workspace:* + version: link:../vscode-wdio-logger + '@vscode-wdio/utils': + specifier: workspace:* + version: link:../vscode-wdio-utils + birpc: + specifier: ^2.3.0 + version: 2.4.0 + get-port: + specifier: ^7.1.0 + version: 7.1.0 + which: + specifier: ^5.0.0 + version: 5.0.0 + ws: + specifier: ^8.18.1 + version: 8.18.2 + devDependencies: + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vscode-wdio/types': + specifier: workspace:* + version: link:../vscode-wdio-types + + packages/vscode-wdio-test: + dependencies: '@vscode-wdio/config': specifier: workspace:* version: link:../vscode-wdio-config @@ -254,6 +253,9 @@ importers: '@vscode-wdio/logger': specifier: workspace:* version: link:../vscode-wdio-logger + '@vscode-wdio/server': + specifier: workspace:* + version: link:../vscode-wdio-server '@vscode-wdio/utils': specifier: workspace:* version: link:../vscode-wdio-utils @@ -327,9 +329,6 @@ importers: packages/vscode-webdriverio: devDependencies: - '@vscode-wdio/api': - specifier: workspace:* - version: link:../vscode-wdio-api '@vscode-wdio/config': specifier: workspace:* version: link:../vscode-wdio-config @@ -342,6 +341,9 @@ importers: '@vscode-wdio/reporter': specifier: workspace:* version: link:../vscode-wdio-reporter + '@vscode-wdio/server': + specifier: workspace:* + version: link:../vscode-wdio-server '@vscode-wdio/test': specifier: workspace:* version: link:../vscode-wdio-test