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/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/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/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/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 + + const createTestStats = (overrides: Partial = {}): TestStats => + ({ + uid: 'test-123', + title: 'should login', + fullTitle: 'Login Suite should login', + file: '/test/login.spec.ts', + parent: 'suite-1', + cid: '0-0', + ...overrides + }) as TestStats + + const createSuiteStats = (overrides: Partial = {}): SuiteStats => + ({ + uid: 'suite-123', + title: 'Login Suite', + fullTitle: 'Login Suite', + file: '/test/login.spec.ts', + cid: '0-0', + ...overrides + }) as SuiteStats + + beforeEach(() => { + sendUpstream = vi.fn() + reporter = new TestReporter( + { logFile: '/tmp/test.log' }, + sendUpstream as any + ) + }) + + describe('stable UID generation for reruns', () => { + it('should generate consistent UIDs for test reruns', () => { + const testStats1 = createTestStats() + const testStats2 = createTestStats({ uid: 'test-456' }) + + reporter.onTestStart(testStats1) + const uid1 = (testStats1 as any).uid + + reporter = new TestReporter( + { logFile: '/tmp/test.log' }, + sendUpstream as any + ) + reporter.onTestStart(testStats2) + const uid2 = (testStats2 as any).uid + + expect(uid1).toBe(uid2) + expect(uid1).toContain('stable-') + }) + + it('should generate unique UIDs for different tests', () => { + const tests = [ + createTestStats({ + uid: 'test-1', + title: 'test A', + fullTitle: 'Suite test A' + }), + createTestStats({ + uid: 'test-2', + title: 'test B', + fullTitle: 'Suite test B' + }) + ] + + tests.forEach((test) => reporter.onTestStart(test)) + + expect((tests[0] as any).uid).not.toBe((tests[1] as any).uid) + }) + + it('should handle suite stable UIDs for reruns', () => { + const suite1 = createSuiteStats() + const suite2 = createSuiteStats({ uid: 'suite-456' }) + + reporter.onSuiteStart(suite1) + const uid1 = (suite1 as any).uid + + reporter = new TestReporter( + { logFile: '/tmp/test.log' }, + sendUpstream as any + ) + reporter.onSuiteStart(suite2) + const uid2 = (suite2 as any).uid + + expect(uid1).toBe(uid2) + }) + }) + + describe('Cucumber feature file tracking for reruns', () => { + it('should capture feature file and line for Cucumber tests', () => { + const testStats = createTestStats({ + uid: 'test-1', + title: 'Login scenario', + fullTitle: 'Login scenario', + file: '/test/login.feature', + argument: { uri: '/test/features/login.feature', line: 15 } as any + }) + + reporter.onTestStart(testStats) + + expect((testStats as any).featureFile).toBe( + '/test/features/login.feature' + ) + expect((testStats as any).featureLine).toBe(15) + }) + + it('should call reporter methods without errors', () => { + const testStats = createTestStats({ + uid: 'test-1', + title: 'test', + fullTitle: 'test', + file: '/test.spec.ts', + parent: 'suite' + }) + + expect(() => { + reporter.onTestStart(testStats) + reporter.onTestEnd(testStats) + }).not.toThrow() + }) + }) +}) diff --git a/packages/service/tests/session.test.ts b/packages/service/tests/session.test.ts new file mode 100644 index 0000000..6aa3630 --- /dev/null +++ b/packages/service/tests/session.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SessionCapturer } from '../src/session.js' +import { WebSocket } from 'ws' +import fs from 'node:fs/promises' + +vi.mock('ws') +vi.mock('node:fs/promises') + +describe('SessionCapturer', () => { + const mockBrowser = { + takeScreenshot: vi.fn().mockResolvedValue('screenshot') + } as any + + const executeCommand = async ( + capturer: SessionCapturer, + command: string, + args: any[] = [], + result: any = undefined, + callSource?: string + ) => { + return capturer.afterCommand( + mockBrowser, + command as any, + args, + result, + undefined, + callSource + ) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fs.access).mockRejectedValue(new Error('File not found')) + }) + + describe('constructor and connection', () => { + it('should initialize properties and create websocket connection', () => { + const capturer1 = new SessionCapturer() + expect(capturer1.isReportingUpstream).toBe(false) + expect(capturer1.commandsLog).toEqual([]) + + const capturer2 = new SessionCapturer({ + hostname: 'localhost', + port: 3000 + }) + expect(WebSocket).toHaveBeenCalledWith('ws://localhost:3000/worker') + expect(capturer2.isReportingUpstream).toBe(false) + }) + }) + + describe('afterCommand - screenshots', () => { + it('should capture screenshots for every command', async () => { + const capturer = new SessionCapturer() + const mockScreenshot = 'base64EncodedScreenshotData' + mockBrowser.takeScreenshot.mockResolvedValueOnce(mockScreenshot) + + await executeCommand(capturer, 'click', ['.button']) + + expect(mockBrowser.takeScreenshot).toHaveBeenCalledTimes(1) + expect(capturer.commandsLog).toHaveLength(1) + expect(capturer.commandsLog[0].screenshot).toBe(mockScreenshot) + }) + + it('should handle screenshot failures gracefully', async () => { + const capturer = new SessionCapturer() + mockBrowser.takeScreenshot.mockRejectedValueOnce( + new Error('Screenshot failed') + ) + + await executeCommand(capturer, 'click', ['.button']) + + expect(mockBrowser.takeScreenshot).toHaveBeenCalled() + expect(capturer.commandsLog).toHaveLength(1) + expect(capturer.commandsLog[0].screenshot).toBeUndefined() + }) + + it('should capture screenshots for multiple commands in sequence', async () => { + const capturer = new SessionCapturer() + const screenshots = ['screenshot1', 'screenshot2', 'screenshot3'] + mockBrowser.takeScreenshot + .mockResolvedValueOnce(screenshots[0]) + .mockResolvedValueOnce(screenshots[1]) + .mockResolvedValueOnce(screenshots[2]) + + await executeCommand(capturer, 'url', ['https://example.com']) + await executeCommand(capturer, 'click', ['.btn']) + await executeCommand(capturer, 'getText', ['.result'], 'Success') + + expect(mockBrowser.takeScreenshot).toHaveBeenCalledTimes(3) + screenshots.forEach((screenshot, i) => { + expect(capturer.commandsLog[i].screenshot).toBe(screenshot) + }) + }) + }) + + describe('afterCommand - source capture', () => { + it('should capture source code and filter internal frames', async () => { + const capturer = new SessionCapturer() + const sourceCode = 'const test = "hello";' + const sourcePath = '/test/spec.ts' + + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readFile).mockResolvedValue(sourceCode as any) + + await executeCommand( + capturer, + 'click', + [], + undefined, + `${sourcePath}:10:5` + ) + await executeCommand( + capturer, + 'getText', + [], + 'text', + `${sourcePath}:15:5` + ) + + expect(capturer.sources.size).toBeGreaterThan(0) + + vi.mocked(fs.access).mockRejectedValue(new Error('File not found')) + await executeCommand(capturer, 'url', ['https://example.com']) + + expect(capturer.commandsLog.length).toBeGreaterThan(0) + }) + }) + + describe('data collection', () => { + it('should accumulate commands and handle mutations', async () => { + const capturer = new SessionCapturer() + const commands = [ + ['url', ['https://example.com'], undefined], + ['click', ['.button'], undefined], + ['getText', ['.result'], 'Success'] + ] + + for (const [cmd, args, result] of commands) { + await executeCommand(capturer, cmd as string, args as any[], result) + } + + expect(capturer.commandsLog).toHaveLength(3) + expect(capturer.commandsLog[0].command).toBe('url') + + const mutations: TraceMutation[] = [ + { + type: 'childList', + timestamp: Date.now(), + target: 'ref-1', + addedNodes: [], + removedNodes: [] + } + ] + + capturer.mutations = mutations + expect(capturer.mutations).toHaveLength(1) + expect(capturer.mutations[0].type).toBe('childList') + }) + }) + + describe('websocket communication', () => { + it('should handle connection states and errors', () => { + const mockWs = { + readyState: WebSocket.CONNECTING, + on: vi.fn(), + send: vi.fn() + } as any + + vi.mocked(WebSocket).mockImplementation(function (this: any) { + return mockWs + } as any) + + const capturer = new SessionCapturer({ + hostname: 'localhost', + port: 3000 + }) + + expect(capturer.isReportingUpstream).toBe(false) + + mockWs.readyState = WebSocket.OPEN + expect(capturer.isReportingUpstream).toBe(true) + + const errorHandler = mockWs.on.mock.calls.find( + (call: any) => call[0] === 'error' + )?.[1] + if (errorHandler) { + expect(() => errorHandler(new Error('Connection failed'))).not.toThrow() + } + }) + }) + + describe('integration', () => { + it('should handle complete session capture workflow', async () => { + const capturer = new SessionCapturer() + const commands = [ + ['url', ['https://example.com']], + ['click', ['.btn']], + ['getText', ['.result'], 'Success'] + ] + + for (const [cmd, args, result] of commands) { + await executeCommand(capturer, cmd as string, args as any[], result) + } + + expect(capturer.commandsLog).toHaveLength(3) + const expectedCommands = ['url', 'click', 'getText'] + capturer.commandsLog.forEach((log, i) => { + expect(log.command).toBe(expectedCommands[i]) + }) + expect(capturer.commandsLog[2].result).toBe('Success') + expect(mockBrowser.takeScreenshot).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/packages/service/tests/utils.test.ts b/packages/service/tests/utils.test.ts new file mode 100644 index 0000000..e02ea2d --- /dev/null +++ b/packages/service/tests/utils.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getBrowserObject, setCurrentSpecFile } from '../src/utils.js' + +describe('service utils', () => { + beforeEach(() => { + vi.clearAllMocks() + setCurrentSpecFile(undefined) + }) + + describe('getBrowserObject', () => { + it('should return browser directly or traverse hierarchy', () => { + const mockBrowser = { + sessionId: 'session-123', + capabilities: {} + } as WebdriverIO.Browser + + // Direct browser object + expect(getBrowserObject(mockBrowser)).toBe(mockBrowser) + + // Single level + const element1 = { + elementId: 'element-1', + parent: mockBrowser + } as unknown as WebdriverIO.Element + expect(getBrowserObject(element1)).toBe(mockBrowser) + + // Multiple levels + const element2 = { + elementId: 'element-2', + parent: element1 + } as unknown as WebdriverIO.Element + expect(getBrowserObject(element2)).toBe(mockBrowser) + }) + }) + + describe('setCurrentSpecFile', () => { + it('should set and clear spec file without errors', () => { + expect(() => setCurrentSpecFile('/path/to/test.spec.ts')).not.toThrow() + expect(() => setCurrentSpecFile(undefined)).not.toThrow() + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99cefdd..c361058 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/browser': specifier: ^4.0.16 - version: 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -48,6 +48,9 @@ importers: eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.38.0(jiti@2.6.1)) + happy-dom: + specifier: ^20.0.11 + version: 20.0.11 npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -80,7 +83,7 @@ importers: version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + version: 4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) webdriverio: specifier: ^9.19.1 version: 9.20.0(puppeteer-core@21.11.0) @@ -1515,6 +1518,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/which@2.0.2': resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} @@ -3033,6 +3039,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + happy-dom@20.0.11: + resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==} + engines: {node: '>=20.0.0'} + has-ansi@4.0.1: resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==} engines: {node: '>=8'} @@ -5169,6 +5179,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -6469,6 +6483,8 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/which@2.0.2': {} '@types/ws@8.18.1': @@ -6579,7 +6595,7 @@ snapshots: '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 - '@vitest/browser@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/browser@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/utils': 4.0.16 @@ -6588,7 +6604,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -8554,6 +8570,12 @@ snapshots: graphemer@1.4.0: {} + happy-dom@20.0.11: + dependencies: + '@types/node': 20.19.27 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + has-ansi@4.0.1: dependencies: ansi-regex: 4.1.1 @@ -10618,7 +10640,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) @@ -10642,6 +10664,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.3 + happy-dom: 20.0.11 transitivePeerDependencies: - jiti - less @@ -10798,6 +10821,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@5.0.0: