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 = '

App

Content

' + + const vnode = parseDocument(div) + + // Document parsing wraps in html element + expect(vnode).toHaveProperty('type') + expect(vnode).toHaveProperty('props') + expect(JSON.stringify(vnode)).toBeTruthy() + }) + + it('should handle parsing errors without breaking mutation capture', () => { + const fragmentResult = parseFragment(null as any) + const documentResult = parseDocument(null as any) + + // Should return error containers instead of throwing + expect(typeof fragmentResult).toBe('object') + expect(typeof documentResult).toBe('object') + + if (typeof fragmentResult === 'object') { + expect(fragmentResult.type).toBe('div') + expect(fragmentResult.props.class).toBe('parseFragmentWrapper') + } + + if (typeof documentResult === 'object') { + expect(documentResult.type).toBe('div') + expect(documentResult.props.class).toBe('parseDocument') + } + }) + + it('should support complete mutation tracking workflow: assign ref โ†’ serialize โ†’ transmit', () => { + // Simulate what happens in index.ts when MutationObserver detects changes + const addedNode = document.createElement('article') + addedNode.innerHTML = '

New Section

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 + + 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: