import os from 'node:os'
import url from 'node:url'
import path from 'node:path'
import chokidar from 'chokidar'
import EventEmitter from 'node:events'
import { vi, describe, it, expect, afterEach } from 'vitest'
import type { Options, Workers } from '@wdio/types'
import type { RunCommandArguments } from '../src/types.js'
import Watcher from '../src/watcher.js'
vi.mock('chokidar')
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
vi.mock('@wdio/config', () => import(path.join(process.cwd(), '__mocks__', '@wdio/config')))
vi.mock('@wdio/utils', () => import(path.join(process.cwd(), '__mocks__', '@wdio/utils')))
vi.mock('../src/launcher', async () => {
const { ConfigParser } = await import('@wdio/config/node')
interface LauncherMockRunCommandArguments extends Omit<RunCommandArguments, 'configPath'> {
isMultiremote?: boolean;
}
class LauncherMock {
public configParser: any
isMultiremote: boolean
runner: any
interface: any
initialize = vi.fn()
constructor (configFile: string, args: LauncherMockRunCommandArguments) {
this.configParser = new ConfigParser(configFile)
this.configParser.addConfigFile(configFile)
this.configParser.merge(args)
this.isMultiremote = args.isMultiremote || false
this.runner = {}
this.interface = {
emit: vi.fn(),
setup: vi.fn()
}
}
}
return { default: LauncherMock }
})
interface WorkerMockRunPayload extends Partial<Workers.WorkerRunPayload> {
isBusy?: boolean;
sessionId?: string;
specs: string[];
}
class WorkerMock extends EventEmitter implements Workers.Worker {
cid: string
specs: any
caps: WebdriverIO.Capabilities
config: Options.Testrunner
capabilities: WebdriverIO.Capabilities
sessionId: string
isBusy: boolean
postMessage = vi.fn()
constructor ({ cid, specs, sessionId, isBusy = false }: WorkerMockRunPayload) {
super()
this.cid = cid || `${Math.random()}`
this.specs = specs.map((spec) => Array.isArray(spec)
? spec.map((s) => url.pathToFileURL(s).href)
: url.pathToFileURL(spec).href
)
this.caps = { browserName: 'chrome' }
this.config = { baseUrl: 'http://localhost:1234' } as any
this.capabilities = this.caps
this.sessionId = sessionId || `${Math.random()}`
this.isBusy = isBusy
this.on = vi.fn()
}
logsAggregator: string[]
execArgv?: string[] | undefined
onWorkerStart?(cid: string, capabilities: WebdriverIO.Capabilities, specs: string[], args: Options.Testrunner, execArgv: string[]): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
onWorkerEnd?(cid: string, exitCode: number, specs: string[], retries: number): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
afterSession?(config: Options.Testrunner, capabilities: WebdriverIO.Capabilities, specs: string[]): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
onReload?(oldSessionId: string, newSessionId: string): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
beforeHook?(test: any, context: any, hookName: string): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
beforeCommand?(commandName: string, args: any[]): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
afterCommand?(commandName: string, args: any[], result: any, error?: Error): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
beforeAssertion?(params: AssertionHookParams): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
afterAssertion?(params: AfterAssertionHookParams): unknown | Promise<unknown> {
throw new Error('Method not implemented.')
}
runner?: 'local' | 'browser' | ['browser', WebdriverIO.BrowserRunnerOptions] | ['local', never] | undefined
exclude?: string[] | undefined
suites?: Record<string, (string | string[])[] | string[][]> | undefined
maxInstances?: number | undefined
maxInstancesPerCapability?: number | undefined
injectGlobals?: boolean | undefined
bail?: number | undefined
updateSnapshots?: 'all' | 'new' | 'none' | undefined
resolveSnapshotPath?: ((testPath: string, snapExtension: string) => string) | undefined
specFileRetries?: number | undefined
specFileRetriesDelay?: number | undefined
specFileRetriesDeferred?: boolean | undefined
groupLogsByTestSpec?: boolean | undefined
services?
framework?: string | undefined
reporters?
reporterSyncInterval?: number | undefined
reporterSyncTimeout?: number | undefined
runnerEnv?: Record<string, string> | undefined
filesToWatch?: string[] | undefined
cucumberFeaturesWithLineNumbers?: string[] | undefined
watch?: boolean | undefined
shard?: Options.ShardOptions | undefined
mochaOpts?: WebdriverIO.MochaOpts | undefined
jasmineOpts?: WebdriverIO.JasmineOpts | undefined
cucumberOpts?: WebdriverIO.CucumberOpts | undefined
tsConfigPath?: string | undefined
automationProtocol?: string | undefined
region?: Options.SauceRegions | undefined
baseUrl?: string | undefined
waitforTimeout?: number | undefined
waitforInterval?: number | undefined
logLevel?: Options.WebDriverLogTypes | undefined
logLevels?: Record<string, Options.WebDriverLogTypes> | undefined
connectionRetryTimeout?: number | undefined
connectionRetryCount?: number | undefined
headers?: { [name: string]: string } | undefined
transformRequest?: ((requestOptions: RequestInit) => RequestInit) | undefined
transformResponse?: ((response: Options.RequestLibResponse, requestOptions: RequestInit) => Options.RequestLibResponse) | undefined
enableDirectConnect?: boolean | undefined
strictSSL?: boolean | undefined
outputDir?: string | undefined
cacheDir?: string | undefined
protocol?: string | undefined
hostname?: string | undefined
port?: number | undefined
path?: string | undefined
queryParams?: { [name: string]: string } | undefined
user?: string | undefined
key?: string | undefined
}
describe('watcher', () => {
it('should initialize properly', async () => {
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
expect(watcher['_launcher']).toBeDefined()
})
it('should run initial suite when starting watching', async () => {
if (os.platform() === 'win32') {
return
}
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'] = {
run: vi.fn(),
initialize: vi.fn(),
interface: {
finalise: vi.fn()
},
configParser: {
getConfig: vi.fn().mockReturnValue({ filesToWatch: ['./foobar'] }),
initialize: vi.fn(),
getSpecs: vi.fn().mockReturnValue(['file:///foo', 'file:///bar']),
getCapabilities: vi.fn().mockReturnValue([{ browserName: 'chrome' }])
},
runner: {
workerPool: {
'0-0': new WorkerMock({ specs: ['./tests/test1.js'] })
}
}
} as any
await watcher.watch()
expect(chokidar.watch).toHaveBeenCalledTimes(2)
expect(chokidar.watch).toBeCalledWith(['/foo', '/bar'], expect.any(Object))
expect(chokidar.watch).toBeCalledWith([expect.stringContaining('/foobar')], expect.any(Object))
})
it('should run initial suite when starting watching with grouped specs', async () => {
if (os.platform() === 'win32') {
return
}
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'] = {
run: vi.fn(),
initialize: vi.fn(),
interface: {
finalise: vi.fn()
},
configParser: {
getConfig: vi.fn().mockReturnValue({ filesToWatch: [] }),
initialize: vi.fn(),
getSpecs: vi.fn().mockReturnValue(['file:///foo', 'file:///bar']),
getCapabilities: vi.fn().mockReturnValue([{ browserName: 'chrome' }])
},
runner: {
workerPool: {
'0-0': new WorkerMock(<WorkerMockRunPayload>{ specs: ['/a.js', ['/b.js', '/c.js', '/d.js'], 'e.js'] })
}
}
} as any
await watcher.watch()
expect(chokidar.watch).toHaveBeenCalledTimes(1)
})
it('should run also watch `filesToWatch` files', async () => {
if (os.platform() === 'win32') {
return
}
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'] = {
run: vi.fn(),
initialize: vi.fn(),
interface: {
finalise: vi.fn()
},
configParser: {
getConfig: vi.fn().mockReturnValue({ filesToWatch: ['/foo/bar'] }),
initialize: vi.fn(),
getSpecs: vi.fn().mockReturnValue(['file:///foo', 'file:///bar']),
getCapabilities: vi.fn().mockReturnValue([{ browserName: 'chrome' }])
},
runner: {
workerPool: {
'0-0': new WorkerMock({ specs: ['./tests/test1.js'] })
}
}
} as any
await watcher.watch()
expect(chokidar.watch).toHaveBeenCalledTimes(2)
const worker = watcher['_launcher'].runner!.workerPool['0-0']
expect(worker.on).toBeCalledTimes(1)
const eventHandler = worker.on.mock.calls[0][1]
expect(watcher['_launcher'].interface!.finalise).toBeCalledTimes(0)
worker.isBusy = true
eventHandler()
expect(watcher['_launcher'].interface!.finalise).toBeCalledTimes(0)
worker.isBusy = false
eventHandler()
expect(watcher['_launcher'].interface!.finalise).toBeCalledTimes(1)
})
it('should call run with modifed path when a new file was changed or added', async () => {
if (os.platform() === 'win32') {
return
}
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'] = {
run: vi.fn(),
initialize: vi.fn(),
interface: {
finalise: vi.fn()
},
configParser: {
getConfig: vi.fn().mockReturnValue({ filesToWatch: ['/foo/bar'] }),
initialize: vi.fn(),
getSpecs: vi.fn().mockReturnValue(['file:///foo', 'file:///bar']),
getCapabilities: vi.fn().mockReturnValue([{ browserName: 'chrome' }])
},
runner: {
workerPool: {
'0-0': new WorkerMock({ specs: ['./tests/test1.js'] })
}
}
} as any
watcher.run = vi.fn()
await watcher.watch()
vi.mocked(chokidar.on).mock.calls[0][1]('/some/path.js')
vi.mocked(chokidar.on).mock.calls[1][1]('/some/other/path.js')
vi.mocked(chokidar.on).mock.calls[2][1]('/some/another/path.js')
vi.mocked(chokidar.on).mock.calls[3][1]('/some/another/path.js')
expect(watcher.run).toHaveBeenNthCalledWith(1, { spec: ['file:///some/path.js'] })
expect(watcher.run).toHaveBeenNthCalledWith(2, { spec: ['file:///some/other/path.js'] })
expect(watcher.run).toHaveBeenNthCalledWith(3, {})
expect(watcher.run).toHaveBeenNthCalledWith(4, {})
})
it('should get workers by pickBy function', () => {
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
const workerPool = {
'0-0': new WorkerMock({ cid: '0-0', specs: ['file:///foo/bar.js'] }),
'0-1': new WorkerMock({ cid: '0-1', specs: ['file:///foo/bar2.js'], isBusy: true }),
'1-0': new WorkerMock({ cid: '1-0', specs: ['file:///bar/foo.js'] })
}
watcher['_launcher'].runner!.workerPool = workerPool
expect(watcher.getWorkers(null, true)).toEqual(workerPool)
expect(watcher.getWorkers()).toEqual({
'0-0': workerPool['0-0'],
'1-0': workerPool['1-0']
})
expect(watcher.getWorkers(
(worker: Workers.Worker) => worker.specs.find((spec) => spec.endsWith('/bar/foo.js')))
).toEqual({ '1-0': workerPool['1-0'] })
})
it('should run workers on existing session', () => {
if (os.platform() === 'win32') {
return
}
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'].runner!.workerPool = {
'0-0': new WorkerMock({ cid: '0-0', specs: ['/foo/bar.js'] }),
'0-1': new WorkerMock({ cid: '0-1', specs: ['/foo/bar2.js'], isBusy: true }),
'1-0': new WorkerMock({ cid: '1-0', specs: ['/bar/foo.js'] })
}
watcher['_launcher'].interface!.emit = vi.fn()
watcher.run({ spec: 'file:///foo/bar.js' } as any)
expect(watcher['_launcher'].interface!.emit).toHaveBeenCalledWith('job:start', {
cid: '0-0',
caps: { browserName: 'chrome' },
specs: ['file:///foo/bar.js']
})
const { postMessage, sessionId } = watcher['_launcher'].runner!.workerPool['0-0']
expect(postMessage).toHaveBeenCalledWith('run', {
sessionId,
spec: 'file:///foo/bar.js',
baseUrl: 'http://localhost:1234'
})
expect(watcher['_launcher'].interface!.totalWorkerCnt).toBe(1)
})
it('should not clean if no watcher is running', () => {
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'].runner!.workerPool = {
'0-0': new WorkerMock({ cid: '0-0', specs: ['/foo/bar.js'] }),
'0-1': new WorkerMock({ cid: '0-1', specs: ['/foo/bar2.js'], isBusy: true }),
'1-0': new WorkerMock({ cid: '1-0', specs: ['/bar/foo.js'] })
}
watcher['_launcher'].interface!.emit = vi.fn()
watcher.run({ spec: '/foo/bar2.js' } as any)
expect(watcher['_launcher'].interface!.emit).toHaveBeenCalledTimes(0)
})
it('should run all tests if `filesToWatch` entry was changed', () => {
if (os.platform() === 'win32') {
return
}
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'].interface!.totalWorkerCnt = 1
watcher.cleanUp = vi.fn()
watcher['_launcher'].runner!.workerPool = {
'0-0': new WorkerMock({ cid: '0-0', specs: ['/foo/bar.js'] }),
'0-1': new WorkerMock({ cid: '0-1', specs: ['/foo/bar2.js'], isBusy: true }),
'1-0': new WorkerMock({ cid: '1-0', specs: ['/bar/foo.js'] })
}
watcher.run()
expect(watcher['_launcher'].interface!.totalWorkerCnt).toBe(2)
const worker00 = watcher['_launcher'].runner!.workerPool['0-0']
expect(worker00.postMessage).toHaveBeenCalledWith(
'run',
{ sessionId: worker00.sessionId, baseUrl: 'http://localhost:1234' })
expect(watcher['_launcher'].interface!.emit).toHaveBeenCalledWith('job:start', {
cid: '0-0',
caps: { browserName: 'chrome' },
specs: ['file:///foo/bar.js'] })
const worker10 = watcher['_launcher'].runner!.workerPool['0-0']
expect(worker10.postMessage).toHaveBeenCalledWith(
'run',
{ sessionId: worker10.sessionId, baseUrl: 'http://localhost:1234' })
expect(watcher['_launcher'].interface!.emit).toHaveBeenCalledWith('job:start', {
cid: '1-0',
caps: { browserName: 'chrome' },
specs: ['file:///bar/foo.js'] })
})
it('should re-run all specs when the --spec command line option is set and a filesToWatch file is added or changed', async () => {
if (os.platform() === 'win32') {
return
}
const spec = ['/some/path.js', '/some/other/path.js']
const someOtherExcludedPath = '/some/other/excluded/path.js'
const filesToWatch = ['/some/another/path.js']
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'] = {
__args: { spec },
run: vi.fn(),
initialize: vi.fn(),
interface: {
emit: vi.fn(),
finalise: vi.fn()
},
configParser: {
getConfig: vi.fn().mockReturnValue({ filesToWatch }),
initialize: vi.fn(),
getSpecs: vi.fn().mockReturnValue(['file:///foo', 'file:///bar']),
getCapabilities: vi.fn().mockReturnValue([{ browserName: 'chrome' }])
},
runner: {
workerPool: {
'0-0': new WorkerMock({ cid: '0-0', specs: [spec[0]] }),
'0-1': new WorkerMock({ cid: '0-1', specs: [spec[1]] })
}
}
} as any
const runSpy = vi.spyOn(watcher, 'run')
const emitSpy = watcher['_launcher'].interface!.emit
watcher.cleanUp = vi.fn()
await watcher.watch()
vi.mocked(chokidar.on).mock.calls[0][1](spec[0])
expect(runSpy).toHaveBeenNthCalledWith(1, { spec: [`file://${spec[0]}`] })
expect(emitSpy).toHaveBeenCalledTimes(1)
vi.mocked(emitSpy).mockClear()
vi.mocked(chokidar.on).mock.calls[1][1](someOtherExcludedPath)
expect(runSpy).toHaveBeenNthCalledWith(2, { spec: [`file://${someOtherExcludedPath}`] })
expect(emitSpy).not.toHaveBeenCalled()
vi.mocked(emitSpy).mockClear()
vi.mocked(chokidar.on).mock.calls[2][1](filesToWatch[0])
expect(runSpy).toHaveBeenNthCalledWith(3, {})
expect(emitSpy).toHaveBeenCalledTimes(2)
vi.mocked(emitSpy).mockClear()
vi.mocked(chokidar.on).mock.calls[3][1](filesToWatch[0])
expect(runSpy).toHaveBeenNthCalledWith(4, {})
expect(emitSpy).toHaveBeenCalledTimes(2)
})
it('should re-run all specs, with grouped specs, when the --spec command line option is set and a filesToWatch file is added or changed', async () => {
if (os.platform() === 'win32') {
return
}
const spec = ['/a.js', ['/b.js', '/c.js', '/d.js']]
const someOtherExcludedPath = '/some/other/excluded/path.js'
const filesToWatch = ['/some/another/path.js']
const wdioConf = path.join(__dirname, '__fixtures__', 'wdio.conf')
const watcher = new Watcher(wdioConf, {})
watcher['_launcher'] = {
__args: { spec },
run: vi.fn(),
initialize: vi.fn(),
interface: {
emit: vi.fn(),
finalise: vi.fn()
},
configParser: {
getConfig: vi.fn().mockReturnValue({ filesToWatch }),
initialize: vi.fn(),
getSpecs: vi.fn().mockReturnValue(['file:///foo', 'file:///bar']),
getCapabilities: vi.fn().mockReturnValue([{ browserName: 'chrome' }])
},
runner: {
workerPool: {
'0-0': new WorkerMock({ cid: '0-0', specs: [spec[0] as any] }),
'0-1': new WorkerMock({ cid: '0-1', specs: spec[1] as any })
}
}
} as any
const runSpy = vi.spyOn(watcher, 'run')
const emitSpy = watcher['_launcher'].interface!.emit
watcher.cleanUp = vi.fn()
await watcher.watch()
vi.mocked(chokidar.on).mock.calls[0][1](spec[0])
expect(runSpy).toHaveBeenNthCalledWith(1, { spec: [`file://${spec[0]}`] })
expect(emitSpy).toHaveBeenCalledTimes(1)
vi.mocked(emitSpy).mockClear()
vi.mocked(chokidar.on).mock.calls[1][1](someOtherExcludedPath)
expect(runSpy).toHaveBeenNthCalledWith(2, { spec: [`file://${someOtherExcludedPath}`] })
expect(emitSpy).not.toHaveBeenCalled()
vi.mocked(emitSpy).mockClear()
vi.mocked(chokidar.on).mock.calls[2][1](filesToWatch[0])
expect(runSpy).toHaveBeenNthCalledWith(3, {})
expect(emitSpy).toHaveBeenCalledTimes(2)
vi.mocked(emitSpy).mockClear()
vi.mocked(chokidar.on).mock.calls[3][1](filesToWatch[0])
expect(runSpy).toHaveBeenNthCalledWith(4, {})
expect(emitSpy).toHaveBeenCalledTimes(2)
})
afterEach(() => {
vi.mocked(chokidar.watch).mockClear()
chokidar.on.mockClear()
})
})