Content
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9bffcbf..3781fd5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,8 @@ jobs:
run_install: false
- name: Install dependencies
run: pnpm install
+ - name: Lint
+ run: pnpm lint
- name: ๐ฆ Bundle
run: pnpm -r --workspace-concurrency=1 build
- name: ๐งช Run Tests
diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts
index 1c1e77a..e790c82 100644
--- a/example/wdio.conf.ts
+++ b/example/wdio.conf.ts
@@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
capabilities: [
{
browserName: 'chrome',
- browserVersion: '143.0.7499.169', // specify chromium browser version for testing
+ browserVersion: '143.0.7499.193', // specify chromium browser version for testing
'goog:chromeOptions': {
args: [
'--headless',
diff --git a/package.json b/package.json
index 1751be0..aa19ec0 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,12 @@
"demo": "wdio run ./example/wdio.conf.ts",
"dev": "pnpm --parallel dev",
"preview": "pnpm --parallel preview",
- "test": "pnpm --parallel test",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "test:ui": "vitest --ui",
+ "test:debug": "node --inspect-brk ./node_modules/.bin/vitest run",
+ "lint": "pnpm --parallel lint",
"watch": "pnpm build --watch"
},
"pnpm": {
@@ -26,6 +31,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^62.0.0",
+ "happy-dom": "^20.0.11",
"npm-run-all": "^4.1.5",
"postcss": "^8.5.6",
"postcss-lit": "^1.2.0",
diff --git a/packages/app/package.json b/packages/app/package.json
index f19871b..d5496df 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -13,7 +13,7 @@
"dev": "vite build --watch",
"build": "tsc && vite build",
"preview": "vite preview",
- "test": "eslint ."
+ "lint": "eslint ."
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
diff --git a/packages/app/src/components/workbench/actionItems/item.ts b/packages/app/src/components/workbench/actionItems/item.ts
index 08fdf54..f778cb1 100644
--- a/packages/app/src/components/workbench/actionItems/item.ts
+++ b/packages/app/src/components/workbench/actionItems/item.ts
@@ -40,7 +40,7 @@ export class ActionItem extends Element {
return html`
${diffLabel}
`
diff --git a/packages/app/src/tailwind.css b/packages/app/src/tailwind.css
index 80a7a4b..92c8208 100644
--- a/packages/app/src/tailwind.css
+++ b/packages/app/src/tailwind.css
@@ -15,4 +15,5 @@
--color-chartsYellow: var(--vscode-charts-yellow);
--color-chartsBlue: var(--vscode-charts-blue);
--color-input-background: var(--vscode-input-background);
+ --color-debugTokenExpressionName: var(--vscode-debugTokenExpression-name);
}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 26bcd24..c6a51a9 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -20,7 +20,7 @@
"dev:ts": "tsc --watch",
"dev:app": "nodemon --watch ./dist ./dist/index.js",
"build": "tsc -p ./tsconfig.json",
- "test": "eslint ."
+ "lint": "eslint ."
},
"dependencies": {
"@fastify/static": "^9.0.0",
diff --git a/packages/backend/tests/index.test.ts b/packages/backend/tests/index.test.ts
new file mode 100644
index 0000000..7e7d0a7
--- /dev/null
+++ b/packages/backend/tests/index.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { start } from '../src/index.js'
+import * as utils from '../src/utils.js'
+
+vi.mock('../src/utils.js', () => ({
+ getDevtoolsApp: vi.fn()
+}))
+
+vi.mock('ws')
+
+describe('backend index', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ // Clean up any running servers
+ })
+
+ describe('start', () => {
+ it('should handle start errors', async () => {
+ vi.mocked(utils.getDevtoolsApp).mockRejectedValue(
+ new Error('Package not found')
+ )
+ await expect(start()).rejects.toThrow('Package not found')
+ })
+ })
+
+ describe('API endpoints', () => {
+ it('should handle test run and stop requests with validation', async () => {
+ vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
+ const server = await start({ port: 0 })
+ const { testRunner } = await import('../src/runner.js')
+ const runSpy = vi.spyOn(testRunner, 'run').mockResolvedValue()
+ const stopSpy = vi.spyOn(testRunner, 'stop')
+
+ // Test invalid payload - missing uid
+ const invalidResponse = await server?.inject({
+ method: 'POST',
+ url: '/api/tests/run',
+ payload: { entryType: 'test' }
+ })
+ expect(invalidResponse?.statusCode).toBe(400)
+ expect(JSON.parse(invalidResponse?.body || '{}')).toEqual({
+ error: 'Invalid run payload'
+ })
+ expect(runSpy).not.toHaveBeenCalled()
+
+ // Test valid run request with all parameters
+ const runPayload = {
+ uid: 'test-123',
+ entryType: 'test',
+ specFile: '/test.spec.ts'
+ }
+ const runResponse = await server?.inject({
+ method: 'POST',
+ url: '/api/tests/run',
+ payload: runPayload
+ })
+ expect(runResponse?.statusCode).toBe(200)
+ expect(JSON.parse(runResponse?.body || '{}')).toEqual({ ok: true })
+ expect(runSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uid: 'test-123',
+ entryType: 'test',
+ specFile: '/test.spec.ts',
+ devtoolsHost: expect.any(String),
+ devtoolsPort: expect.any(Number)
+ })
+ )
+
+ // Test stop request
+ const stopResponse = await server?.inject({
+ method: 'POST',
+ url: '/api/tests/stop'
+ })
+ expect(stopResponse?.statusCode).toBe(200)
+ expect(JSON.parse(stopResponse?.body || '{}')).toEqual({ ok: true })
+ expect(stopSpy).toHaveBeenCalled()
+
+ await server?.close()
+ })
+
+ it('should handle test run errors gracefully', async () => {
+ vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
+ const server = await start({ port: 0 })
+ const { testRunner } = await import('../src/runner.js')
+ vi.spyOn(testRunner, 'run').mockRejectedValue(
+ new Error('Test execution failed')
+ )
+
+ const response = await server?.inject({
+ method: 'POST',
+ url: '/api/tests/run',
+ payload: {
+ uid: 'test-456',
+ entryType: 'test',
+ specFile: '/test.spec.ts'
+ }
+ })
+
+ expect(response?.statusCode).toBe(500)
+ expect(JSON.parse(response?.body || '{}')).toEqual({
+ error: 'Test execution failed'
+ })
+
+ await server?.close()
+ })
+ })
+})
diff --git a/packages/backend/tests/runner.test.ts b/packages/backend/tests/runner.test.ts
new file mode 100644
index 0000000..0b1c7a3
--- /dev/null
+++ b/packages/backend/tests/runner.test.ts
@@ -0,0 +1,280 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { spawn } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import type { RunnerRequestBody } from '../src/types.js'
+
+vi.mock('node:child_process')
+vi.mock('tree-kill')
+vi.mock('node:fs', () => ({
+ default: {
+ existsSync: vi.fn().mockReturnValue(true),
+ readFileSync: vi.fn()
+ }
+}))
+
+// Mock the module resolution to prevent resolveWdioBin from failing during import
+vi.mock('node:module', () => ({
+ createRequire: () => ({
+ resolve: () => '/mock/wdio/cli/index.js'
+ })
+}))
+
+// Now import after mocks are set up
+const { testRunner } = await import('../src/runner.js')
+
+describe('TestRunner', () => {
+ const mockConfigPath = '/test/wdio.conf.ts'
+ const mockSpecFile = '/test/specs/test.spec.ts'
+ const mockChild = {
+ once: vi.fn((event: string, callback: (err?: Error) => void) => {
+ if (event === 'spawn') {
+ setTimeout(() => callback(), 0)
+ }
+ }),
+ pid: 12345
+ } as any
+
+ const createMockChild = (spawnCallback = true, errorCallback = false) =>
+ ({
+ once: vi.fn((event, callback) => {
+ if (event === 'spawn' && spawnCallback) {
+ callback()
+ }
+ if (event === 'error' && errorCallback) {
+ callback(new Error('Spawn failed'))
+ }
+ }),
+ pid: 12345
+ }) as any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(fs.existsSync).mockReturnValue(true)
+ process.env.DEVTOOLS_RUNNER_CWD = ''
+ process.env.DEVTOOLS_WDIO_CONFIG = ''
+ process.env.DEVTOOLS_WDIO_BIN = ''
+ })
+
+ afterEach(() => {
+ testRunner.stop()
+ })
+
+ describe('framework filters', () => {
+ beforeEach(() => {
+ vi.mocked(spawn).mockReturnValue(mockChild)
+ })
+
+ it('should apply correct filters for cucumber, mocha, and jasmine frameworks', async () => {
+ const frameworks = [
+ { name: 'cucumber', flag: '--cucumberOpts.name' },
+ { name: 'mocha', flag: '--mochaOpts.grep' },
+ { name: 'jasmine', flag: '--jasmineOpts.grep' }
+ ]
+
+ for (let i = 0; i < frameworks.length; i++) {
+ const { name, flag } = frameworks[i]
+ await testRunner.run({
+ uid: `test-${i + 1}`,
+ entryType: 'test',
+ framework: name as any,
+ fullTitle: `${name} test`,
+ specFile: mockSpecFile,
+ configFile: mockConfigPath
+ })
+ expect(vi.mocked(spawn).mock.calls[i][1]).toEqual(
+ expect.arrayContaining([flag])
+ )
+ testRunner.stop()
+ }
+ })
+ })
+
+ describe('run and stop', () => {
+ it('should prevent concurrent runs and handle environment variables', async () => {
+ vi.mocked(spawn).mockReturnValue(mockChild)
+ const payload: RunnerRequestBody = {
+ uid: 'test-1',
+ entryType: 'test',
+ configFile: mockConfigPath,
+ devtoolsHost: 'localhost',
+ devtoolsPort: 3000
+ }
+
+ const firstRun = testRunner.run(payload)
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ await expect(testRunner.run(payload)).rejects.toThrow(
+ 'A test run is already in progress'
+ )
+
+ const env = vi.mocked(spawn).mock.calls[0][2]?.env as Record<
+ string,
+ string
+ >
+ expect(env.DEVTOOLS_APP_HOST).toBe('localhost')
+ expect(env.DEVTOOLS_APP_PORT).toBe('3000')
+ expect(env.DEVTOOLS_APP_REUSE).toBe('1')
+
+ testRunner.stop()
+ await firstRun.catch(() => {})
+ })
+
+ it('should handle spawn errors', async () => {
+ const errorChild = createMockChild(false, true)
+ vi.mocked(spawn).mockReturnValue(errorChild)
+
+ await expect(
+ testRunner.run({
+ uid: 'test-1',
+ entryType: 'test',
+ configFile: mockConfigPath
+ })
+ ).rejects.toThrow('Spawn failed')
+ })
+ })
+
+ describe('configuration', () => {
+ it('should find config and use environment variables', async () => {
+ const specFile = '/project/test/specs/test.spec.ts'
+ const configInTestDir = '/project/test/wdio.conf.ts'
+ const envConfig = '/custom/wdio.conf.ts'
+
+ const mockChild = {
+ once: vi.fn((event, callback) => {
+ if (event === 'spawn') {
+ callback()
+ }
+ }),
+ pid: 12345
+ } as any
+
+ vi.mocked(spawn).mockReturnValue(mockChild)
+
+ // Test with spec file location
+ vi.mocked(fs.existsSync).mockImplementation(
+ (path) => path === configInTestDir
+ )
+ await testRunner.run({ uid: 'test-1', entryType: 'test', specFile })
+ expect(vi.mocked(spawn).mock.calls[0][1]).toContain(configInTestDir)
+ testRunner.stop()
+
+ // Test with env variable
+ process.env.DEVTOOLS_WDIO_CONFIG = envConfig
+ vi.mocked(fs.existsSync).mockImplementation((path) => path === envConfig)
+ await testRunner.run({ uid: 'test-2', entryType: 'test' })
+ expect(vi.mocked(spawn).mock.calls[1][1]).toContain(envConfig)
+ testRunner.stop()
+ })
+
+ it('should throw error if config cannot be found', async () => {
+ vi.mocked(fs.existsSync).mockReturnValue(false)
+
+ const payload: RunnerRequestBody = {
+ uid: 'test-1',
+ entryType: 'test'
+ }
+
+ const mockChild = {
+ once: vi.fn(),
+ pid: 12345
+ } as any
+
+ vi.mocked(spawn).mockReturnValue(mockChild)
+
+ await expect(testRunner.run(payload)).rejects.toThrow(
+ 'Cannot locate WDIO config'
+ )
+ })
+ })
+
+ describe('spec file normalization', () => {
+ it('should handle file:// URLs', async () => {
+ const payload: RunnerRequestBody = {
+ uid: 'test-1',
+ entryType: 'test',
+ specFile: 'file:///project/test.spec.ts',
+ configFile: mockConfigPath
+ }
+
+ vi.mocked(spawn).mockReturnValue(createMockChild())
+ await testRunner.run(payload)
+
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[]
+ expect(args.some((arg) => arg.includes('/project/test.spec.ts'))).toBe(
+ true
+ )
+ })
+
+ it('should extract spec from callSource', async () => {
+ vi.mocked(spawn).mockReturnValue(createMockChild())
+ await testRunner.run({
+ uid: 'test-1',
+ entryType: 'test',
+ callSource: '/project/test.spec.ts:10:5',
+ configFile: mockConfigPath
+ })
+ expect(spawn).toHaveBeenCalled()
+ })
+
+ it('should resolve relative paths', async () => {
+ vi.mocked(spawn).mockReturnValue(createMockChild())
+ await testRunner.run({
+ uid: 'test-1',
+ entryType: 'test',
+ specFile: 'test/test.spec.ts',
+ configFile: mockConfigPath
+ })
+
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[]
+ expect(
+ args.some((arg) => arg.startsWith('/') || path.isAbsolute(arg))
+ ).toBe(true)
+ })
+ })
+
+ describe('line number resolution', () => {
+ it('should use lineNumber from payload', async () => {
+ vi.mocked(spawn).mockReturnValue(createMockChild())
+ await testRunner.run({
+ uid: 'test-1',
+ entryType: 'test',
+ specFile: mockSpecFile,
+ lineNumber: 42,
+ configFile: mockConfigPath
+ })
+
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[]
+ expect(args.some((arg) => arg.includes(':42'))).toBe(true)
+ })
+
+ it('should extract line number from callSource', async () => {
+ vi.mocked(spawn).mockReturnValue(createMockChild())
+ await testRunner.run({
+ uid: 'test-1',
+ entryType: 'test',
+ specFile: mockSpecFile,
+ callSource: '/project/test.spec.ts:25:10',
+ configFile: mockConfigPath
+ })
+
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[]
+ expect(args.some((arg) => arg.includes(':25'))).toBe(true)
+ })
+ })
+
+ describe('runAll mode', () => {
+ it('should not use spec filter when runAll is true', async () => {
+ vi.mocked(spawn).mockReturnValue(createMockChild())
+ await testRunner.run({
+ uid: 'run-all',
+ entryType: 'suite',
+ runAll: true,
+ configFile: mockConfigPath
+ })
+
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[]
+ expect(args).not.toContain('--spec')
+ })
+ })
+})
diff --git a/packages/script/package.json b/packages/script/package.json
index a53c843..33c47e2 100644
--- a/packages/script/package.json
+++ b/packages/script/package.json
@@ -16,7 +16,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
- "test": "eslint ."
+ "lint": "eslint ."
},
"dependencies": {
"htm": "^3.1.1",
diff --git a/packages/script/tests/preload.test.ts b/packages/script/tests/preload.test.ts
deleted file mode 100644
index 0ae9e84..0000000
--- a/packages/script/tests/preload.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-// import { test, expect } from 'vitest'
-// import { h, render } from 'preact'
-// import type { VNode as PreactVNode } from 'preact'
-
-// function transform (node: SimplifiedVNode | string): PreactVNode<{}> | string {
-// if (typeof node !== 'object') {
-// return node
-// }
-
-// const { children, ...props } = node.props
-// const childrenRequired = children || []
-// const c = Array.isArray(childrenRequired) ? childrenRequired : [childrenRequired]
-// return h(node.type as string, props, ...c.map(transform)) as PreactVNode<{}>
-// }
-
-// test('should be able serialize DOM', async () => {
-// await import('../src/index.ts')
-// expect(window.wdioCaptureErrors).toEqual([])
-// expect(window.wdioDOMChanges.length).toBe(1)
-// expect(window.wdioDOMChanges).toMatchSnapshot()
-// })
-
-// test('should be able to parse serialized DOM and render it', () => {
-// const stage = document.createDocumentFragment()
-// const [initial] = window.wdioDOMChanges
-// render(transform(initial.addedNodes[0]), stage)
-// expect(document.documentElement.outerHTML)
-// .toBe((stage.childNodes[0] as HTMLElement).outerHTML)
-// })
-
-// test('should be able to properly serialize changes', async () => {
-// const change = document.createElement('div')
-// change.setAttribute('id', 'change')
-// change.appendChild(document.createTextNode('some '))
-// const bold = document.createElement('i')
-// bold.appendChild(document.createTextNode('real'))
-// change.appendChild(bold)
-// change.appendChild(document.createTextNode(' change'))
-// document.body.appendChild(change)
-
-// await new Promise((resolve) => setTimeout(resolve, 10))
-// expect(window.wdioDOMChanges.length).toBe(2)
-// const [, vChange] = window.wdioDOMChanges
-// const stage = document.createDocumentFragment()
-// render(transform((vChange.addedNodes[0] as SimplifiedVNode).props.children as SimplifiedVNode), stage)
-// expect((stage.childNodes[0] as HTMLElement).outerHTML).toMatchSnapshot()
-// })
diff --git a/packages/script/tests/utils.test.ts b/packages/script/tests/utils.test.ts
new file mode 100644
index 0000000..f612bb0
--- /dev/null
+++ b/packages/script/tests/utils.test.ts
@@ -0,0 +1,140 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { describe, it, expect, beforeEach } from 'vitest'
+import {
+ waitForBody,
+ assignRef,
+ getRef,
+ parseFragment,
+ parseDocument
+} from '../src/utils.js'
+
+describe('DOM mutation capture utilities', () => {
+ beforeEach(() => {
+ if (!document.body) {
+ const body = document.createElement('body')
+ document.documentElement.appendChild(body)
+ }
+ document.body.innerHTML = ''
+ })
+
+ it('should wait for body to exist before capturing mutations', async () => {
+ await expect(waitForBody()).resolves.toBeUndefined()
+ })
+
+ it('should assign trackable refs to DOM elements for mutation identification', () => {
+ const parent = document.createElement('div')
+ const child1 = document.createElement('span')
+ const child2 = document.createElement('p')
+ parent.appendChild(child1)
+ parent.appendChild(child2)
+
+ assignRef(parent)
+
+ const parentRef = getRef(parent)
+ const child1Ref = getRef(child1)
+ const child2Ref = getRef(child2)
+
+ // Each element should get unique ref
+ expect(parentRef).toBeTruthy()
+ expect(child1Ref).toBeTruthy()
+ expect(child2Ref).toBeTruthy()
+ expect(parentRef).not.toBe(child1Ref)
+ expect(child1Ref).not.toBe(child2Ref)
+ })
+
+ it('should maintain stable refs across multiple assignments', () => {
+ const div = document.createElement('div')
+ assignRef(div)
+ const firstRef = getRef(div)
+
+ assignRef(div)
+ const secondRef = getRef(div)
+
+ expect(firstRef).toBe(secondRef)
+ })
+
+ it('should serialize DOM elements to transmittable VNode structure', () => {
+ const button = document.createElement('button')
+ button.id = 'submit'
+ button.className = 'btn-primary'
+ button.textContent = 'Submit'
+
+ const vnode = parseFragment(button)
+
+ // VNode should be serializable (has type and props)
+ expect(vnode).toHaveProperty('type')
+ expect(vnode).toHaveProperty('props')
+ expect(JSON.stringify(vnode)).toBeTruthy()
+ })
+
+ it('should serialize complete document hierarchy for initial capture', () => {
+ const div = document.createElement('div')
+ div.innerHTML = ' ContentApp
New content
' + + // Step 1: Assign ref so we can track this node + assignRef(addedNode) + const nodeRef = getRef(addedNode) + + // Step 2: Serialize for transmission to backend + const serialized = parseFragment(addedNode) + + // Step 3: Verify we can identify and serialize the mutation + expect(nodeRef).toBeTruthy() + expect(serialized).toHaveProperty('type') + expect(serialized).toHaveProperty('props') + + // The serialized VNode should be transmittable as JSON + const json = JSON.stringify(serialized) + expect(json).toBeTruthy() + expect(() => JSON.parse(json)).not.toThrow() + }) + + it('should support mutation removal tracking via refs', () => { + const target = document.createElement('div') + const child = document.createElement('span') + target.appendChild(child) + + assignRef(target) + + // When mutation observer detects removals, we can get refs + const targetRef = getRef(target) + const childRef = getRef(child) + + expect(targetRef).not.toBeNull() + expect(childRef).not.toBeNull() + }) +}) diff --git a/packages/service/package.json b/packages/service/package.json index 0ff9904..f04fd30 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -31,7 +31,7 @@ "scripts": { "dev": "vite build --watch", "build": "tsc && vite build", - "test": "eslint ." + "lint": "eslint ." }, "dependencies": { "@babel/types": "^7.28.4", diff --git a/packages/service/tests/index.test.ts b/packages/service/tests/index.test.ts new file mode 100644 index 0000000..24ad0c1 --- /dev/null +++ b/packages/service/tests/index.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import DevToolsHookService from '../src/index.js' + +const fakeFrame = { + getFileName: () => '/test/specs/fake.spec.ts', + getLineNumber: () => 1, + getColumnNumber: () => 1 +} +// Create mock instance that will be returned by SessionCapturer constructor +vi.mock('stack-trace', () => ({ + parse: () => [fakeFrame] +})) +const mockSessionCapturerInstance = { + afterCommand: vi.fn(), + sendUpstream: vi.fn(), + injectScript: vi.fn().mockResolvedValue(undefined), + commandsLog: [], + sources: new Map(), + mutations: [], + traceLogs: [], + consoleLogs: [], + isReportingUpstream: false +} + +vi.mock('../src/session.js', () => ({ + SessionCapturer: vi.fn(function (this: any) { + return mockSessionCapturerInstance + }) +})) + +describe('DevtoolsService - Internal Command Filtering', () => { + let service: DevToolsHookService + const mockBrowser = { + isBidi: true, + sessionId: 'test-session', + scriptAddPreloadScript: vi.fn().mockResolvedValue(undefined), + takeScreenshot: vi.fn().mockResolvedValue('screenshot'), + execute: vi.fn().mockResolvedValue({ + width: 1200, + height: 800, + offsetLeft: 0, + offsetTop: 0 + }) + } as any + + // Helper to execute a command (before + after) + const executeCommand = ( + cmd: string, + args: any[] = [], + result: any = undefined + ) => { + service.beforeCommand(cmd as any, args) + service.afterCommand(cmd as any, args, result) + } + + beforeEach(() => { + vi.clearAllMocks() + mockSessionCapturerInstance.afterCommand.mockClear() + mockSessionCapturerInstance.sendUpstream.mockClear() + service = new DevToolsHookService() + }) + + describe('beforeCommand', () => { + it('should not add internal commands to command stack', () => { + const internalCommands = [ + 'getTitle', + 'waitUntil', + 'getUrl', + 'execute', + 'findElement' + ] + internalCommands.forEach((cmd) => service.beforeCommand(cmd as any, [])) + expect(true).toBe(true) + }) + + it('should add user commands to command stack', () => { + ;['click', 'url', 'getText'].forEach((cmd, i) => { + const args = [['.button', 'https://example.com', '.result'][i]] + service.beforeCommand(cmd as any, args) + }) + expect(true).toBe(true) + }) + }) + + describe('afterCommand - internal command filtering', () => { + beforeEach(async () => { + await service.before({} as any, [], mockBrowser) + vi.clearAllMocks() + mockSessionCapturerInstance.afterCommand.mockClear() + }) + + it('should filter mixed internal and user commands correctly', () => { + // Execute mix of user and internal commands + executeCommand('url', ['https://example.com']) + executeCommand('getTitle', [], 'Page Title') // internal + executeCommand('click', ['.button']) + executeCommand('waitUntil', [expect.any(Function)], true) // internal + executeCommand('getText', ['.result'], 'Success') + + // Only user commands (url, click, getText) should be captured + expect(mockSessionCapturerInstance.afterCommand).toHaveBeenCalledTimes(3) + + const capturedCommands = + mockSessionCapturerInstance.afterCommand.mock.calls.map( + (call) => call[1] + ) + expect(capturedCommands).toEqual(['url', 'click', 'getText']) + expect(capturedCommands).not.toContain('getTitle') + expect(capturedCommands).not.toContain('waitUntil') + }) + }) +}) diff --git a/packages/service/tests/launcher.test.ts b/packages/service/tests/launcher.test.ts new file mode 100644 index 0000000..f78fe5b --- /dev/null +++ b/packages/service/tests/launcher.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { DevToolsAppLauncher } from '../src/launcher.js' +import * as backend from '@wdio/devtools-backend' +import { remote } from 'webdriverio' + +vi.mock('@wdio/devtools-backend', () => ({ + start: vi.fn() +})) + +vi.mock('webdriverio', () => ({ + remote: vi.fn() +})) + +vi.mock('@wdio/logger', () => { + const mockLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn() + } + const loggerFunc: any = vi.fn(() => mockLogger) + loggerFunc.setLevel = vi.fn() + return { + default: loggerFunc + } +}) + +describe('DevToolsAppLauncher', () => { + const mockServer = { address: () => ({ port: 3000 }) } + const mockBrowser = { + url: vi.fn().mockResolvedValue(undefined), + getTitle: vi.fn().mockResolvedValue('Test'), + deleteSession: vi.fn().mockResolvedValue(undefined) + } + + beforeEach(() => { + vi.clearAllMocks() + delete process.env.DEVTOOLS_APP_REUSE + delete process.env.DEVTOOLS_APP_PORT + delete process.env.DEVTOOLS_APP_HOST + mockBrowser.url.mockResolvedValue(undefined) + mockBrowser.getTitle.mockResolvedValue('Test') + mockBrowser.deleteSession.mockResolvedValue(undefined) + }) + + describe('onPrepare', () => { + it('should start devtools backend and update capabilities', async () => { + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + + expect(backend.start).toHaveBeenCalledWith({ + port: 3000, + hostname: undefined + }) + expect(caps[0]['wdio:devtoolsOptions']).toEqual({ + port: 3000, + hostname: 'localhost' + }) + expect(remote).toHaveBeenCalled() + expect(mockBrowser.url).toHaveBeenCalledWith('http://localhost:3000') + }) + + it('should use custom hostname', async () => { + const customServer = { address: () => ({ port: 4000 }) } + vi.mocked(backend.start).mockResolvedValue({ + server: customServer + } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const launcher = new DevToolsAppLauncher({ + hostname: '127.0.0.1', + port: 4000 + }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + + expect(caps[0]['wdio:devtoolsOptions']).toEqual({ + port: 4000, + hostname: '127.0.0.1' + }) + }) + + it('should reuse existing devtools app when DEVTOOLS_APP_REUSE is set', async () => { + process.env.DEVTOOLS_APP_REUSE = '1' + process.env.DEVTOOLS_APP_PORT = '5000' + process.env.DEVTOOLS_APP_HOST = 'localhost' + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + + // Should not start new server + expect(backend.start).not.toHaveBeenCalled() + + // Should use existing port + expect(caps[0]['wdio:devtoolsOptions']).toEqual({ + port: 5000, + hostname: 'localhost' + }) + }) + + it('should handle server start failure', async () => { + vi.mocked(backend.start).mockRejectedValue(new Error('Failed to start')) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [{ browserName: 'chrome' }] as any + + // Should not throw, just log error + await expect( + launcher.onPrepare(undefined as never, caps) + ).resolves.toBeUndefined() + }) + + it('should handle missing port in server address', async () => { + const mockServer = { + address: () => null + } + + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + + // Should handle gracefully + expect(backend.start).toHaveBeenCalled() + }) + + it('should not update non-array capabilities', async () => { + const mockServer = { + address: () => ({ port: 3000 }) + } + + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps: any = { + browserName: 'chrome' + } + + await launcher.onPrepare(undefined as never, caps) + + // Should not throw or modify non-array caps + expect(caps['wdio:devtoolsOptions']).toBeUndefined() + }) + + it('should update multiple capabilities', async () => { + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [ + { browserName: 'chrome' }, + { browserName: 'firefox' }, + { browserName: 'edge' } + ] as any + + await launcher.onPrepare(undefined as never, caps) + + caps.forEach((cap: any) => { + expect(cap['wdio:devtoolsOptions']).toEqual({ + port: 3000, + hostname: 'localhost' + }) + }) + }) + + it('should pass devtoolsCapabilities to remote', async () => { + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const customCaps = { + browserName: 'chrome', + 'goog:chromeOptions': { args: ['--headless'] } + } + + const launcher = new DevToolsAppLauncher({ + port: 3000, + devtoolsCapabilities: customCaps + }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + + expect(remote).toHaveBeenCalledWith( + expect.objectContaining({ + automationProtocol: 'devtools', + capabilities: expect.objectContaining(customCaps) + }) + ) + }) + }) + + describe('onComplete', () => { + it('should wait for browser window to close', async () => { + let getTitleCallCount = 0 + mockBrowser.getTitle.mockImplementation(() => { + getTitleCallCount++ + return getTitleCallCount <= 1 + ? Promise.resolve('Test') + : Promise.reject(new Error('Browser closed')) + }) + + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + await launcher.onComplete() + + expect(mockBrowser.getTitle).toHaveBeenCalled() + expect(mockBrowser.deleteSession).toHaveBeenCalled() + vi.useRealTimers() + }, 10000) + + it('should handle no browser instance', async () => { + const launcher = new DevToolsAppLauncher({ port: 3000 }) + + // Should not throw + await expect(launcher.onComplete()).resolves.toBeUndefined() + }) + + it('should handle deleteSession errors', async () => { + mockBrowser.getTitle.mockRejectedValue(new Error('Browser closed')) + mockBrowser.deleteSession.mockRejectedValue( + new Error('Session already closed') + ) + + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const launcher = new DevToolsAppLauncher({ port: 3000 }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + await expect(launcher.onComplete()).resolves.toBeUndefined() + }) + }) + + describe('integration', () => { + it('should handle full lifecycle', async () => { + mockBrowser.getTitle.mockRejectedValue(new Error('Browser closed')) + + vi.mocked(backend.start).mockResolvedValue({ server: mockServer } as any) + vi.mocked(remote).mockResolvedValue(mockBrowser as any) + + const launcher = new DevToolsAppLauncher({ + port: 3000, + hostname: 'localhost' + }) + const caps = [{ browserName: 'chrome' }] as any + + await launcher.onPrepare(undefined as never, caps) + + expect(backend.start).toHaveBeenCalled() + expect(remote).toHaveBeenCalled() + expect(mockBrowser.url).toHaveBeenCalledWith('http://localhost:3000') + expect(caps[0]['wdio:devtoolsOptions']).toBeDefined() + + await launcher.onComplete() + expect(mockBrowser.deleteSession).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/service/tests/reporter.test.ts b/packages/service/tests/reporter.test.ts new file mode 100644 index 0000000..3c53df3 --- /dev/null +++ b/packages/service/tests/reporter.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { TestReporter } from '../src/reporter.js' +import type { TestStats, SuiteStats } from '@wdio/reporter' + +describe('TestReporter - Rerun & Stable UID', () => { + let reporter: TestReporter + let sendUpstream: ReturnType