From c122e744c505f32368ef33a28bb63a2d1b44ac84 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 16 Feb 2026 16:16:19 +0530 Subject: [PATCH 1/4] feat: Add Nightwatch DevTools plugin - Complete Nightwatch adapter for WebdriverIO DevTools - Auto-opening browser UI with visual test debugging - Command tracking with retry deduplication - Performance data capture (80% solution without CDP) - Suite title extraction from describe() blocks - All tests displayed correctly in UI - Supporting changes to backend, service, and app packages --- example/wdio.conf.ts | 2 +- package.json | 6 +- .../app/src/components/sidebar/explorer.ts | 55 +- .../app/src/components/sidebar/test-suite.ts | 6 +- packages/app/src/controller/DataManager.ts | 12 +- packages/backend/src/index.ts | 48 +- packages/nightwatch-devtools/.gitignore | 36 + packages/nightwatch-devtools/README.md | 133 ++ .../nightwatch-devtools/example/README.md | 135 ++ .../example/nightwatch.conf.cjs | 29 + .../example/nightwatch.conf.js | 361 +++++ .../example/tests/login.test.js | 43 + .../example/tests/sample.test.js | 23 + .../nightwatch-devtools/example/validate.cjs | 59 + .../nightwatch-devtools/nightwatch.conf.cjs | 361 +++++ packages/nightwatch-devtools/package.json | 44 + .../src/capturePerformance.ts | 56 + packages/nightwatch-devtools/src/constants.ts | 75 + packages/nightwatch-devtools/src/index.ts | 781 ++++++++++ packages/nightwatch-devtools/src/reporter.ts | 241 +++ packages/nightwatch-devtools/src/session.ts | 564 +++++++ packages/nightwatch-devtools/src/types.ts | 158 ++ packages/nightwatch-devtools/src/utils.ts | 180 +++ packages/nightwatch-devtools/tsconfig.json | 18 + packages/service/src/types.ts | 1 + pnpm-lock.yaml | 1326 ++++++++++++++++- pnpm-workspace.yaml | 1 + 27 files changed, 4729 insertions(+), 25 deletions(-) create mode 100644 packages/nightwatch-devtools/.gitignore create mode 100644 packages/nightwatch-devtools/README.md create mode 100644 packages/nightwatch-devtools/example/README.md create mode 100644 packages/nightwatch-devtools/example/nightwatch.conf.cjs create mode 100644 packages/nightwatch-devtools/example/nightwatch.conf.js create mode 100644 packages/nightwatch-devtools/example/tests/login.test.js create mode 100644 packages/nightwatch-devtools/example/tests/sample.test.js create mode 100644 packages/nightwatch-devtools/example/validate.cjs create mode 100644 packages/nightwatch-devtools/nightwatch.conf.cjs create mode 100644 packages/nightwatch-devtools/package.json create mode 100644 packages/nightwatch-devtools/src/capturePerformance.ts create mode 100644 packages/nightwatch-devtools/src/constants.ts create mode 100644 packages/nightwatch-devtools/src/index.ts create mode 100644 packages/nightwatch-devtools/src/reporter.ts create mode 100644 packages/nightwatch-devtools/src/session.ts create mode 100644 packages/nightwatch-devtools/src/types.ts create mode 100644 packages/nightwatch-devtools/src/utils.ts create mode 100644 packages/nightwatch-devtools/tsconfig.json diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts index 7401cae..1fd07d9 100644 --- a/example/wdio.conf.ts +++ b/example/wdio.conf.ts @@ -63,7 +63,7 @@ export const config: Options.Testrunner = { capabilities: [ { browserName: 'chrome', - browserVersion: '144.0.7559.60', // specify chromium browser version for testing + browserVersion: '144.0.7559.133', // specify chromium browser version for testing 'goog:chromeOptions': { args: [ '--headless', diff --git a/package.json b/package.json index aa19ec0..c8c7472 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "pnpm --parallel build", "demo": "wdio run ./example/wdio.conf.ts", + "demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example", "dev": "pnpm --parallel dev", "preview": "pnpm --parallel preview", "test": "vitest run", @@ -17,7 +18,10 @@ "pnpm": { "overrides": { "vite": "^7.3.0" - } + }, + "ignoredBuiltDependencies": [ + "chromedriver" + ] }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 0d2835f..0fd3f05 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -1,6 +1,6 @@ import { Element } from '@core/element' import { html, css, nothing, type TemplateResult } from 'lit' -import { customElement } from 'lit/decorators.js' +import { customElement, property } from 'lit/decorators.js' import { consume } from '@lit/context' import type { TestStats, SuiteStats } from '@wdio/reporter' import type { Metadata } from '@wdio/devtools-service/types' @@ -63,6 +63,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ] @consume({ context: suiteContext, subscribe: true }) + @property({ type: Array }) suites: Record[] | undefined = undefined @consume({ context: metadataContext, subscribe: true }) @@ -71,6 +72,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { @consume({ context: isTestRunningContext, subscribe: true }) isTestRunning = false + updated(changedProperties: Map) { + super.updated(changedProperties) + } + connectedCallback(): void { super.connectedCallback() window.addEventListener('app-test-filter', this.#filterListener) @@ -285,6 +290,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { feature-file="${entry.featureFile || ''}" feature-line="${entry.featureLine ?? ''}" suite-type="${entry.suiteType || ''}" + ?has-children="${entry.children && entry.children.length > 0}" .runDisabled=${this.#isRunDisabled(entry)} .runDisabledReason=${this.#getRunDisabledReason(entry)} > @@ -326,6 +332,38 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ) } + #isRunning(entry: TestStats | SuiteStats): boolean { + if ('tests' in entry) { + // Check if any immediate test is running + if (entry.tests.some((t) => !t.end)) { + return true + } + // Check if any nested suite is running + if (entry.suites.some((s) => this.#isRunning(s))) { + return true + } + return false + } + // For individual tests, check if end is not set + return !entry.end + } + + #hasFailed(entry: TestStats | SuiteStats): boolean { + if ('tests' in entry) { + // Check if any immediate test failed + if (entry.tests.find((t) => t.state === 'failed')) { + return true + } + // Check if any nested suite has failures + if (entry.suites.some((s) => this.#hasFailed(s))) { + return true + } + return false + } + // For individual tests + return entry.state === 'failed' + } + #getTestEntry(entry: TestStats | SuiteStats): TestEntry { if ('tests' in entry) { const entries = [...entry.tests, ...entry.suites] @@ -333,9 +371,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { uid: entry.uid, label: entry.title, type: 'suite', - state: entry.tests.some((t) => !t.end) + state: this.#isRunning(entry) ? TestState.RUNNING - : entry.tests.find((t) => t.state === 'failed') + : this.#hasFailed(entry) ? TestState.FAILED : TestState.PASSED, callSource: (entry as any).callSource, @@ -421,9 +459,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { (suite) => suite.uid, (suite) => this.#renderEntry(suite) ) - : html`

- No tests found -

`} + : html`
+

No tests to display

+

+ Debug: suites=${this.suites?.length || 0}, + rootSuites=${uniqueSuites.length}, + filtered=${suites.length} +

+
`} ` } diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index 428a267..934bf21 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -80,6 +80,9 @@ export class ExplorerTestEntry extends CollapseableEntry { @property({ type: String, attribute: 'suite-type' }) suiteType?: string + @property({ type: Boolean, attribute: 'has-children' }) + hasChildren = false + static styles = [ ...Element.styles, css` @@ -206,8 +209,7 @@ export class ExplorerTestEntry extends CollapseableEntry { } render() { - const hasNoChildren = - this.querySelectorAll('[slot="children"]').length === 0 + const hasNoChildren = !this.hasChildren const isCollapsed = this.isCollapsed === 'true' const runTooltip = this.runDisabled ? this.runDisabledReason || diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts index e4c546d..ee234f2 100644 --- a/packages/app/src/controller/DataManager.ts +++ b/packages/app/src/controller/DataManager.ts @@ -49,10 +49,10 @@ export const isTestRunningContext = createContext( ) interface SocketMessage< - T extends keyof TraceLog | 'testStopped' = keyof TraceLog | 'testStopped' + T extends keyof TraceLog | 'testStopped' | 'clearExecutionData' = keyof TraceLog | 'testStopped' | 'clearExecutionData' > { scope: T - data: T extends keyof TraceLog ? TraceLog[T] : unknown + data: T extends keyof TraceLog ? TraceLog[T] : T extends 'clearExecutionData' ? { uid?: string } : unknown } export class DataManagerController implements ReactiveController { @@ -270,6 +270,14 @@ export class DataManagerController implements ReactiveController { return } + // Handle clear execution data event (when tests change) + if (scope === 'clearExecutionData') { + const clearData = data as { uid?: string } + this.clearExecutionData(clearData.uid) + this.#host.requestUpdate() + return + } + // Check for new run BEFORE processing suites data if (scope === 'suites') { const shouldReset = this.#shouldResetForNewRun(data) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 89c2809..b6c24ab 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -32,7 +32,15 @@ export function broadcastToClients(message: string) { export async function start(opts: DevtoolsBackendOptions = {}) { const host = opts.hostname || 'localhost' - const port = opts.port || (await getPort({ port: DEFAULT_PORT })) + // Use getPort to find an available port, starting with the preferred port + const preferredPort = opts.port || DEFAULT_PORT + const port = await getPort({ port: preferredPort }) + + // Log if we had to use a different port + if (opts.port && port !== opts.port) { + log.warn(`Port ${opts.port} is already in use, using port ${port} instead`) + } + const appPath = await getDevtoolsApp() server = Fastify({ logger: true }) @@ -91,6 +99,34 @@ export async function start(opts: DevtoolsBackendOptions = {}) { log.info( `received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}` ) + + // Parse message to check if it's a clearCommands message + try { + const parsed = JSON.parse(message.toString()) + + // If this is a clearCommands message, transform it to clear-execution-data format + if (parsed.scope === 'clearCommands') { + const testUid = parsed.data?.testUid + log.info(`Clearing commands for test: ${testUid || 'all'}`) + + // Create a synthetic message that DataManager will understand + const clearMessage = JSON.stringify({ + scope: 'clearExecutionData', + data: { uid: testUid } + }) + + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(clearMessage) + } + }) + return + } + } catch (e) { + // Not JSON or parsing failed, forward as-is + } + + // Forward all other messages as-is clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message.toString()) @@ -111,8 +147,16 @@ export async function stop() { } log.info('Shutting down WebdriverIO Devtools application') - await server.close() + + // Close all WebSocket connections first + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.terminate() + } + }) clients.clear() + + await server.close() } /** diff --git a/packages/nightwatch-devtools/.gitignore b/packages/nightwatch-devtools/.gitignore new file mode 100644 index 0000000..f68e975 --- /dev/null +++ b/packages/nightwatch-devtools/.gitignore @@ -0,0 +1,36 @@ +# Build output +dist/ +*.tsbuildinfo + +# Dependencies +node_modules/ + +# Test outputs +tests_output/ +logs/ +example/logs/ + +# Trace files +*-trace-*.json +nightwatch-trace-*.json + +# Log files +*.log +npm-debug.log* +pnpm-debug.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.bak diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md new file mode 100644 index 0000000..71a4eb7 --- /dev/null +++ b/packages/nightwatch-devtools/README.md @@ -0,0 +1,133 @@ +# @wdio/nightwatch-devtools + +> Nightwatch adapter for WebdriverIO DevTools - Visual debugging UI for your Nightwatch tests + +## What is this? + +Brings the powerful WebdriverIO DevTools visual debugging interface to Nightwatch tests with **zero test code changes**. + +See everything in real-time: +- šŸ“‹ **Commands** - Every action executed +- šŸ–„ļø **Console** - Browser console logs +- 🌐 **Network** - HTTP requests/responses +- āœ… **Tests** - Suite structure and results +- šŸ“ **Sources** - Test file contents +- šŸ“ **Logs** - Framework debugging + +## Installation + +```bash +npm install @wdio/nightwatch-devtools --save-dev +# or +pnpm add -D @wdio/nightwatch-devtools +``` + +## Usage + +Add to your Nightwatch config: + +```javascript +// nightwatch.conf.js +module.exports = { + src_folders: ['tests'], + + plugins: ['@wdio/nightwatch-devtools'], + + test_settings: { + default: { + desiredCapabilities: { + browserName: 'chrome' + } + } + } +} +``` + +Run your tests: + +```bash +nightwatch +``` + +The DevTools UI will automatically: +1. Start backend server on port 3000 +2. Open in a new browser window +3. Stream test data in real-time +4. Stay open after tests finish (close manually to exit) + +## Example + +See [`example/`](./example) directory for a working sample with: +- Sample test suite +- Nightwatch configuration +- Setup instructions + +Run it: +```bash +cd packages/nightwatch-devtools +pnpm build +pnpm validate # Quick check +pnpm example # Run tests with DevTools UI +``` + +## How It Works + +This is a **thin adapter** (~210 lines) that: + +1. āœ… Reuses `@wdio/devtools-backend` - Fastify server + WebSocket +2. āœ… Reuses `@wdio/devtools-app` - Lit-based UI components +3. āœ… Reuses `@wdio/devtools-script` - Browser capture +4. āœ… Adds only Nightwatch lifecycle hooks: `before`, `beforeSuite`, `beforeEach`, `afterEach`, `after` + +Same backend, same UI, same capture as WDIO - just different framework hooks! + +## Validation + +Verify the plugin is working: + +```bash +pnpm validate +``` + +Output: +``` +āœ… Plugin compiled (dist/ exists) +āœ… Plugin module loaded +āœ… Plugin exports default class +āœ… Plugin can be instantiated +āœ… All required lifecycle methods present +✨ Plugin validation successful! +``` + +## Options + +```javascript +plugins: [ + ['@wdio/nightwatch-devtools', { + port: 3000, // DevTools server port + hostname: 'localhost' // DevTools server hostname + }] +] +``` + +## What Gets Captured + +āœ… Test suites and hierarchy +āœ… Test pass/fail status +āœ… Execution timing +āœ… Error messages and stack traces +āœ… Browser console logs (automatic) +āœ… Network requests (automatic) +āœ… DOM mutations (automatic) + +Browser-side capture works automatically via `@wdio/devtools-script`. + +## Requirements + +- **Nightwatch**: >= 3.0.0 +- **Node.js**: >= 18.0.0 +- **Chrome/Chromium**: For tests and UI + +## License + +MIT diff --git a/packages/nightwatch-devtools/example/README.md b/packages/nightwatch-devtools/example/README.md new file mode 100644 index 0000000..b6e7b09 --- /dev/null +++ b/packages/nightwatch-devtools/example/README.md @@ -0,0 +1,135 @@ +# Nightwatch DevTools Example + +This example demonstrates the `@wdio/nightwatch-devtools` plugin in action. + +## Prerequisites + +Make sure you have Chrome/Chromium installed on your system. The example uses Nightwatch's built-in chromedriver manager. + +## Setup + +1. Build the plugin: +```bash +cd packages/nightwatch-devtools +pnpm build +``` + +2. Install dependencies: +```bash +pnpm install +``` + +## Running the Example + +### Option 1: Automatic (Recommended) + +Run the example tests with DevTools UI: + +```bash +pnpm example +``` + +### Option 2: Manual Setup + +If you encounter chromedriver issues, you can: + +1. **Install chromedriver globally:** +```bash +npm install -g chromedriver +``` + +2. **Or download Chrome for Testing:** +Visit: https://googlechromelabs.github.io/chrome-for-testing/ + +3. **Update nightwatch.conf.cjs** with your chromedriver path: +```javascript +webdriver: { + start_process: true, + server_path: '/path/to/chromedriver', + port: 9515 +} +``` + +## What Happens + +When you run the example, the plugin will: + +1. āœ… Start the DevTools backend server on port 3000 +2. āœ… Open the DevTools UI in a new browser window +3. āœ… Run your Nightwatch tests +4. āœ… Stream all commands, logs, and results to the UI in real-time +5. āœ… Keep the UI open until you close the browser window + +## What You'll See in the DevTools UI + +- **Commands Tab**: Every Nightwatch command executed (url, click, assert, etc.) +- **Console Tab**: Browser console logs +- **Network Tab**: All HTTP requests made during tests +- **Tests Tab**: Test suite structure and results (pass/fail) +- **Metadata Tab**: Session information and test timing +- **Sources Tab**: Test file sources +- **Logs Tab**: Framework logs and debugging information + +## Zero Test Changes Required + +Notice the test files in `example/tests/` have **zero DevTools-specific code**. They're pure Nightwatch tests. The plugin automatically: +- Hooks into Nightwatch's lifecycle +- Captures all test data +- Sends it to the DevTools backend +- Updates the UI in real-time + +## Configuration + +### Minimal (Default) + +```javascript +// nightwatch.conf.cjs +module.exports = { + plugins: ['@wdio/nightwatch-devtools'] +} +``` + +### Custom Port + +```javascript +module.exports = { + plugins: [ + ['@wdio/nightwatch-devtools', { + port: 4000, + hostname: 'localhost' + }] + ] +} +``` + +## Troubleshooting + +### "Failed to connect to ChromeDriver" + +Make sure chromedriver is installed: +```bash +pnpm install chromedriver +# Then rebuild it +pnpm rebuild chromedriver +``` + +Or install globally: +```bash +npm install -g chromedriver +``` + +### "Module not found" + +Make sure you've built the plugin: +```bash +pnpm build +``` + +### Port Already in Use + +Change the port in your config: +```javascript +plugins: [ + ['@wdio/nightwatch-devtools', { port: 4000 }] +] +``` diff --git a/packages/nightwatch-devtools/example/nightwatch.conf.cjs b/packages/nightwatch-devtools/example/nightwatch.conf.cjs new file mode 100644 index 0000000..76a8ca4 --- /dev/null +++ b/packages/nightwatch-devtools/example/nightwatch.conf.cjs @@ -0,0 +1,29 @@ +// Simple import - just require the package +const nightwatchDevtools = require('@wdio/nightwatch-devtools').default; + +module.exports = { + src_folders: ['example/tests'], + + // Add custom reporter to capture commands + custom_commands_path: [], + custom_assertions_path: [], + + webdriver: { + start_process: true, + server_path: '/opt/homebrew/bin/chromedriver', + port: 9515 + }, + + test_settings: { + default: { + desiredCapabilities: { + browserName: 'chrome', + 'goog:chromeOptions': { + args: ['--headless', '--no-sandbox', '--disable-dev-shm-usage'] + } + }, + // Simple configuration - just call the function to get globals + globals: nightwatchDevtools({ port: 3000 }) + } + } +}; diff --git a/packages/nightwatch-devtools/example/nightwatch.conf.js b/packages/nightwatch-devtools/example/nightwatch.conf.js new file mode 100644 index 0000000..b261e70 --- /dev/null +++ b/packages/nightwatch-devtools/example/nightwatch.conf.js @@ -0,0 +1,361 @@ +// +// Refer to the online docs for more details: +// https://nightwatchjs.org/guide/configuration/nightwatch-configuration-file.html +// +// _ _ _ _ _ _ _ +// | \ | |(_) | | | | | | | | +// | \| | _ __ _ | |__ | |_ __ __ __ _ | |_ ___ | |__ +// | . ` || | / _` || '_ \ | __|\ \ /\ / / / _` || __| / __|| '_ \ +// | |\ || || (_| || | | || |_ \ V V / | (_| || |_ | (__ | | | | +// \_| \_/|_| \__, ||_| |_| \__| \_/\_/ \__,_| \__| \___||_| |_| +// __/ | +// |___/ +// + +module.exports = { + // An array of folders (excluding subfolders) where your tests are located; + // if this is not specified, the test source must be passed as the second argument to the test runner. + src_folders: [], + + // See https://nightwatchjs.org/guide/concepts/page-object-model.html + page_objects_path: ['node_modules/nightwatch/examples/pages/'], + + // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-commands.html + custom_commands_path: ['node_modules/nightwatch/examples/custom-commands/'], + + // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-assertions.html + custom_assertions_path: '', + + // See https://nightwatchjs.org/guide/extending-nightwatch/adding-plugins.html + plugins: [], + + // See https://nightwatchjs.org/guide/concepts/test-globals.html#external-test-globals + globals_path : '', + + // Set this to true to disable bounding boxes on terminal output. Useful when running in some CI environments. + disable_output_boxes: false, + + webdriver: {}, + + test_workers: { + enabled: true, + workers: 'auto' + }, + + test_settings: { + default: { + disable_error_log: false, + launch_url: 'https://nightwatchjs.org', + + screenshots: { + enabled: false, + path: 'screens', + on_failure: true + }, + + desiredCapabilities: { + browserName : 'firefox' + }, + + webdriver: { + start_process: true, + server_path: '' + } + }, + + safari: { + desiredCapabilities : { + browserName : 'safari', + alwaysMatch: { + acceptInsecureCerts: false + } + }, + webdriver: { + start_process: true, + server_path: '' + } + }, + + firefox: { + desiredCapabilities : { + browserName : 'firefox', + acceptInsecureCerts: true, + 'moz:firefoxOptions': { + args: [ + // '-headless', + // '-verbose' + ] + } + }, + webdriver: { + start_process: true, + server_path: '', + cli_args: [ + // very verbose geckodriver logs + // '-vv' + ] + } + }, + + chrome: { + desiredCapabilities : { + browserName : 'chrome', + 'goog:chromeOptions' : { + // More info on Chromedriver: https://sites.google.com/a/chromium.org/chromedriver/ + // + // w3c:false tells Chromedriver to run using the legacy JSONWire protocol (not required in Chrome 78) + w3c: true, + args: [ + //'--no-sandbox', + //'--ignore-certificate-errors', + //'--allow-insecure-localhost', + //'--headless' + ] + } + }, + + webdriver: { + start_process: true, + server_path: '', + cli_args: [ + // '--verbose' + ] + } + }, + + edge: { + desiredCapabilities : { + browserName : 'MicrosoftEdge', + 'ms:edgeOptions' : { + w3c: true, + // More info on EdgeDriver: https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/capabilities-edge-options + args: [ + //'--headless' + ] + } + }, + + webdriver: { + start_process: true, + // Download msedgedriver from https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/ + // and set the location below: + server_path: '', + cli_args: [ + // '--verbose' + ] + } + }, + + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using cucumber-js (https://cucumber.io) | + // | + // It uses the bundled examples inside the nightwatch examples folder; feel free | + // to adapt this to your own project needs | + ////////////////////////////////////////////////////////////////////////////////// + 'cucumber-js': { + src_folders: ['examples/cucumber-js/features/step_definitions'], + + test_runner: { + // set cucumber as the runner + type: 'cucumber', + + // define cucumber specific options + options: { + //set the feature path + feature_path: 'node_modules/nightwatch/examples/cucumber-js/*/*.feature', + + // start the webdriver session automatically (enabled by default) + // auto_start_session: true + + // use parallel execution in Cucumber + // workers: 2 // set number of workers to use (can also be defined in the cli as --workers=2 + } + } + }, + + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using the browserstack.com cloud service | + // | + // Please set the username and access key by setting the environment variables: | + // - BROWSERSTACK_USERNAME | + // - BROWSERSTACK_ACCESS_KEY | + // .env files are supported | + ////////////////////////////////////////////////////////////////////////////////// + browserstack: { + selenium: { + host: 'hub.browserstack.com', + port: 443 + }, + // More info on configuring capabilities can be found on: + // https://www.browserstack.com/automate/capabilities?tag=selenium-4 + desiredCapabilities: { + 'bstack:options' : { + userName: '${BROWSERSTACK_USERNAME}', + accessKey: '${BROWSERSTACK_ACCESS_KEY}', + } + }, + + disable_error_log: true, + webdriver: { + timeout_options: { + timeout: 60000, + retry_attempts: 3 + }, + keep_alive: true, + start_process: false + } + }, + + 'browserstack.local': { + extends: 'browserstack', + desiredCapabilities: { + 'browserstack.local': true + } + }, + + 'browserstack.chrome': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'chrome', + chromeOptions : { + w3c: true + } + } + }, + + 'browserstack.firefox': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'firefox' + } + }, + + 'browserstack.ie': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'internet explorer', + browserVersion: '11.0' + } + }, + + 'browserstack.safari': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'safari' + } + }, + + 'browserstack.local_chrome': { + extends: 'browserstack.local', + desiredCapabilities: { + browserName: 'chrome' + } + }, + + 'browserstack.local_firefox': { + extends: 'browserstack.local', + desiredCapabilities: { + browserName: 'firefox' + } + }, + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using the SauceLabs cloud service | + // | + // Please set the username and access key by setting the environment variables: | + // - SAUCE_USERNAME | + // - SAUCE_ACCESS_KEY | + ////////////////////////////////////////////////////////////////////////////////// + saucelabs: { + selenium: { + host: 'ondemand.saucelabs.com', + port: 443 + }, + // More info on configuring capabilities can be found on: + // https://docs.saucelabs.com/dev/test-configuration-options/ + desiredCapabilities: { + 'sauce:options' : { + username: '${SAUCE_USERNAME}', + accessKey: '${SAUCE_ACCESS_KEY}', + screenResolution: '1280x1024' + // https://docs.saucelabs.com/dev/cli/sauce-connect-proxy/#--region + // region: 'us-west-1' + // https://docs.saucelabs.com/dev/test-configuration-options/#tunnelidentifier + // parentTunnel: '', + // tunnelIdentifier: '', + } + }, + disable_error_log: false, + webdriver: { + start_process: false + } + }, + 'saucelabs.chrome': { + extends: 'saucelabs', + desiredCapabilities: { + browserName: 'chrome', + browserVersion: 'latest', + javascriptEnabled: true, + acceptSslCerts: true, + timeZone: 'London', + chromeOptions : { + w3c: true + } + } + }, + 'saucelabs.firefox': { + extends: 'saucelabs', + desiredCapabilities: { + browserName: 'firefox', + browserVersion: 'latest', + javascriptEnabled: true, + acceptSslCerts: true, + timeZone: 'London' + } + }, + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using the Selenium service, either locally or remote, | + // like Selenium Grid | + ////////////////////////////////////////////////////////////////////////////////// + selenium_server: { + // Selenium Server is running locally and is managed by Nightwatch + // Install the NPM package @nightwatch/selenium-server or download the selenium server jar file from https://github.com/SeleniumHQ/selenium/releases/, e.g.: selenium-server-4.1.1.jar + selenium: { + start_process: true, + port: 4444, + server_path: '', // Leave empty if @nightwatch/selenium-server is installed + command: 'standalone', // Selenium 4 only + cli_args: { + //'webdriver.gecko.driver': '', + //'webdriver.chrome.driver': '' + } + }, + webdriver: { + start_process: false, + default_path_prefix: '/wd/hub' + } + }, + + 'selenium.chrome': { + extends: 'selenium_server', + desiredCapabilities: { + browserName: 'chrome', + chromeOptions : { + w3c: true + } + } + }, + + 'selenium.firefox': { + extends: 'selenium_server', + desiredCapabilities: { + browserName: 'firefox', + 'moz:firefoxOptions': { + args: [ + // '-headless', + // '-verbose' + ] + } + } + } + } +}; diff --git a/packages/nightwatch-devtools/example/tests/login.test.js b/packages/nightwatch-devtools/example/tests/login.test.js new file mode 100644 index 0000000..e2efcf0 --- /dev/null +++ b/packages/nightwatch-devtools/example/tests/login.test.js @@ -0,0 +1,43 @@ +describe('The Internet Guinea Pig Website', function() { + + it('should log into the secure area with valid credentials', async function(browser) { + console.log('[TEST] Navigating to login page') + browser + .url('https://the-internet.herokuapp.com/login') + .waitForElementVisible('body') + + console.log('[TEST] Attempting login with username: tomsmith') + await browser + .setValue('#username', 'tomsmith') + .setValue('#password', 'SuperSecretPassword!') + .click('button[type="submit"]') + + console.log('[TEST] Verifying flash message: You logged into a secure area!') + await browser + .waitForElementVisible('#flash') + .assert.textContains('#flash', 'You logged into a secure area!') + + console.log('[TEST] Flash message verified successfully') + }) + + it('should show error with invalid credentials', async function(browser) { + console.log('[TEST] Navigating to login page') + await browser + .url('https://the-internet.herokuapp.com/login') + .waitForElementVisible('body') + + console.log('[TEST] Attempting login with username: foobar') + await browser + .setValue('#username', 'foobar') + .setValue('#password', 'barfoo') + .click('button[type="submit"]') + + console.log('[TEST] Verifying flash message: Your username is invalid!') + await browser + .waitForElementVisible('.flash', 5000) + .assert.textContains('.flash', 'Your username is invalid!') + + console.log('[TEST] Flash message verified successfully') + }) + +}) diff --git a/packages/nightwatch-devtools/example/tests/sample.test.js b/packages/nightwatch-devtools/example/tests/sample.test.js new file mode 100644 index 0000000..fe135e3 --- /dev/null +++ b/packages/nightwatch-devtools/example/tests/sample.test.js @@ -0,0 +1,23 @@ +describe('Sample Nightwatch Test with DevTools', function() { + + it('should navigate to example.com and check title', async function(browser) { + await browser + .url('https://example.com') + .waitForElementVisible('body', 5000) + .assert.titleContains('Example') + .assert.visible('h1') + + const result = await browser.getText('h1') + browser.assert.ok(result.includes('Example'), 'H1 contains "Example"') + }); + + it('should perform basic interactions', async function(browser) { + await browser + .url('https://www.google.com') + .waitForElementVisible('body', 5000) + .assert.visible('textarea[name="q"]') + .setValue('textarea[name="q"]', 'WebdriverIO DevTools') + .pause(1000) + }); + +}); diff --git a/packages/nightwatch-devtools/example/validate.cjs b/packages/nightwatch-devtools/example/validate.cjs new file mode 100644 index 0000000..109b0cb --- /dev/null +++ b/packages/nightwatch-devtools/example/validate.cjs @@ -0,0 +1,59 @@ +/** + * Quick validation script to test the plugin without running actual browser tests + * This verifies the plugin can be loaded and instantiated correctly + */ + +const path = require('path'); + +async function validate() { + console.log('šŸ” Validating Nightwatch DevTools Plugin...\n'); + + try { + // Check if dist exists + const distPath = path.join(__dirname, '..', 'dist'); + require('fs').accessSync(distPath); + console.log('āœ… Plugin compiled (dist/ exists)'); + + // Try to load the plugin + const plugin = require(path.join(__dirname, '..', 'dist', 'index.js')); + console.log('āœ… Plugin module loaded'); + + // Check if it exports a class + if (typeof plugin.default === 'function') { + console.log('āœ… Plugin exports default class'); + + // Try to instantiate it + const instance = new plugin.default({ port: 3001 }); + console.log('āœ… Plugin can be instantiated'); + + // Check for required methods + const requiredMethods = ['before', 'beforeSuite', 'beforeEach', 'afterEach', 'after']; + const hasAllMethods = requiredMethods.every(method => typeof instance[method] === 'function'); + + if (hasAllMethods) { + console.log('āœ… All required lifecycle methods present:', requiredMethods.join(', ')); + } else { + console.log('āŒ Missing some lifecycle methods'); + return false; + } + + console.log('\n✨ Plugin validation successful!'); + console.log('\nNext steps:'); + console.log('1. Make sure Chrome/Chromium is installed'); + console.log('2. Run: pnpm rebuild chromedriver'); + console.log('3. Run: pnpm example'); + + return true; + } else { + console.log('āŒ Plugin does not export a class'); + return false; + } + } catch (error) { + console.error('āŒ Validation failed:', error.message); + return false; + } +} + +validate().then(success => { + process.exit(success ? 0 : 1); +}); diff --git a/packages/nightwatch-devtools/nightwatch.conf.cjs b/packages/nightwatch-devtools/nightwatch.conf.cjs new file mode 100644 index 0000000..b261e70 --- /dev/null +++ b/packages/nightwatch-devtools/nightwatch.conf.cjs @@ -0,0 +1,361 @@ +// +// Refer to the online docs for more details: +// https://nightwatchjs.org/guide/configuration/nightwatch-configuration-file.html +// +// _ _ _ _ _ _ _ +// | \ | |(_) | | | | | | | | +// | \| | _ __ _ | |__ | |_ __ __ __ _ | |_ ___ | |__ +// | . ` || | / _` || '_ \ | __|\ \ /\ / / / _` || __| / __|| '_ \ +// | |\ || || (_| || | | || |_ \ V V / | (_| || |_ | (__ | | | | +// \_| \_/|_| \__, ||_| |_| \__| \_/\_/ \__,_| \__| \___||_| |_| +// __/ | +// |___/ +// + +module.exports = { + // An array of folders (excluding subfolders) where your tests are located; + // if this is not specified, the test source must be passed as the second argument to the test runner. + src_folders: [], + + // See https://nightwatchjs.org/guide/concepts/page-object-model.html + page_objects_path: ['node_modules/nightwatch/examples/pages/'], + + // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-commands.html + custom_commands_path: ['node_modules/nightwatch/examples/custom-commands/'], + + // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-assertions.html + custom_assertions_path: '', + + // See https://nightwatchjs.org/guide/extending-nightwatch/adding-plugins.html + plugins: [], + + // See https://nightwatchjs.org/guide/concepts/test-globals.html#external-test-globals + globals_path : '', + + // Set this to true to disable bounding boxes on terminal output. Useful when running in some CI environments. + disable_output_boxes: false, + + webdriver: {}, + + test_workers: { + enabled: true, + workers: 'auto' + }, + + test_settings: { + default: { + disable_error_log: false, + launch_url: 'https://nightwatchjs.org', + + screenshots: { + enabled: false, + path: 'screens', + on_failure: true + }, + + desiredCapabilities: { + browserName : 'firefox' + }, + + webdriver: { + start_process: true, + server_path: '' + } + }, + + safari: { + desiredCapabilities : { + browserName : 'safari', + alwaysMatch: { + acceptInsecureCerts: false + } + }, + webdriver: { + start_process: true, + server_path: '' + } + }, + + firefox: { + desiredCapabilities : { + browserName : 'firefox', + acceptInsecureCerts: true, + 'moz:firefoxOptions': { + args: [ + // '-headless', + // '-verbose' + ] + } + }, + webdriver: { + start_process: true, + server_path: '', + cli_args: [ + // very verbose geckodriver logs + // '-vv' + ] + } + }, + + chrome: { + desiredCapabilities : { + browserName : 'chrome', + 'goog:chromeOptions' : { + // More info on Chromedriver: https://sites.google.com/a/chromium.org/chromedriver/ + // + // w3c:false tells Chromedriver to run using the legacy JSONWire protocol (not required in Chrome 78) + w3c: true, + args: [ + //'--no-sandbox', + //'--ignore-certificate-errors', + //'--allow-insecure-localhost', + //'--headless' + ] + } + }, + + webdriver: { + start_process: true, + server_path: '', + cli_args: [ + // '--verbose' + ] + } + }, + + edge: { + desiredCapabilities : { + browserName : 'MicrosoftEdge', + 'ms:edgeOptions' : { + w3c: true, + // More info on EdgeDriver: https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/capabilities-edge-options + args: [ + //'--headless' + ] + } + }, + + webdriver: { + start_process: true, + // Download msedgedriver from https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/ + // and set the location below: + server_path: '', + cli_args: [ + // '--verbose' + ] + } + }, + + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using cucumber-js (https://cucumber.io) | + // | + // It uses the bundled examples inside the nightwatch examples folder; feel free | + // to adapt this to your own project needs | + ////////////////////////////////////////////////////////////////////////////////// + 'cucumber-js': { + src_folders: ['examples/cucumber-js/features/step_definitions'], + + test_runner: { + // set cucumber as the runner + type: 'cucumber', + + // define cucumber specific options + options: { + //set the feature path + feature_path: 'node_modules/nightwatch/examples/cucumber-js/*/*.feature', + + // start the webdriver session automatically (enabled by default) + // auto_start_session: true + + // use parallel execution in Cucumber + // workers: 2 // set number of workers to use (can also be defined in the cli as --workers=2 + } + } + }, + + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using the browserstack.com cloud service | + // | + // Please set the username and access key by setting the environment variables: | + // - BROWSERSTACK_USERNAME | + // - BROWSERSTACK_ACCESS_KEY | + // .env files are supported | + ////////////////////////////////////////////////////////////////////////////////// + browserstack: { + selenium: { + host: 'hub.browserstack.com', + port: 443 + }, + // More info on configuring capabilities can be found on: + // https://www.browserstack.com/automate/capabilities?tag=selenium-4 + desiredCapabilities: { + 'bstack:options' : { + userName: '${BROWSERSTACK_USERNAME}', + accessKey: '${BROWSERSTACK_ACCESS_KEY}', + } + }, + + disable_error_log: true, + webdriver: { + timeout_options: { + timeout: 60000, + retry_attempts: 3 + }, + keep_alive: true, + start_process: false + } + }, + + 'browserstack.local': { + extends: 'browserstack', + desiredCapabilities: { + 'browserstack.local': true + } + }, + + 'browserstack.chrome': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'chrome', + chromeOptions : { + w3c: true + } + } + }, + + 'browserstack.firefox': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'firefox' + } + }, + + 'browserstack.ie': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'internet explorer', + browserVersion: '11.0' + } + }, + + 'browserstack.safari': { + extends: 'browserstack', + desiredCapabilities: { + browserName: 'safari' + } + }, + + 'browserstack.local_chrome': { + extends: 'browserstack.local', + desiredCapabilities: { + browserName: 'chrome' + } + }, + + 'browserstack.local_firefox': { + extends: 'browserstack.local', + desiredCapabilities: { + browserName: 'firefox' + } + }, + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using the SauceLabs cloud service | + // | + // Please set the username and access key by setting the environment variables: | + // - SAUCE_USERNAME | + // - SAUCE_ACCESS_KEY | + ////////////////////////////////////////////////////////////////////////////////// + saucelabs: { + selenium: { + host: 'ondemand.saucelabs.com', + port: 443 + }, + // More info on configuring capabilities can be found on: + // https://docs.saucelabs.com/dev/test-configuration-options/ + desiredCapabilities: { + 'sauce:options' : { + username: '${SAUCE_USERNAME}', + accessKey: '${SAUCE_ACCESS_KEY}', + screenResolution: '1280x1024' + // https://docs.saucelabs.com/dev/cli/sauce-connect-proxy/#--region + // region: 'us-west-1' + // https://docs.saucelabs.com/dev/test-configuration-options/#tunnelidentifier + // parentTunnel: '', + // tunnelIdentifier: '', + } + }, + disable_error_log: false, + webdriver: { + start_process: false + } + }, + 'saucelabs.chrome': { + extends: 'saucelabs', + desiredCapabilities: { + browserName: 'chrome', + browserVersion: 'latest', + javascriptEnabled: true, + acceptSslCerts: true, + timeZone: 'London', + chromeOptions : { + w3c: true + } + } + }, + 'saucelabs.firefox': { + extends: 'saucelabs', + desiredCapabilities: { + browserName: 'firefox', + browserVersion: 'latest', + javascriptEnabled: true, + acceptSslCerts: true, + timeZone: 'London' + } + }, + ////////////////////////////////////////////////////////////////////////////////// + // Configuration for when using the Selenium service, either locally or remote, | + // like Selenium Grid | + ////////////////////////////////////////////////////////////////////////////////// + selenium_server: { + // Selenium Server is running locally and is managed by Nightwatch + // Install the NPM package @nightwatch/selenium-server or download the selenium server jar file from https://github.com/SeleniumHQ/selenium/releases/, e.g.: selenium-server-4.1.1.jar + selenium: { + start_process: true, + port: 4444, + server_path: '', // Leave empty if @nightwatch/selenium-server is installed + command: 'standalone', // Selenium 4 only + cli_args: { + //'webdriver.gecko.driver': '', + //'webdriver.chrome.driver': '' + } + }, + webdriver: { + start_process: false, + default_path_prefix: '/wd/hub' + } + }, + + 'selenium.chrome': { + extends: 'selenium_server', + desiredCapabilities: { + browserName: 'chrome', + chromeOptions : { + w3c: true + } + } + }, + + 'selenium.firefox': { + extends: 'selenium_server', + desiredCapabilities: { + browserName: 'firefox', + 'moz:firefoxOptions': { + args: [ + // '-headless', + // '-verbose' + ] + } + } + } + } +}; diff --git a/packages/nightwatch-devtools/package.json b/packages/nightwatch-devtools/package.json new file mode 100644 index 0000000..7a9621d --- /dev/null +++ b/packages/nightwatch-devtools/package.json @@ -0,0 +1,44 @@ +{ + "name": "@wdio/nightwatch-devtools", + "version": "0.1.0", + "description": "Nightwatch adapter for WebdriverIO DevTools - reuses existing backend, UI, and capture infrastructure", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "nightwatch": { + "plugin": true + }, + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "clean": "rm -rf dist", + "validate": "node example/validate.cjs", + "example": "nightwatch -c example/nightwatch.conf.cjs" + }, + "keywords": [ + "nightwatch", + "devtools", + "debugging", + "testing" + ], + "author": "WebdriverIO Team", + "license": "MIT", + "dependencies": { + "@wdio/devtools-backend": "workspace:*", + "@wdio/logger": "^9.6.0", + "import-meta-resolve": "^4.2.0", + "stacktrace-parser": "^0.1.10", + "webdriverio": "^9.18.0", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/ws": "^8.18.1", + "chromedriver": "^133.0.0", + "nightwatch": "^3.0.0", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "nightwatch": ">=3.0.0" + } +} diff --git a/packages/nightwatch-devtools/src/capturePerformance.ts b/packages/nightwatch-devtools/src/capturePerformance.ts new file mode 100644 index 0000000..c64ca09 --- /dev/null +++ b/packages/nightwatch-devtools/src/capturePerformance.ts @@ -0,0 +1,56 @@ +/** + * Script to capture performance data, cookies, and document info from the browser + * This gets injected and executed in the browser context + */ + +/** + * Returns the script as a string to be executed in the browser + */ +export const getCapturePerformanceScript = (): string => { + return ` + (function() { + const performance = window.performance; + const navigation = performance.getEntriesByType?.('navigation')?.[0]; + const resources = performance.getEntriesByType?.('resource') || []; + + return { + navigation: navigation ? { + url: window.location.href, + timing: { + loadTime: navigation.loadEventEnd - navigation.fetchStart, + domReady: navigation.domContentLoadedEventEnd - navigation.fetchStart, + responseTime: navigation.responseEnd - navigation.requestStart, + dnsLookup: navigation.domainLookupEnd - navigation.domainLookupStart, + tcpConnection: navigation.connectEnd - navigation.connectStart, + serverResponse: navigation.responseEnd - navigation.responseStart + } + } : undefined, + resources: resources.map(function(resource) { + return { + url: resource.name, + duration: resource.duration, + size: resource.transferSize || 0, + type: resource.initiatorType, + startTime: resource.startTime, + responseEnd: resource.responseEnd + }; + }), + cookies: document.cookie, + documentInfo: { + url: window.location.href, + title: document.title, + headers: { + userAgent: navigator.userAgent, + language: navigator.language, + platform: navigator.platform + }, + documentInfo: { + readyState: document.readyState, + referrer: document.referrer, + characterSet: document.characterSet + } + } + }; + })() + ` +} diff --git a/packages/nightwatch-devtools/src/constants.ts b/packages/nightwatch-devtools/src/constants.ts new file mode 100644 index 0000000..fde9876 --- /dev/null +++ b/packages/nightwatch-devtools/src/constants.ts @@ -0,0 +1,75 @@ +export const PAGE_TRANSITION_COMMANDS = [ + 'url', + 'navigateTo', + 'click', + 'submitForm' +] as const + +/** + * Internal Nightwatch commands to exclude from capture + * These are helper/platform detection commands not relevant to users + */ +export const INTERNAL_COMMANDS_TO_IGNORE = [ + 'isAppiumClient', + 'isSafari', + 'isChrome', + 'isFirefox', + 'isEdge', + 'isMobile', + 'isAndroid', + 'isIOS', + 'session', + 'sessions', + 'timeouts', + 'timeoutsAsyncScript', + 'timeoutsImplicitWait', + 'getLog', + 'getLogTypes', + 'screenshot', + 'availableContexts', + 'currentContext', + 'setChromeOptions', + 'setDeviceName', + 'perform', // Internal command queue executor + 'execute', // We'll filter our own performance capture scripts + 'executeAsync', + 'executeScript' // Used internally for performance data capture +] as const + +/** + * Console method types for log capturing + */ +export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const + +/** + * Log level detection patterns + */ +export const LOG_LEVEL_PATTERNS: ReadonlyArray<{ + level: 'trace' | 'debug' | 'info' | 'warn' | 'error' + pattern: RegExp +}> = [ + { level: 'trace', pattern: /\btrace\b/i }, + { level: 'debug', pattern: /\bdebug\b/i }, + { level: 'info', pattern: /\binfo\b/i }, + { level: 'warn', pattern: /\bwarn(ing)?\b/i }, + { level: 'error', pattern: /\berror\b/i } +] as const + +/** + * Console log source types + */ +export const LOG_SOURCES = { + BROWSER: 'browser', + TEST: 'test', + TERMINAL: 'terminal' +} as const + +/** + * ANSI escape code regex - matches all ANSI escape sequences including: + * - Color codes: \x1b[36m, \x1b[39m + * - Cursor control: \x1b[2K, \x1b[1G, \x1b[1A + * - Cursor visibility: \x1b[?25l, \x1b[?25h + * - SGR parameters: \x1b[1m, \x1b[22m + * Pattern: ESC [ (optional ?)(digits/semicolons)(letter) + */ +export const ANSI_REGEX = /\x1b\[[?]?[0-9;]*[A-Za-z]/g diff --git a/packages/nightwatch-devtools/src/index.ts b/packages/nightwatch-devtools/src/index.ts new file mode 100644 index 0000000..7747b36 --- /dev/null +++ b/packages/nightwatch-devtools/src/index.ts @@ -0,0 +1,781 @@ +/** + * Nightwatch DevTools Plugin + * + * Integrates Nightwatch with WebdriverIO DevTools following the WDIO service pattern. + * Captures commands, network requests, and console logs during test execution in real-time. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import { start, stop } from '@wdio/devtools-backend' +import logger from '@wdio/logger' +import { remote } from 'webdriverio' +import { SessionCapturer } from './session.js' +import { TestReporter } from './reporter.js' +import { TraceType, type DevToolsOptions, type NightwatchBrowser } from './types.js' +import { INTERNAL_COMMANDS_TO_IGNORE } from './constants.js' +import { findTestFileFromStack, findTestFileByName, extractTestMetadata, getCallSourceFromStack } from './utils.js' + +const log = logger('@wdio/nightwatch-devtools') + +class NightwatchDevToolsPlugin { + private options: Required + private sessionCapturer!: SessionCapturer + private testReporter!: TestReporter + private isScriptInjected = false + #currentSuiteByFile = new Map() + #currentTest: any = null + #currentTestFile: string | null = null + #currentTestFullPath: string | null = null // Store full path from callSource + #processedTests = new Map>() // Track which tests have been created per suite + #browserProxied = false + #lastSessionId: string | null = null // Track browser session changes + #devtoolsBrowser?: WebdriverIO.Browser + + constructor(options: DevToolsOptions = {}) { + this.options = { + port: options.port ?? 3000, + hostname: options.hostname ?? 'localhost' + } + } + + /** + * Nightwatch Hook: before (global) + * Start the DevTools backend server + */ + async before() { + try { + log.info('šŸš€ Starting DevTools backend...') + const server = await start(this.options) + + // Fastify's server doesn't have addresses() until after listen() + // The port is already set in options after start() completes + const url = `http://${this.options.hostname}:${this.options.port}` + log.info(`āœ“ Backend started on port ${this.options.port}`) + log.info(``) + log.info(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`) + log.info(` 🌐 Opening DevTools UI in browser...`) + log.info(``) + log.info(` ${url}`) + log.info(``) + log.info(` ā³ Waiting for UI to connect...`) + log.info(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`) + log.info(``) + + // Open DevTools UI in a separate browser window using WDIO's method + try { + this.#devtoolsBrowser = await remote({ + logLevel: 'silent', + automationProtocol: 'devtools', + capabilities: { + browserName: 'chrome', + 'goog:chromeOptions': { + args: ['--window-size=1600,1200'] + } + } + }) + + await this.#devtoolsBrowser.url(url) + log.info('āœ“ DevTools UI opened in separate browser window') + } catch (err) { + log.error(`Failed to open DevTools UI: ${(err as Error).message}`) + log.info(`Please manually open: ${url}`) + } + + // Wait for UI to connect + log.info('Waiting 10 seconds for UI to connect...') + await new Promise(resolve => setTimeout(resolve, 10000)) + + log.info('Starting tests...') + + + } catch (err) { + log.error(`Failed to start backend: ${(err as Error).message}`) + throw err + } + } + + /** + * Nightwatch Hook: beforeEach + * Initialize session and inject script before each test + */ + async beforeEach(browser: NightwatchBrowser) { + // Check if browser session changed (new session = new test file) + const currentSessionId = (browser as any).sessionId + if (currentSessionId && this.#lastSessionId && currentSessionId !== this.#lastSessionId) { + this.#browserProxied = false + } + this.#lastSessionId = currentSessionId + + // Initialize on first test + if (!this.sessionCapturer) { + // Wait a bit more for WebSocket to be ready on first test + await new Promise(resolve => setTimeout(resolve, 500)) + + this.sessionCapturer = new SessionCapturer({ + port: this.options.port, + hostname: this.options.hostname + }, browser) + + // Wait for WebSocket to connect before proceeding + const connected = await this.sessionCapturer.waitForConnection(3000) + if (!connected) { + log.error('āŒ Worker WebSocket failed to connect!') + } else { + log.info('āœ“ Worker WebSocket connected') + } + + // TestReporter callback sends suites data upstream in WDIO format + this.testReporter = new TestReporter((suitesData: any) => { + if (this.sessionCapturer) { + // suitesData is already in WDIO format: [{ uid: {...suite} }] + this.sessionCapturer.sendUpstream('suites', suitesData) + } + }) + log.info('āœ“ Session initialized') + + // Send initial metadata + const capabilities = browser.capabilities || {} + this.sessionCapturer.sendUpstream('metadata', { + type: TraceType.Testrunner, + capabilities, + options: {}, + url: '' + }) + } + + // Get current test info - use WDIO DevTools approach with stack trace + const currentTest = (browser as any).currentTest + if (currentTest) { + const testFile = (currentTest.module || '').split('/').pop() || currentTest.module || 'unknown' + + // Reset #currentTestFullPath if we're starting a new test file + if (this.#currentTestFile !== testFile) { + this.#currentTestFullPath = null + } + + // Find test file: try stack trace first, then search workspace + let fullPath = findTestFileFromStack() || this.#currentTestFullPath + + if (!fullPath && testFile) { + // Try searching common test directories + const workspaceRoot = process.cwd() + const possiblePaths = [ + path.join(workspaceRoot, 'example/tests', testFile + '.js'), + path.join(workspaceRoot, 'example/tests', testFile), + path.join(workspaceRoot, 'tests', testFile + '.js'), + path.join(workspaceRoot, 'test', testFile + '.js'), + path.join(workspaceRoot, testFile + '.js'), + ] + + for (const possiblePath of possiblePaths) { + if (fs.existsSync(possiblePath)) { + fullPath = possiblePath + break + } + } + } + + // Extract suite title and test metadata + let suiteTitle = testFile + let testNames: string[] = [] + if (fullPath) { + const metadata = extractTestMetadata(fullPath) + if (metadata.suiteTitle) { + suiteTitle = metadata.suiteTitle + } + testNames = metadata.testNames + + // Log extracted metadata for debugging + console.log(`[METADATA] Suite: "${suiteTitle}", Tests: [${testNames.map(t => `"${t}"`).join(', ')}]`) + } + + // Create/update suite for this test file + if (!this.#currentSuiteByFile.has(testFile)) { + const suiteStats = { + uid: '', // Will be set by generateStableUid + cid: '0-0', + title: suiteTitle, + fullTitle: suiteTitle, + file: fullPath || testFile, + type: 'suite' as const, + start: new Date(), + end: null, // Will be set when all tests complete + tests: [] as any[], + suites: [], + hooks: [], + _duration: 0 + } + + this.testReporter.onSuiteStart(suiteStats) + this.#currentSuiteByFile.set(testFile, suiteStats) + + // Capture source file for display in Source tab + if (fullPath && fullPath.includes('/')) { + this.sessionCapturer.captureSource(fullPath).catch(() => { + // Silently ignore source capture errors + }) + } + + // Add all tests with pending state (like WDIO does) + if (testNames.length > 0) { + for (const testName of testNames) { + const testUid = `${suiteStats.uid}::${testName}` + const testEntry: any = { + uid: testUid, + cid: '0-0', + title: testName, + fullTitle: `${suiteTitle} ${testName}`, + parent: suiteStats.uid, + state: 'pending' as const, + start: new Date(), + end: null, + type: 'test' as const, + file: fullPath || testFile, + retries: 0, + _duration: 0, + hooks: [] + } + suiteStats.tests.push(testEntry) + } + this.testReporter.updateSuites() + } + } + + // Find which test is about to run (first unprocessed test) + const currentSuite = this.#currentSuiteByFile.get(testFile)! + if (!this.#processedTests.has(testFile)) { + this.#processedTests.set(testFile, new Set()) + } + const processedForSuite = this.#processedTests.get(testFile)! + + // Find first test that hasn't been processed yet and mark it as running + let currentTestName = testNames.find(name => !processedForSuite.has(name)) + + if (currentTestName) { + const testIndex = currentSuite.tests.findIndex( + (t: any) => typeof t !== 'string' && t.title === currentTestName + ) + if (testIndex !== -1) { + currentSuite.tests[testIndex].state = 'running' + currentSuite.tests[testIndex].start = new Date() + currentSuite.tests[testIndex].end = null + // Send onTestStart for the first test + this.testReporter.onTestStart(currentSuite.tests[testIndex]) + console.log(`[STATE] Test "${currentTestName}" → RUNNING`) + // Small delay to let UI render the spinner before test starts executing + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + // Create temporary test for command tracking + const uniqueTempUid = `${currentSuite.uid}::temp-${Date.now()}-${Math.random().toString(36).substring(7)}` + + this.#currentTest = { + uid: uniqueTempUid, + cid: '0-0', + title: currentTestName || 'test', + fullTitle: currentTestName || 'test', + parent: currentSuite.uid, + state: 'running' as const, + start: new Date(), + end: new Date(), + type: 'test' as const, + file: fullPath || testFile, + retries: 0, + _duration: 0, + hooks: [] + } + + // Store reference to current suite for command tracking + this.#currentTestFile = testFile + + const originalUrl = browser.url.bind(browser) + const sessionCapturer = this.sessionCapturer + + browser.url = function(url: string) { + const result = originalUrl(url) as any + + if (result && typeof result.perform === 'function') { + result.perform(async function(this: any) { + try { + log.info(`Injecting script after navigation to: ${url}`) + await sessionCapturer.injectScript(this) + } catch (err) { + log.error(`Failed to inject script: ${(err as Error).message}`) + } + }) + } + + return result + } as any + + this.isScriptInjected = true + log.info('āœ“ Script injection wrapped') + } + + // Use Proxy to intercept ALL browser commands + if (browser && !this.#browserProxied) { + const self = this + const sessionCapturer = this.sessionCapturer + const browserAny = browser as any + + // Get ALL methods - both own properties and prototype + const allMethods = new Set([ + ...Object.keys(browser), + ...Object.getOwnPropertyNames(Object.getPrototypeOf(browser)) + ]) + const wrappedMethods: string[] = [] + + allMethods.forEach(methodName => { + if (methodName === 'constructor' || typeof browserAny[methodName] !== 'function') { + return + } + + // Skip internal Nightwatch commands + if (INTERNAL_COMMANDS_TO_IGNORE.includes(methodName as any)) { + return + } + + // Skip methods starting with __ (internal methods) + if (methodName.startsWith('__')) { + return + } + + const originalMethod = browserAny[methodName].bind(browser) + + browserAny[methodName] = function(...args: any[]) { + // Get call stack using WDIO DevTools approach + const callInfo = getCallSourceFromStack() + const callSource = callInfo.callSource + + // Update #currentTestFullPath if we found a valid file + if (callInfo.filePath && !self.#currentTestFullPath) { + self.#currentTestFullPath = callInfo.filePath + } + + try { + // Execute the command + const result = originalMethod(...args) + + // For commands that return promises, handle result when it resolves + if (result && typeof result.then === 'function') { + result.then(async (actualResult: any) => { + if (!self.#currentTest || !sessionCapturer) { + return + } + + // Extract the actual value from the resolved result + let extractedValue: any = undefined + if (actualResult && typeof actualResult === 'object' && 'value' in actualResult) { + extractedValue = actualResult.value + } else if (actualResult !== undefined && actualResult !== result && actualResult !== browser && actualResult !== browserAny) { + extractedValue = actualResult + } + + // Check if this command was already captured in the immediate capture path + const lastCommand = sessionCapturer.commandsLog[sessionCapturer.commandsLog.length - 1] + const recentlyCaptured = lastCommand && + lastCommand.command === methodName && + lastCommand.timestamp > Date.now() - 2000 && + JSON.stringify(lastCommand.args) === JSON.stringify(args) + + if (recentlyCaptured) { + // Command was already captured, just update result if needed and it doesn't have performance data + const hasPerformanceData = lastCommand.result && typeof lastCommand.result === 'object' && 'resources' in lastCommand.result + if (extractedValue !== undefined && !hasPerformanceData && lastCommand.result === undefined) { + lastCommand.result = extractedValue + sessionCapturer.sendUpstream('commands', [lastCommand]) + } + } else { + // Command wasn't captured yet (was stored as pending), capture it now + const pending = (sessionCapturer as any)._pendingCommand + if (pending && pending.methodName === methodName) { + await sessionCapturer.captureCommand( + methodName, + pending.args, + extractedValue, + undefined, + pending.testUid, + pending.callSource, + pending.timestamp + ).catch(() => {}) + delete (sessionCapturer as any)._pendingCommand + } + } + }).catch(() => { + // Ignore promise rejection - error will be captured by original handler + }) + } + + // Serialize result safely (avoid circular references and Nightwatch API objects) + let serializedResult: any = undefined + const isBrowserObject = result === browser || result === browserAny + const isChainableAPI = result && typeof result === 'object' && ('queue' in result || 'sessionId' in result || 'capabilities' in result) + + if (isBrowserObject || isChainableAPI) { + // For commands that return browser object, check if they should be tracked + const isWaitCommand = methodName.startsWith('waitFor') + // Capture waitFor commands immediately with success marker, others as undefined (will get updated by promise) + serializedResult = isWaitCommand ? true : undefined + } else if (result && typeof result === 'object') { + if ('value' in result) { + serializedResult = result.value + } else { + try { + serializedResult = JSON.parse(JSON.stringify(result)) + } catch { + serializedResult = String(result) + } + } + } else if (result !== undefined) { + serializedResult = result + } + + // Capture command immediately if we have a result, OR if it's a special command that should be tracked + const isSpecialCommand = ['pause', 'url', 'navigate', 'navigateTo', 'click', 'setValue'].some(cmd => + methodName.toLowerCase().includes(cmd.toLowerCase()) + ) + const shouldCaptureNow = serializedResult !== undefined || isSpecialCommand + + if (self.#currentTest && sessionCapturer) { + if (shouldCaptureNow) { + // Capture immediately (result may be undefined and will be updated by promise) + sessionCapturer.captureCommand( + methodName, + args, + serializedResult, + undefined, + self.#currentTest.uid, + callSource + ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) + } else { + // Store as pending - will be captured when promise resolves + (sessionCapturer as any)._pendingCommand = { + methodName, + args, + callSource, + timestamp: Date.now(), + testUid: self.#currentTest.uid + } + } + } + + return result + } catch (error) { + // Capture command with error + if (self.#currentTest && sessionCapturer) { + sessionCapturer.captureCommand( + methodName, + args, + undefined, // no result + error instanceof Error ? error : new Error(String(error)), // ensure it's an Error object + self.#currentTest.uid, + callSource + ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) + } + + throw error + } + } + + wrappedMethods.push(methodName) + }) + + // Keep proxy active for all tests + if (!this.#browserProxied) { + this.#browserProxied = true + log.info(`āœ“ Wrapped ${wrappedMethods.length} browser methods`) + log.info(` Methods: ${wrappedMethods.slice(0, 20).join(', ')}${wrappedMethods.length > 20 ? '...' : ''}`) + } + } + } + + /** + * Nightwatch Hook: afterEach + * Capture trace data after each test + */ + async afterEach(browser: NightwatchBrowser) { + if (browser && this.sessionCapturer) { + try { + // Update test stats with result + if (this.#currentTest) { + const currentTest = (browser as any).currentTest + const results = currentTest?.results || {} + const testFile = (currentTest.module || '').split('/').pop() || 'unknown' + + // Extract actual test name from results.testcases + // BUT: results.testcases contains ALL tests in the suite, not just the current one + // We need to find which test just finished + const testcases = results.testcases || {} + const testcaseNames = Object.keys(testcases) + + // Find the test that matches the current temp UID's commands + // or just process the last test if we can't determine + const currentSuite = this.#currentSuiteByFile.get(testFile) + if (currentSuite && testcaseNames.length > 0) { + // Get or create set of processed tests for this suite + if (!this.#processedTests.has(testFile)) { + this.#processedTests.set(testFile, new Set()) + } + const processedForSuite = this.#processedTests.get(testFile)! + + // Process ALL unprocessed tests + const unprocessedTests = testcaseNames.filter(name => !processedForSuite.has(name)) + + // Process completed tests one at a time with delays + for (const currentTestName of unprocessedTests) { + const testcase = testcases[currentTestName] + const testState: 'passed' | 'failed' = (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' + const finalTestUid = `${currentSuite.uid}::${currentTestName}` + + // Map all commands from temp UID to final UID + const tempUid = this.#currentTest.uid + if (tempUid && tempUid !== finalTestUid) { + const commandsToUpdate: any[] = [] + this.sessionCapturer.commandsLog.forEach(cmd => { + if (cmd.testUid === tempUid) { + cmd.testUid = finalTestUid + commandsToUpdate.push(cmd) + } + }) + } + + // Find existing test and update it + const testIndex = currentSuite.tests.findIndex( + (t: any) => typeof t !== 'string' && t.title === currentTestName + ) + + if (testIndex !== -1) { + // Update existing test with final state + currentSuite.tests[testIndex].state = testState + currentSuite.tests[testIndex].end = new Date() + currentSuite.tests[testIndex]._duration = parseFloat(testcase.time || '0') * 1000 + currentSuite.tests[testIndex].uid = finalTestUid + + console.log(`[STATE] Test "${currentTestName}" → ${testState.toUpperCase()}`) + + // Report final state + this.testReporter.onTestEnd(currentSuite.tests[testIndex]) + } else { + // Test not found, add it (shouldn't happen) + const testStats = { + uid: finalTestUid, + cid: '0-0', + title: currentTestName, + fullTitle: `${currentSuite.title} ${currentTestName}`, + parent: currentSuite.uid, + state: testState, + start: this.#currentTest.start, + end: new Date(), + type: 'test' as const, + file: currentTest.module || testFile, + retries: 0, + _duration: parseFloat(testcase.time || '0') * 1000, + hooks: [] + } + currentSuite.tests.push(testStats) + } + + // Mark as processed + processedForSuite.add(currentTestName) + } + + // After processing all tests, check if suite is complete + if (processedForSuite.size === testcaseNames.length) { + // All tests in this suite are complete + currentSuite.end = new Date() + currentSuite._duration = currentSuite.end.getTime() - (currentSuite.start?.getTime() || 0) + const allPassed = currentSuite.tests.every((t: any) => t.state === 'passed') + currentSuite.state = allPassed ? 'passed' : 'failed' + console.log(`[STATE] Suite "${currentSuite.title}" → ${currentSuite.state.toUpperCase()} (${currentSuite.tests.length} tests)`) + this.testReporter.onSuiteEnd(currentSuite) + + // Give UI time to process suite completion before next suite starts + await new Promise(resolve => setTimeout(resolve, 200)) + } else { + // There are more tests to run - mark next as running + const nextTestName = testcaseNames.find(name => !processedForSuite.has(name)) + if (nextTestName) { + const nextTestIndex = currentSuite.tests.findIndex( + (t: any) => typeof t !== 'string' && t.title === nextTestName + ) + if (nextTestIndex !== -1) { + currentSuite.tests[nextTestIndex].state = 'running' + currentSuite.tests[nextTestIndex].start = new Date() + currentSuite.tests[nextTestIndex].end = null + console.log(`[STATE] Test "${nextTestName}" → RUNNING`) + this.testReporter.onTestStart(currentSuite.tests[nextTestIndex]) + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + } + } + } + + // Capture trace data from browser + await this.sessionCapturer.captureTrace(browser) + } catch (err) { + log.error(`Failed to capture trace: ${(err as Error).message}`) + } + } + } + + /** + * Nightwatch Hook: after (global) + * Keep the application alive until the browser window is closed + */ + async after(browser?: NightwatchBrowser) { + try { + // Process any remaining incomplete suites + for (const [testFile, suite] of this.#currentSuiteByFile.entries()) { + const processedTests = this.#processedTests.get(testFile) || new Set() + + // Mark any tests still in "running" state as passed (they completed successfully if we're here) + // For pending tests, check if they actually ran by looking at Nightwatch results + const currentTest = (browser as any)?.currentTest + const results = currentTest?.results || {} + const testcases = results.testcases || {} + const actualTestNames = Object.keys(testcases) + + suite.tests.forEach((test: any) => { + if (test.state === 'running' && test.start) { + // Test was started but never finished - assume passed + test.state = 'passed' + test.end = new Date() + test._duration = test.end.getTime() - (test.start?.getTime() || 0) + console.log(`[STATE] Test "${test.title}" → PASSED (finalized)`) + this.testReporter.onTestEnd(test) + } else if (test.state === 'pending') { + // Check if this test actually ran + const testcase = testcases[test.title] + if (testcase) { + // Test ran but we didn't track it properly - update it now + const testState: 'passed' | 'failed' = (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' + test.state = testState + test.start = test.start || new Date() + test.end = new Date() + test._duration = parseFloat(testcase.time || '0') * 1000 + console.log(`[STATE] Test "${test.title}" → ${testState.toUpperCase()} (from results)`) + this.testReporter.onTestEnd(test) + } else { + // Test was never actually run - skip it + test.state = 'skipped' + test.end = new Date() + test._duration = 0 + console.log(`[STATE] Test "${test.title}" → SKIPPED (never started)`) + this.testReporter.onTestEnd(test) + } + } + }) + + // Give UI time to process test completions + await new Promise(resolve => setTimeout(resolve, 200)) + + // Now mark suite as complete + if (!suite.end) { + // Mark suite as complete + suite.end = new Date() + suite._duration = suite.end.getTime() - (suite.start?.getTime() || 0) + const allPassed = suite.tests.every((t: any) => t.state === 'passed') + suite.state = allPassed ? 'passed' : 'failed' + console.log(`[STATE] Suite "${suite.title}" → ${suite.state.toUpperCase()} (${suite.tests.length} tests)`) + this.testReporter.onSuiteEnd(suite) + } + } + + // Give UI time to process all final updates before showing completion message + await new Promise(resolve => setTimeout(resolve, 200)) + + log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + log.info('āœ… Tests complete!') + log.info('šŸ’” Please close the DevTools browser window to exit') + log.info(' Or press Ctrl+C to force exit') + log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + + // Keep polling until the WebSocket connection is closed + // This indicates the browser window was closed + await this.#waitForBrowserClose() + + // Close devtools browser if still open + if (this.#devtoolsBrowser) { + try { + await this.#devtoolsBrowser.deleteSession() + } catch { + // Already closed + } + } + + log.info('šŸ›‘ Stopping DevTools backend...') + await stop() + log.info('āœ“ Backend stopped') + } catch (err) { + log.error(`Failed to stop backend: ${(err as Error).message}`) + } + } + + /** + * Wait for browser window to close by polling browser status (WDIO method) + */ + async #waitForBrowserClose() { + if (!this.#devtoolsBrowser) { + return + } + + return new Promise((resolve) => { + // Poll browser every second to check if it's still open + const checkInterval = setInterval(async () => { + try { + await this.#devtoolsBrowser!.getTitle() + // Browser is still open, continue waiting + } catch { + // Browser closed + clearInterval(checkInterval) + log.info('āœ“ Browser window closed') + resolve() + } + }, 1000) + + // Also handle Ctrl+C gracefully + const sigintHandler = () => { + clearInterval(checkInterval) + log.info('\nāœ“ Received exit signal (Ctrl+C)') + resolve() + } + + process.once('SIGINT', sigintHandler) + process.once('SIGTERM', sigintHandler) + }) + } +} + +/** + * Factory function to create the plugin instance + * This allows simple usage: require('@wdio/nightwatch-devtools').default + */ +export default function createNightwatchDevTools(options?: DevToolsOptions) { + const plugin = new NightwatchDevToolsPlugin(options) + + return { + // Set long timeout to allow user to review DevTools UI + // The after() hook will wait for browser window to close + asyncHookTimeout: 3600000, // 1 hour + + before: async function(this: any) { + await plugin.before() + }, + beforeEach: async function(this: any, browser: NightwatchBrowser) { + await plugin.beforeEach(browser) + }, + afterEach: async function(this: any, browser: NightwatchBrowser) { + await plugin.afterEach(browser) + }, + after: async function(this: any) { + await plugin.after() + } + } +} + +// Also export the class for advanced usage +export { NightwatchDevToolsPlugin } + diff --git a/packages/nightwatch-devtools/src/reporter.ts b/packages/nightwatch-devtools/src/reporter.ts new file mode 100644 index 0000000..46e3e14 --- /dev/null +++ b/packages/nightwatch-devtools/src/reporter.ts @@ -0,0 +1,241 @@ +import logger from '@wdio/logger' +import { extractTestMetadata } from './utils.js' +import type { SuiteStats, TestStats } from './types.js' + +const log = logger('@wdio/nightwatch-devtools:Reporter') + +// Track test occurrences to generate stable UIDs +const signatureCounters = new Map() + +/** + * Generate stable UID based on test/suite metadata + */ +function generateStableUid(item: SuiteStats | TestStats): string { + const signature = `${item.file}::${item.fullTitle}` + const currentCount = signatureCounters.get(signature) || 0 + signatureCounters.set(signature, currentCount + 1) + + return currentCount > 0 + ? `${signature}::${currentCount}` + : signature +} + +/** + * Reset counters at the start of each test run + */ +function resetSignatureCounters() { + signatureCounters.clear() +} + +export class TestReporter { + #report: (data: any) => void + #currentSpecFile?: string + #testNamesCache = new Map() + #currentSuite?: SuiteStats + #allSuites: SuiteStats[] = [] + + constructor(report: (data: any) => void) { + this.#report = report + resetSignatureCounters() + } + + /** + * Generate stable UID for test/suite - public method + */ + generateStableUid(filePath: string, name: string): string { + const signature = `${filePath}::${name}` + const currentCount = signatureCounters.get(signature) || 0 + signatureCounters.set(signature, currentCount + 1) + + return currentCount > 0 + ? `${signature}::${currentCount}` + : signature + } + + /** + * Called when a suite starts + */ + onSuiteStart(suiteStats: SuiteStats) { + this.#currentSpecFile = suiteStats.file + this.#currentSuite = suiteStats + + // Generate stable UID + suiteStats.uid = generateStableUid(suiteStats) + + // Extract test names from source file + if (suiteStats.file && !this.#testNamesCache.has(suiteStats.file)) { + const metadata = extractTestMetadata(suiteStats.file) + const testNames = metadata.testNames + if (testNames.length > 0) { + this.#testNamesCache.set(suiteStats.file, testNames) + log.info(`šŸ“ Extracted ${testNames.length} test names from ${suiteStats.file}`) + } + } + + this.#allSuites.push(suiteStats) + this.#sendUpstream() + } + + /** + * Update the suites data (send to UI) + */ + updateSuites() { + this.#sendUpstream() + } + + /** + * Called when a test starts + */ + onTestStart(testStats: TestStats) { + // Generate stable UID if not already set + if (!testStats.uid || testStats.uid.includes('temp-')) { + testStats.uid = generateStableUid(testStats) + } + + // Update existing test in current suite (don't add duplicates) + if (this.#currentSuite) { + const testIndex = this.#currentSuite.tests.findIndex( + (t) => typeof t !== 'string' && t.title === testStats.title + ) + if (testIndex !== -1) { + // Update existing test + this.#currentSuite.tests[testIndex] = testStats + } else { + // Test not found, add it (legacy behavior) + this.#currentSuite.tests.push(testStats) + } + } + + this.#sendUpstream() + } + + /** + * Called when a test ends + */ + onTestEnd(testStats: TestStats) { + // Update the test in current suite + if (this.#currentSuite) { + const testIndex = this.#currentSuite.tests.findIndex( + (t) => (typeof t === 'string' ? t : t.uid) === testStats.uid + ) + if (testIndex !== -1) { + this.#currentSuite.tests[testIndex] = testStats + } + } + + this.#sendUpstream() + } + + /** + * Called when a test passes + */ + onTestPass(testStats: TestStats) { + // Update the test in current suite + if (this.#currentSuite) { + const testIndex = this.#currentSuite.tests.findIndex( + (t) => (typeof t === 'string' ? t : t.uid) === testStats.uid + ) + if (testIndex !== -1) { + this.#currentSuite.tests[testIndex] = testStats + } + } + + this.#sendUpstream() + } + + /** + * Called when a test fails + */ + onTestFail(testStats: TestStats) { + // Update the test in current suite + if (this.#currentSuite) { + const testIndex = this.#currentSuite.tests.findIndex( + (t) => (typeof t === 'string' ? t : t.uid) === testStats.uid + ) + if (testIndex !== -1) { + this.#currentSuite.tests[testIndex] = testStats + } + } + + this.#sendUpstream() + } + + /** + * Called when a suite ends - create skipped tests + */ + onSuiteEnd(suiteStats: SuiteStats) { + // Get all test names from cache + const cachedNames = this.#testNamesCache.get(suiteStats.file) || [] + const processedTestNames = new Set( + suiteStats.tests + .map((t) => (typeof t === 'string' ? t : t.title)) + .filter((title): title is string => Boolean(title)) + ) + + // Create skipped tests for tests that didn't run + cachedNames.forEach((testName) => { + if (!processedTestNames.has(testName)) { + const skippedTest: TestStats = { + uid: generateStableUid({ + file: suiteStats.file, + fullTitle: `${suiteStats.title} ${testName}` + } as TestStats), + cid: '0-0', + title: testName, + fullTitle: `${suiteStats.title} ${testName}`, + parent: suiteStats.uid, + state: 'skipped', + start: new Date(), + end: new Date(), + type: 'test', + file: suiteStats.file, + retries: 0, + _duration: 0, + hooks: [] + } + + suiteStats.tests.push(skippedTest) + log.info(`Created skipped test "${testName}" (never executed)`) + } + }) + + this.#sendUpstream() + } + + /** + * Update a specific suite and send to UI (used when updating suite title) + */ + updateSuite(suiteStats: SuiteStats) { + // Find and remove the old suite by file + const index = this.#allSuites.findIndex(s => s.file === suiteStats.file) + if (index !== -1) { + // Remove the old suite entry (with old UID) + this.#allSuites.splice(index, 1) + } + // Add the updated suite with new UID + this.#allSuites.push(suiteStats) + // Update current suite reference + this.#currentSuite = suiteStats + this.#sendUpstream() + } + + #sendUpstream() { + // Convert suites to WDIO format: array of objects with UID as key + const payload: Record[] = [] + + for (const suite of this.#allSuites) { + if (suite && suite.uid) { + // Each suite becomes an object with its UID as the key + payload.push({ [suite.uid]: suite }) + } + } + + if (payload.length > 0) { + this.#report(payload) + } + } + + get report() { + return this.#allSuites + } +} diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts new file mode 100644 index 0000000..00fd989 --- /dev/null +++ b/packages/nightwatch-devtools/src/session.ts @@ -0,0 +1,564 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import logger from '@wdio/logger' +import { WebSocket } from 'ws' +import { CONSOLE_METHODS, LOG_SOURCES, ANSI_REGEX, LOG_LEVEL_PATTERNS } from './constants.js' +import type { CommandLog, ConsoleLog, LogLevel, NightwatchBrowser } from './types.js' +import { getCapturePerformanceScript } from './capturePerformance.js' + +const require = createRequire(import.meta.url) +const log = logger('@wdio/nightwatch-devtools:SessionCapturer') + +/** + * Strip ANSI escape codes from text + */ +const stripAnsiCodes = (text: string): string => text.replace(ANSI_REGEX, '') + +/** + * Detect log level from text content + */ +const detectLogLevel = (text: string): LogLevel => { + const cleanText = stripAnsiCodes(text).toLowerCase() + + for (const { level, pattern } of LOG_LEVEL_PATTERNS) { + if (pattern.test(cleanText)) { + return level + } + } + + return 'log' +} + +/** + * Create a console log entry + */ +const createConsoleLogEntry = ( + type: LogLevel, + args: any[], + source: string +): ConsoleLog => ({ + timestamp: Date.now(), + type, + args, + source +}) + +export class SessionCapturer { + #ws: WebSocket | undefined + #originalConsoleMethods: Record< + (typeof CONSOLE_METHODS)[number], + typeof console.log + > + #originalProcessMethods: { + stdoutWrite: typeof process.stdout.write + stderrWrite: typeof process.stderr.write + } + #isCapturingConsole = false + #browser: NightwatchBrowser | undefined + #lastCommandSig: string | null = null + + commandsLog: CommandLog[] = [] + sources = new Map() + consoleLogs: ConsoleLog[] = [] + mutations: any[] = [] + traceLogs: string[] = [] + networkRequests: any[] = [] + metadata?: any + + constructor(devtoolsOptions: { hostname?: string; port?: number } = {}, browser?: NightwatchBrowser) { + const { port, hostname } = devtoolsOptions + this.#browser = browser + if (hostname && port) { + this.#ws = new WebSocket(`ws://${hostname}:${port}/worker`) + + this.#ws.on('open', () => { + log.info('āœ“ Worker WebSocket connected to backend') + }) + + this.#ws.on('error', (err: unknown) => + log.error( + `Couldn't connect to devtools backend: ${(err as Error).message}` + ) + ) + + this.#ws.on('close', () => { + log.info('Worker WebSocket disconnected') + }) + } + + this.#originalConsoleMethods = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error + } + + this.#originalProcessMethods = { + stdoutWrite: process.stdout.write.bind(process.stdout), + stderrWrite: process.stderr.write.bind(process.stderr) + } + + this.#patchConsole() + this.#interceptProcessStreams() + } + + #patchConsole() { + // Temporarily disable console patching to prevent infinite loops + // We can re-enable this later with better filtering + return + + CONSOLE_METHODS.forEach((method) => { + const originalMethod = this.#originalConsoleMethods[method] + console[method] = (...consoleArgs: any[]) => { + // Skip capturing if we're already in a capture operation (prevent infinite recursion) + if (this.#isCapturingConsole) { + return originalMethod.apply(console, consoleArgs) + } + + // Skip capturing internal framework logs (logger messages) + const firstArg = String(consoleArgs[0] || '') + if (firstArg.includes('@wdio/') || firstArg.includes('INFO') || firstArg.includes('WARN') || firstArg.includes('ERROR')) { + return originalMethod.apply(console, consoleArgs) + } + + const serializedArgs = consoleArgs.map((arg) => + typeof arg === 'object' && arg !== null + ? (() => { + try { + return JSON.stringify(arg) + } catch { + return String(arg) + } + })() + : String(arg) + ) + + // Set flag before capturing to prevent recursion + this.#isCapturingConsole = true + + const logEntry = createConsoleLogEntry( + method, + serializedArgs, + LOG_SOURCES.TEST + ) + this.consoleLogs.push(logEntry) + this.sendUpstream('consoleLogs', [logEntry]) + + const result = originalMethod.apply(console, consoleArgs) + + // Reset flag after everything is done + this.#isCapturingConsole = false + return result + } + }) + } + + #interceptProcessStreams() { + // Temporarily disable stream interception to prevent infinite loops + // We can re-enable this later with better filtering + return + + // Regex to detect spinner/progress characters + const spinnerRegex = /[\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f]/ + + const captureTerminalOutput = (outputData: string | Uint8Array) => { + const outputText = + typeof outputData === 'string' ? outputData : outputData.toString() + if (!outputText?.trim()) { + return + } + + outputText + .split('\n') + .filter((line) => line.trim()) + .forEach((line) => { + // Skip lines with spinner characters to avoid flooding logs + if (spinnerRegex.test(line)) { + return + } + + // Strip ANSI codes and check if there's actual content + const cleanedLine = stripAnsiCodes(line).trim() + if (!cleanedLine) { + return + } + + const logEntry = createConsoleLogEntry( + detectLogLevel(cleanedLine), + [cleanedLine], + LOG_SOURCES.TERMINAL + ) + this.consoleLogs.push(logEntry) + this.sendUpstream('consoleLogs', [logEntry]) + }) + } + + const interceptStreamWrite = ( + stream: NodeJS.WriteStream, + originalWriteMethod: (...args: any[]) => boolean + ) => { + const capturer = this + stream.write = function (chunk: any, ...additionalArgs: any[]): boolean { + const writeResult = originalWriteMethod.call( + stream, + chunk, + ...additionalArgs + ) + if (chunk && !capturer.#isCapturingConsole) { + captureTerminalOutput(chunk) + } + return writeResult + } as any + } + + interceptStreamWrite( + process.stdout, + this.#originalProcessMethods.stdoutWrite + ) + interceptStreamWrite( + process.stderr, + this.#originalProcessMethods.stderrWrite + ) + } + + #restoreConsole() { + CONSOLE_METHODS.forEach((method) => { + console[method] = this.#originalConsoleMethods[method] + }) + } + + cleanup() { + this.#restoreConsole() + } + + get isReportingUpstream() { + return Boolean(this.#ws) && this.#ws?.readyState === WebSocket.OPEN + } + + /** + * Wait for WebSocket to connect + */ + async waitForConnection(timeoutMs: number = 5000): Promise { + if (!this.#ws) { + return false + } + + if (this.#ws.readyState === WebSocket.OPEN) { + return true + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + log.warn(`WebSocket connection timeout after ${timeoutMs}ms`) + resolve(false) + }, timeoutMs) + + this.#ws!.once('open', () => { + clearTimeout(timeout) + resolve(true) + }) + + this.#ws!.once('error', () => { + clearTimeout(timeout) + resolve(false) + }) + }) + } + + /** + * Capture a command execution + * @returns true if command was captured, false if it was skipped as a duplicate + */ + async captureCommand( + command: string, + args: any[], + result: any, + error: Error | undefined, + testUid?: string, + callSource?: string, + timestamp?: number + ): Promise { + // Create command signature to detect duplicate captures (e.g., on retries) + const cmdSig = JSON.stringify({ + command, + args, + src: callSource + }) + + // Skip if this is the same command as the last one (retry scenario) + if (this.#lastCommandSig === cmdSig) { + return false + } + + // Update last command signature + this.#lastCommandSig = cmdSig + + // Serialize error properly (Error objects don't JSON.stringify well) + const serializedError = error ? { + name: error.name, + message: error.message, + stack: error.stack + } : undefined + + const commandLogEntry: CommandLog = { + command, + args, + result, + error: serializedError as any, + timestamp: timestamp || Date.now(), + callSource, + testUid + } + + // Capture performance data for navigation commands (url, navigate, etc.) + const isNavigationCommand = ['url', 'navigate', 'navigateTo'].some(cmd => + command.toLowerCase().includes(cmd.toLowerCase()) + ) + + if (isNavigationCommand && this.#browser && !error) { + try { + // Wait a bit for page to load + await new Promise(resolve => setTimeout(resolve, 500)) + + // Execute script to capture performance data + // Nightwatch's execute() requires a function, not a string + const performanceData = await this.#browser.execute(function() { + // @ts-ignore - executed in browser context + const performance = window.performance; + // @ts-ignore + const navigation = performance.getEntriesByType?.('navigation')?.[0]; + // @ts-ignore + const resources = performance.getEntriesByType?.('resource') || []; + + return { + navigation: navigation ? { + // @ts-ignore + url: window.location.href, + timing: { + loadTime: navigation.loadEventEnd - navigation.fetchStart, + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart, + firstPaint: performance.getEntriesByType?.('paint')?.[0]?.startTime || 0 + } + } : null, + resources: resources.map((r: any) => ({ + name: r.name, + type: r.initiatorType, + size: r.transferSize || r.decodedBodySize || 0, + duration: r.duration + })), + // @ts-ignore + cookies: document.cookie, + documentInfo: { + // @ts-ignore + title: document.title, + // @ts-ignore + url: window.location.href, + // @ts-ignore + referrer: document.referrer + } + }; + }) + + // Nightwatch returns{value: result} or just the result directly + let data: any + if (performanceData && typeof performanceData === 'object') { + // Check if it has a 'value' property (WebDriver format) + if ('value' in performanceData) { + data = (performanceData as any).value + } else { + // It might be the data directly + data = performanceData + } + } + + if (data && data.navigation) { + commandLogEntry.performance = { + navigation: data.navigation, + resources: data.resources + } + commandLogEntry.cookies = data.cookies + commandLogEntry.documentInfo = data.documentInfo + + // Always set result with performance data for consistency + commandLogEntry.result = { + url: args[0], + loadTime: data.navigation?.timing?.loadTime, + resources: data.resources, + resourceCount: data.resources?.length, + cookies: data.cookies, + title: data.documentInfo?.title + } + + console.log(`āœ“ Captured performance data: ${data.resources?.length || 0} resources, load time: ${data.navigation?.timing?.loadTime || 0}ms`) + } + } catch (err) { + console.log(`āš ļø Failed to capture performance data: ${(err as Error).message}`) + } + } + + this.commandsLog.push(commandLogEntry) + this.sendUpstream('commands', [commandLogEntry]) + return true + } + + /** + * Capture test source code + */ + async captureSource(filePath: string) { + if (!this.sources.has(filePath)) { + try { + const sourceCode = await fs.readFile(filePath, 'utf-8') + this.sources.set(filePath, sourceCode.toString()) + this.sendUpstream('sources', { [filePath]: sourceCode.toString() }) + } catch (err) { + log.warn(`Failed to read source file ${filePath}: ${(err as Error).message}`) + } + } + } + + /** + * Send data upstream to backend + */ + sendUpstream(event: string, data: any) { + // Check if WebSocket is open and ready + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + // Use ORIGINAL process methods to avoid infinite recursion from console patching + this.#originalProcessMethods.stderrWrite(`[SESSION] WebSocket not ready (state: ${this.#ws?.readyState}), cannot send ${event}\n`) + return + } + + try { + // IMPORTANT: WDIO backend expects {scope, data} format, NOT {event, data} + const payload = JSON.stringify({ scope: event, data }) + // Use ORIGINAL process methods instead of console.log to avoid recursion + this.#originalProcessMethods.stdoutWrite(`[SESSION] Sending ${event} upstream, data size: ${payload.length} bytes\n`) + this.#ws.send(payload) + } catch (err) { + this.#originalProcessMethods.stderrWrite(`[SESSION] Failed to send ${event}: ${(err as Error).message}\n`) + } + } + + /** + * Check if WebSocket connection is still open + * Used by the after() hook to wait for browser window close + */ + isConnected(): boolean { + return this.#ws?.readyState === WebSocket.OPEN + } + + /** + * Inject the WDIO devtools script into the browser page + */ + async injectScript(browser: NightwatchBrowser) { + try { + // Load the preload script + const scriptPath = require.resolve('@wdio/devtools-script') + const scriptDir = path.dirname(scriptPath) + const preloadScriptPath = path.join(scriptDir, 'script.js') + let scriptContent = await fs.readFile(preloadScriptPath, 'utf-8') + + log.info(`Script path: ${preloadScriptPath}`) + log.info(`Script size: ${scriptContent.length} bytes`) + + // The script contains top-level await - wrap the entire script in async IIFE before injection + scriptContent = `(async function() { ${scriptContent} })()` + + // Inject using script element - synchronous check after timeout + const injectionScript = ` + const script = document.createElement('script'); + script.textContent = arguments[0]; + document.head.appendChild(script); + return true; + ` + + const injectResult = await browser.execute(injectionScript, [scriptContent]) + log.info(`Injection command executed: ${JSON.stringify(injectResult)}`) + + // Wait for script to execute + await browser.pause(300) + + // Check if collector exists using string-based execute + const checkScript = 'return typeof window.wdioTraceCollector !== "undefined"' + const checkResult = await browser.execute(checkScript) + + // Nightwatch wraps results in { value: ... } + const hasCollector = (checkResult as any)?.value === true + + log.info(`Collector check result: ${JSON.stringify(checkResult)}, hasCollector: ${hasCollector}`) + + if (hasCollector) { + log.info('āœ“ Devtools script injected successfully') + } else { + log.warn(`Script injection may have failed - collector not found`) + } + } catch (err) { + log.error(`Failed to inject script: ${(err as Error).message}`) + throw err + } + } + + /** + * Capture trace data from the browser (network requests, console logs, etc.) + */ + async captureTrace(browser: NightwatchBrowser) { + try { + // Check if the collector exists in the browser - access .value + const checkResult = await browser.execute('return typeof window.wdioTraceCollector !== "undefined"') + const collectorExists = (checkResult as any)?.value === true + + if (!collectorExists) { + log.warn('wdioTraceCollector not found - script may not have been injected') + return + } + + // Get trace data from the collector - access .value + const result = await browser.execute(` + if (typeof window.wdioTraceCollector === 'undefined') { + return null; + } + return window.wdioTraceCollector.getTraceData(); + `) + + const traceData = (result as any)?.value + + if (!traceData) { + return + } + + const { mutations, traceLogs, consoleLogs, networkRequests, metadata } = traceData + + // Send network requests + if (Array.isArray(networkRequests) && networkRequests.length > 0) { + this.networkRequests.push(...networkRequests) + this.sendUpstream('networkRequests', networkRequests) + log.info(`āœ“ Captured ${networkRequests.length} network requests`) + } + + // Send console logs from browser + if (Array.isArray(consoleLogs) && consoleLogs.length > 0) { + this.consoleLogs.push(...consoleLogs) + this.sendUpstream('consoleLogs', consoleLogs) + } + + // Send mutations + if (Array.isArray(mutations) && mutations.length > 0) { + this.mutations.push(...mutations) + this.sendUpstream('mutations', mutations) + } + + // Send trace logs + if (Array.isArray(traceLogs) && traceLogs.length > 0) { + this.traceLogs.push(...traceLogs) + this.sendUpstream('logs', traceLogs) + } + + // Update metadata + if (metadata) { + this.metadata = { ...this.metadata, ...metadata } + } + } catch (err) { + log.error(`Failed to capture trace: ${(err as Error).message}`) + } + } +} diff --git a/packages/nightwatch-devtools/src/types.ts b/packages/nightwatch-devtools/src/types.ts new file mode 100644 index 0000000..15a96b0 --- /dev/null +++ b/packages/nightwatch-devtools/src/types.ts @@ -0,0 +1,158 @@ +export interface PerformanceData { + navigation?: { + url: string + timing: { + loadTime?: number + domReady?: number + responseTime?: number + dnsLookup?: number + tcpConnection?: number + serverResponse?: number + } + } + resources?: Array<{ + url: string + duration: number + size: number + type: string + startTime: number + responseEnd: number + }> +} + +export interface DocumentInfo { + url: string + title: string + headers: { + userAgent: string + language: string + platform: string + } + documentInfo: { + readyState: string + referrer: string + characterSet: string + } +} + +export interface CommandLog { + command: string + args: any[] + result?: any + error?: Error + timestamp: number + callSource?: string + screenshot?: string + testUid?: string + performance?: PerformanceData + cookies?: string + documentInfo?: DocumentInfo +} + +export enum TraceType { + Testrunner = 'testrunner' +} + +export type LogLevel = 'trace' | 'debug' | 'log' | 'info' | 'warn' | 'error' + +export interface ConsoleLog { + timestamp: number + type: LogLevel + args: any[] + source: string +} + +export interface TestStats { + uid: string + cid: string + title: string + fullTitle: string + parent: string + state: 'passed' | 'failed' | 'skipped' | 'pending' + start: Date + end: Date + type: 'test' + file: string + retries: number + _duration: number + error?: Error + hooks?: any[] +} + +export interface SuiteStats { + uid: string + cid: string + title: string + fullTitle: string + type: 'suite' + file: string + start: Date + tests: (string | TestStats)[] + suites: SuiteStats[] + hooks: any[] + _duration: number +} + +export interface Metadata { + type: TraceType + url?: string + options?: any + capabilities?: any + viewport?: any +} + +export interface TraceLog { + mutations: any[] + logs: string[] + consoleLogs: ConsoleLog[] + networkRequests: any[] + metadata: Metadata + commands: CommandLog[] + sources: Record + suites: Record[] +} + +export interface DevToolsOptions { + port?: number + hostname?: string +} + +export interface NightwatchBrowser { + url: (url: string) => Promise + execute: (script: string | Function, args?: any[]) => Promise + executeAsync: (script: Function, args?: any[]) => Promise + pause: (ms: number) => Promise + capabilities?: Record + sessionId?: string + driver?: any +} + +export interface NetworkRequest { + id: string + url: string + method: string + headers?: Record + cookies?: any[] + status?: number + statusText?: string + timestamp: number + startTime: number + endTime?: number + time?: number + type: string + requestHeaders?: Record + responseHeaders?: Record + navigation?: string + redirectChain?: any[] + children?: NetworkRequest[] + response?: { + fromCache: boolean + headers: Record + mimeType: string + status: number + } + error?: string + requestBody?: string + responseBody?: string + size?: number +} diff --git a/packages/nightwatch-devtools/src/utils.ts b/packages/nightwatch-devtools/src/utils.ts new file mode 100644 index 0000000..f53a700 --- /dev/null +++ b/packages/nightwatch-devtools/src/utils.ts @@ -0,0 +1,180 @@ +/** + * Utility functions for test file discovery and metadata extraction + * Based on WDIO DevTools approach + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import { parse as parseStackTrace } from 'stacktrace-parser' + +// File patterns for test file identification +const SPEC_FILE_PATTERN = /\/(test|spec|tests)\//i +const SPEC_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i + +/** + * Find test file from stack trace (WDIO DevTools approach) + * Parses call stack to find the first frame that looks like a test file + */ +export function findTestFileFromStack(): string | undefined { + const stack = new Error().stack + if (!stack) { + return + } + + const frames = parseStackTrace(stack) + const testFrame = frames.find(frame => { + const file = frame.file + return file && + !file.includes('/node_modules/') && + !file.includes('') && + !file.includes('node:internal') && + !file.includes('/dist/') && + !file.includes('/index.js') && + (SPEC_FILE_PATTERN.test(file) || SPEC_FILE_RE.test(file)) + }) + + if (testFrame && testFrame.file) { + let filePath = testFrame.file + // Strip file:// protocol if present + if (filePath.startsWith('file://')) { + filePath = filePath.replace('file://', '') + } + // Remove line:col suffix + filePath = filePath.split(':')[0] + + // Verify file exists + if (fs.existsSync(filePath)) { + return filePath + } + } + return +} + +/** + * Extract suite and test names from test file using simple regex (lightweight approach) + * Falls back to simple regex matching instead of full AST parsing for performance + */ +export function extractTestMetadata(filePath: string): { + suiteTitle: string | null + testNames: string[] +} { + const result = { suiteTitle: null as string | null, testNames: [] as string[] } + + if (!fs.existsSync(filePath)) { + return result + } + + try { + const source = fs.readFileSync(filePath, 'utf-8') + + // Extract first describe() or suite() call + const suiteMatch = source.match(/(?:describe|suite|context)\s*\(\s*['"']([^'"']+)['"']/) + if (suiteMatch && suiteMatch[1]) { + result.suiteTitle = suiteMatch[1] + } + + // Extract all it() or test() calls + const testRegex = /(?:it|test|specify)\s*\(\s*['"']([^'"']+)['"']/g + let match + while ((match = testRegex.exec(source)) !== null) { + result.testNames.push(match[1]) + } + } catch (err) { + console.log(`[DEBUG] Failed to parse test file ${filePath}: ${(err as Error).message}`) + } + + return result +} + +/** + * Get call source info from stack trace (WDIO approach) + * Returns filename:line format for display + */ +export function getCallSourceFromStack(): { filePath: string | undefined, callSource: string } { + const stack = new Error().stack + if (!stack) { + return { filePath: undefined, callSource: 'unknown:0' } + } + + const frames = parseStackTrace(stack) + const userFrame = frames.find(frame => { + const file = frame.file + return file && + !file.includes('/node_modules/') && + !file.includes('') && + !file.includes('node:internal') && + !file.includes('/dist/') && + !file.includes('/index.js') + }) + + if (userFrame && userFrame.file) { + let filePath = userFrame.file + // Strip file:// protocol + if (filePath.startsWith('file://')) { + filePath = filePath.replace('file://', '') + } + + // Remove line:col from filePath + const cleanFilePath = filePath.split(':')[0] + + // Use full path with line number for callSource so Source tab can match it + const callSource = `${cleanFilePath}:${userFrame.lineNumber || 0}` + + return { filePath: cleanFilePath, callSource } + } + + return { filePath: undefined, callSource: 'unknown:0' } +} + +/** + * Find test file by searching workspace for matching filename + * Used when stack trace doesn't have the file yet (in beforeEach) + */ +export function findTestFileByName(filename: string, workspaceRoot?: string): string | undefined { + if (!filename || !workspaceRoot) { + return undefined + } + + // Clean up filename - remove extensions and normalize + const baseFilename = filename.replace(/\.[cm]?[jt]sx?$/, '') + + // Recursively search directories + function searchDir(dir: string, depth = 0): string | undefined { + if (depth > 5) return undefined // Limit recursion depth + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + // Skip node_modules and dist + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') { + continue + } + // Recursively search subdirectories + const found = searchDir(fullPath, depth + 1) + if (found) return found + } else if (entry.isFile()) { + // Check if filename matches (with or without extension) + const nameMatch = entry.name === filename || + entry.name === `${baseFilename}.test.js` || + entry.name === `${baseFilename}.spec.js` || + entry.name === `${baseFilename}.test.ts` || + entry.name === `${baseFilename}.spec.ts` + + if (nameMatch) { + return fullPath + } + } + } + } catch (err) { + // Permission denied or other error, skip this directory + } + + return undefined + } + + return searchDir(workspaceRoot) +} diff --git a/packages/nightwatch-devtools/tsconfig.json b/packages/nightwatch-devtools/tsconfig.json new file mode 100644 index 0000000..25c4541 --- /dev/null +++ b/packages/nightwatch-devtools/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "sourceMap": true, + "strict": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/service/src/types.ts b/packages/service/src/types.ts index 084152c..d8852d1 100644 --- a/packages/service/src/types.ts +++ b/packages/service/src/types.ts @@ -10,6 +10,7 @@ export interface CommandLog { timestamp: number callSource: string screenshot?: string + testUid?: string } export enum TraceType { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23d89fc..3131099 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)(happy-dom@20.0.11)(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)(jsdom@24.1.3)(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) @@ -83,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)(happy-dom@20.0.11)(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)(jsdom@24.1.3)(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) @@ -237,6 +237,43 @@ importers: specifier: ^8.18.3 version: 8.18.3 + packages/nightwatch-devtools: + dependencies: + '@wdio/devtools-backend': + specifier: workspace:* + version: link:../backend + '@wdio/logger': + specifier: ^9.6.0 + version: 9.18.0 + import-meta-resolve: + specifier: ^4.2.0 + version: 4.2.0 + stacktrace-parser: + specifier: ^0.1.10 + version: 0.1.10 + webdriverio: + specifier: ^9.18.0 + version: 9.20.0(puppeteer-core@21.11.0) + ws: + specifier: ^8.18.3 + version: 8.18.3 + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + chromedriver: + specifier: ^133.0.0 + version: 133.0.3 + nightwatch: + specifier: ^3.0.0 + version: 3.15.0(@cucumber/cucumber@10.9.0)(chromedriver@133.0.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages/script: dependencies: htm: @@ -328,6 +365,9 @@ packages: '@antfu/utils@9.3.0': resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -365,6 +405,9 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bazel/runfiles@6.5.0': + resolution: {integrity: sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==} + '@cacheable/memoize@2.0.3': resolution: {integrity: sha512-hl9wfQgpiydhQEIv7fkjEzTGE+tcosCXLKFDO707wYJ/78FVOlowb36djex5GdbSyeHnG62pomYLMuV/OT8Pbw==} @@ -409,6 +452,24 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} @@ -1138,6 +1199,122 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + + '@nightwatch/chai@5.0.3': + resolution: {integrity: sha512-1OIkOf/7jswOC3/t+Add/HVQO8ib75kz6BVYSNeWGghTlmHUqYEfNJ6vcACbXrn/4v3+9iRlWixuhFkxXkU/RQ==} + engines: {node: '>=12'} + + '@nightwatch/html-reporter-template@0.3.0': + resolution: {integrity: sha512-Mze1z6pmUz2O8N9w1/h3QWz1lzMig45PGyh8PrL9ERs3FxVnIX0RCn37vjZUYiV4wgjZOg41JjdcpriZ3dJxkA==} + + '@nightwatch/nightwatch-inspector@1.0.1': + resolution: {integrity: sha512-/ax11EOB4eJXT5VioMztcalbCtsNeuFn6icfT75qPLBmkxLvThePSfyGTys+t9AULUR0ug0wMDMiLV1Oy586Fg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1434,6 +1611,9 @@ packages: resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} engines: {node: '>=14'} + '@testim/chrome-version@1.1.4': + resolution: {integrity: sha512-kIhULpw9TrGYnHp/8VfdcneIcxKnLixmADtukQRtJUmsVlMg0niMkwV0xZmi8hqa57xqilIHjWFA0GKvEjVU5g==} + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -1464,6 +1644,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@4.3.20': + resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1500,6 +1683,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/selenium-webdriver@4.35.5': + resolution: {integrity: sha512-wCQCjWmahRkUAO7S703UAvBFkxz4o/rjX4T2AOSWKXSi0sTQPsrXxR0GjtFUT0ompedLkYH4R5HO5Urz0hyeog==} + '@types/sinonjs__fake-timers@8.1.5': resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} @@ -1823,6 +2009,13 @@ packages: alien-signals@0.4.14: resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -1851,6 +2044,11 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansi-to-html@0.7.2: + resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==} + engines: {node: '>=8.0.0'} + hasBin: true + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1858,10 +2056,22 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + archiver@7.0.1: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} @@ -1875,6 +2085,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1910,6 +2123,9 @@ packages: assertion-error-formatter@3.0.0: resolution: {integrity: sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==} + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1933,6 +2149,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1951,6 +2170,13 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} peerDependencies: @@ -2029,12 +2255,19 @@ packages: binary@0.3.0: resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2045,6 +2278,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + browserslist@4.27.0: resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2102,6 +2338,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001751: resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} @@ -2111,6 +2351,10 @@ packages: capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + chai-nightwatch@0.5.3: + resolution: {integrity: sha512-38ixH/mqpY6IwnZkz6xPqx8aB5/KVR+j6VPugcir3EGOsphnWXrPH/mUt8Jp+ninL6ghY0AaJDQ10hSfCPGy/g==} + engines: {node: '>= 12.0.0'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2136,6 +2380,9 @@ packages: chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + check-error@1.0.2: + resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -2156,11 +2403,19 @@ packages: engines: {node: '>=12.13.0'} hasBin: true + chromedriver@133.0.3: + resolution: {integrity: sha512-wGZUtrSdyqnbweXEDIbn+ndu7memG4SEqG6/D+mSabKUEic0hveMYepAPAhlYtvyOc0X8JbsARYtEalVD3R/Vg==} + engines: {node: '>=18'} + hasBin: true + chromium-bidi@0.5.8: resolution: {integrity: sha512-blqh+1cEQbHBKmok3rVJkBlBxt9beKBgOsxbFgs7UJcoVbbeZ+K7+6liAsjgpc8l1Xd55cQUy14fXZdGSb4zIw==} peerDependencies: devtools-protocol: '*' + ci-info@3.3.0: + resolution: {integrity: sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==} + ci-info@4.3.1: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} @@ -2172,6 +2427,18 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-table3@0.6.3: resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==} engines: {node: 10.* || >= 12.*} @@ -2180,6 +2447,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2207,6 +2477,10 @@ packages: colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -2230,6 +2504,10 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -2271,6 +2549,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + crc32-stream@6.0.0: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} @@ -2323,6 +2605,10 @@ packages: engines: {node: '>=4'} hasBin: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2331,6 +2617,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2354,6 +2644,15 @@ packages: supports-color: optional: true + debug@4.3.1: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -2372,14 +2671,29 @@ packages: supports-color: optional: true + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + decamelize@6.0.1: resolution: {integrity: sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@4.0.1: + resolution: {integrity: sha512-D/Oxqobjr+kxaHsgiQBZq9b6iAWdEj5W/JdJm8deNduAPc9CwXQ3BJJCuEqlrPXcy45iOMkGPZ0T81Dnz7UDCA==} + engines: {node: '>=6'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2398,6 +2712,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -2406,6 +2724,10 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2418,6 +2740,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devtools-protocol@0.0.1140464: + resolution: {integrity: sha512-I1jXnjpQh/6TBFyQ0A9dB2kXXk6DprpPFZoI8pUsxHtlNuOTQEdv9fUqYBsFtf8tOJCbdsZZyQrWeXu6GfK+Bw==} + devtools-protocol@0.0.1232444: resolution: {integrity: sha512-pM27vqEfxSxRkTMnF+XCmxSEb6duO5R+t8A9DEEJgy4Wz2RVanje2mmj99B6A3zv2r/qGfYlOvYznUhuokizmg==} @@ -2425,10 +2750,17 @@ packages: resolution: {integrity: sha512-Y9LRUJlGI0wjXLbeU6TEHufF9HnG2H22+/EABD0KtHlJt5AIRQnTGi8uLAJsE1aeQMF1YXd8l7ExaxBkfEBq8w==} engines: {node: ^16.13 || >=18} + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.2: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} @@ -2454,6 +2786,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.3.1: + resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + engines: {node: '>=12'} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -2514,6 +2850,9 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2526,6 +2865,11 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + envinfo@7.11.0: + resolution: {integrity: sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==} + engines: {node: '>=4'} + hasBin: true + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2544,6 +2888,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -2873,9 +3220,22 @@ packages: flat-cache@6.1.18: resolution: {integrity: sha512-JUPnFgHMuAVmLmoH9/zoZ6RHOt5n9NlUw/sDXsTbROJ2SFoS2DS4s+swAV6UTeTbGH/CAsZIE6M8TaG/3jVxgQ==} + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2884,6 +3244,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -2891,6 +3255,9 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} @@ -2936,6 +3303,9 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2977,10 +3347,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -2989,7 +3361,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -3099,6 +3476,10 @@ packages: htm@3.1.1: resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} @@ -3204,10 +3585,18 @@ packages: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + ipaddr.js@2.2.0: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3280,6 +3669,10 @@ packages: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3300,6 +3693,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3308,6 +3705,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3340,10 +3740,17 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3360,6 +3767,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is2@2.0.9: + resolution: {integrity: sha512-rZkHeBn9Zzq52sd9IUIV3a5mfwBY+o2HePMh0wkGBM4z4qjvy2GwVxQ6nNXSfw6MmVP6gf1QIlWjiOavhM3x5g==} + engines: {node: '>=v0.10.0'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -3419,6 +3830,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@24.1.3: + resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3619,9 +4039,21 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3643,6 +4075,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + loglevel-plugin-prefix@0.8.4: resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} @@ -3650,6 +4086,9 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -3707,11 +4146,23 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + minimatch@10.0.3: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} @@ -3731,6 +4182,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3756,6 +4210,11 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mocha@10.8.2: + resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} + engines: {node: '>= 14.0.0'} + hasBin: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3791,6 +4250,25 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + nightwatch-axe-verbose@2.4.0: + resolution: {integrity: sha512-ZWSygayLjyDNvkrL4LjfeBhCXCA5yRQ7Gf9ZkNVPba8VzBe/pa4yYuWgpMV+59uZpa+hMcGz7drTpRzT0RI/gw==} + + nightwatch@3.15.0: + resolution: {integrity: sha512-Vvh7TsDyEN1YzOsDNoafEUPJDQ6jfnmJPAsWo/EmygljZiRk1Ja/pEqNAhE5UdYJzF38SNO46gJS8IRk9mUNfA==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + '@cucumber/cucumber': '*' + chromedriver: '*' + geckodriver: '*' + peerDependenciesMeta: + '@cucumber/cucumber': + optional: true + chromedriver: + optional: true + geckodriver: + optional: true + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -3854,6 +4332,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3862,6 +4343,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -3892,10 +4377,22 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -4033,6 +4530,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4070,6 +4570,9 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + piscina@4.9.2: + resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==} + pixelmatch@7.1.0: resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} hasBin: true @@ -4202,6 +4705,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -4226,12 +4732,18 @@ packages: query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -4326,6 +4838,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4349,6 +4864,10 @@ packages: resq@1.11.0: resolution: {integrity: sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==} + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -4373,6 +4892,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-async@4.0.6: resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} engines: {node: '>=0.12.0'} @@ -4418,12 +4943,20 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} seed-random@2.2.0: resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==} + selenium-webdriver@4.27.0: + resolution: {integrity: sha512-LkTJrNz5socxpPnWPODQ2bQ65eYx9JK+DQMYNihpTjMCqHwgWGYQnQTCAAche2W3ZP87alA+1zYPvgS8tHNzMQ==} + engines: {node: '>= 14.21.0'} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -4451,6 +4984,9 @@ packages: resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} engines: {node: '>=18'} + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} @@ -4511,6 +5047,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4593,6 +5132,10 @@ packages: stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + stacktrace-parser@0.1.10: + resolution: {integrity: sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==} + engines: {node: '>=6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4725,6 +5268,9 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4746,9 +5292,16 @@ packages: tar-fs@3.1.1: resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tcp-port-used@1.0.2: + resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} + text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} @@ -4818,9 +5371,17 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + traverse@0.3.9: resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} @@ -4867,10 +5428,22 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-fest@0.6.0: resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} engines: {node: '>=8'} + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + type-fest@0.8.1: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} @@ -4952,6 +5525,10 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -4983,6 +5560,10 @@ packages: resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} engines: {node: '>=18.12.0'} + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -5004,6 +5585,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + urlpattern-polyfill@10.0.0: resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} @@ -5024,6 +5608,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -5130,6 +5718,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wait-port@1.1.0: resolution: {integrity: sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==} engines: {node: '>=10'} @@ -5171,6 +5763,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5187,6 +5783,10 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5230,10 +5830,17 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -5277,10 +5884,17 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5293,10 +5907,22 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -5327,6 +5953,10 @@ packages: yup@1.2.0: resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -5342,6 +5972,14 @@ snapshots: '@antfu/utils@9.3.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5389,6 +6027,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bazel/runfiles@6.5.0': {} + '@cacheable/memoize@2.0.3': dependencies: '@cacheable/utils': 2.1.0 @@ -5475,6 +6115,20 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 @@ -6153,6 +6807,93 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + + '@nightwatch/chai@5.0.3': + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.2 + deep-eql: 4.0.1 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.0.8 + + '@nightwatch/html-reporter-template@0.3.0': {} + + '@nightwatch/nightwatch-inspector@1.0.1': + dependencies: + archiver: 5.3.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6401,6 +7142,8 @@ snapshots: '@teppeis/multimaps@3.0.0': {} + '@testim/chrome-version@1.1.4': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tsconfig/node10@1.0.11': {} @@ -6434,6 +7177,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@4.3.20': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6471,6 +7216,11 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/selenium-webdriver@4.35.5': + dependencies: + '@types/node': 25.0.3 + '@types/ws': 8.18.1 + '@types/sinonjs__fake-timers@8.1.5': {} '@types/stack-trace@0.0.33': {} @@ -6595,7 +7345,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)(happy-dom@20.0.11)(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)(jsdom@24.1.3)(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 @@ -6604,7 +7354,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - 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) + vitest: 4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@24.1.3)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -7047,6 +7797,12 @@ snapshots: alien-signals@0.4.14: {} + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} @@ -7065,6 +7821,10 @@ snapshots: ansi-styles@6.2.3: {} + ansi-to-html@0.7.2: + dependencies: + entities: 2.2.0 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -7072,6 +7832,32 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + archiver-utils@5.0.2: dependencies: glob: 10.5.0 @@ -7082,6 +7868,16 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + archiver@7.0.1: dependencies: archiver-utils: 5.0.2 @@ -7103,6 +7899,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -7163,6 +7963,8 @@ snapshots: pad-right: 0.2.2 repeat-string: 1.6.1 + assertion-error@1.1.0: {} + assertion-error@2.0.1: {} ast-types@0.13.4: @@ -7177,6 +7979,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} autoprefixer@10.4.21(postcss@8.5.6): @@ -7198,6 +8002,16 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + axe-core@4.11.1: {} + + axios@1.13.4: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.7.3: {} balanced-match@1.0.2: {} @@ -7258,10 +8072,27 @@ snapshots: buffers: 0.1.1 chainsaw: 0.1.0 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bluebird@3.4.7: {} boolbase@1.0.0: {} + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -7275,6 +8106,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-stdout@1.3.1: {} + browserslist@4.27.0: dependencies: baseline-browser-mapping: 2.8.20 @@ -7341,6 +8174,8 @@ snapshots: callsites@3.1.0: {} + camelcase@6.3.0: {} + caniuse-lite@1.0.30001751: {} caniuse-lite@1.0.30001761: {} @@ -7351,6 +8186,10 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 + chai-nightwatch@0.5.3: + dependencies: + assertion-error: 1.1.0 + chai@6.2.2: {} chainsaw@0.1.0: @@ -7374,6 +8213,8 @@ snapshots: chardet@2.1.0: {} + check-error@1.0.2: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -7415,19 +8256,34 @@ snapshots: chrome-launcher@1.2.1: dependencies: - '@types/node': 22.19.3 + '@types/node': 25.0.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 2.0.2 transitivePeerDependencies: - supports-color + chromedriver@133.0.3: + dependencies: + '@testim/chrome-version': 1.1.4 + axios: 1.13.4 + compare-versions: 6.1.1 + extract-zip: 2.0.1 + proxy-agent: 6.5.0 + proxy-from-env: 1.1.0 + tcp-port-used: 1.0.2 + transitivePeerDependencies: + - debug + - supports-color + chromium-bidi@0.5.8(devtools-protocol@0.0.1232444): dependencies: devtools-protocol: 0.0.1232444 mitt: 3.0.1 urlpattern-polyfill: 10.0.0 + ci-info@3.3.0: {} + ci-info@4.3.1: {} class-transformer@0.5.1: {} @@ -7436,6 +8292,14 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + cli-table3@0.6.3: dependencies: string-width: 4.2.3 @@ -7444,14 +8308,19 @@ snapshots: cli-width@4.1.0: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - clone@1.0.4: - optional: true + clone@1.0.4: {} codemirror@6.0.2: dependencies: @@ -7477,6 +8346,10 @@ snapshots: colord@2.9.3: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@10.0.1: {} commander@12.0.0: {} @@ -7489,6 +8362,13 @@ snapshots: compare-versions@6.1.1: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -7524,6 +8404,11 @@ snapshots: crc-32@1.2.2: {} + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + crc32-stream@6.0.0: dependencies: crc-32: 1.2.2 @@ -7594,10 +8479,20 @@ snapshots: cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7622,6 +8517,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.1: + dependencies: + ms: 2.1.2 + debug@4.3.4: dependencies: ms: 2.1.2 @@ -7642,10 +8541,39 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@4.0.0: {} + decamelize@6.0.1: {} + decimal.js@10.6.0: {} + + deep-eql@4.0.1: + dependencies: + type-detect: 4.0.8 + deep-eql@5.0.2: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + deep-is@0.1.4: {} deepmerge-ts@5.1.0: {} @@ -7655,7 +8583,6 @@ snapshots: defaults@1.0.4: dependencies: clone: 1.0.4 - optional: true define-data-property@1.1.4: dependencies: @@ -7663,6 +8590,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -7675,12 +8604,16 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} detect-libc@2.1.2: {} + devtools-protocol@0.0.1140464: {} + devtools-protocol@0.0.1232444: {} devtools@8.42.0: @@ -7708,8 +8641,12 @@ snapshots: - supports-color - utf-8-validate + didyoumean@1.2.2: {} + diff@4.0.2: {} + diff@5.2.2: {} + diff@8.0.2: {} dir-glob@3.0.1: @@ -7738,6 +8675,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@16.3.1: {} + dotenv@17.2.3: {} dunder-proto@1.0.1: @@ -7820,12 +8759,16 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@2.2.0: {} + entities@4.5.0: {} entities@6.0.1: {} env-paths@2.2.1: {} + envinfo@7.11.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7895,6 +8838,18 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: @@ -8354,8 +9309,12 @@ snapshots: flatted: 3.3.3 hookified: 1.12.2 + flat@5.0.2: {} + flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -8365,12 +9324,22 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 fraction.js@4.3.7: {} + fs-constants@1.0.0: {} + fs-extra@11.3.2: dependencies: graceful-fs: 4.2.11 @@ -8438,6 +9407,8 @@ snapshots: get-caller-file@2.0.5: {} + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8526,6 +9497,14 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + global-dirs@3.0.1: dependencies: ini: 2.0.0 @@ -8620,6 +9599,10 @@ snapshots: htm@3.1.1: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-tags@3.3.1: {} htmlfy@0.8.1: {} @@ -8719,8 +9702,15 @@ snapshots: ip-address@10.0.1: {} + ip-regex@4.3.0: {} + ipaddr.js@2.2.0: {} + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -8798,6 +9788,8 @@ snapshots: global-dirs: 3.0.1 is-path-inside: 3.0.3 + is-interactive@1.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -8811,10 +9803,14 @@ snapshots: is-path-inside@3.0.3: {} + is-plain-obj@2.1.0: {} + is-plain-obj@4.1.0: {} is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8847,8 +9843,12 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + is-url@1.2.4: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -8864,6 +9864,12 @@ snapshots: dependencies: is-docker: 2.2.1 + is2@2.0.9: + dependencies: + deep-is: 0.1.4 + ip-regex: 4.3.0 + is-url: 1.2.4 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -8937,6 +9943,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@24.1.3: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -9126,8 +10160,16 @@ snapshots: lodash.clonedeep@4.5.0: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.flatten@4.4.0: {} + lodash.flattendeep@4.4.0: {} + lodash.isplainobject@4.0.6: {} + lodash.merge@4.6.2: {} lodash.mergewith@4.6.2: {} @@ -9142,10 +10184,19 @@ snapshots: lodash@4.17.21: {} + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + loglevel-plugin-prefix@0.8.4: {} loglevel@1.9.2: {} + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -9187,8 +10238,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime@3.0.0: {} + mimic-fn@2.1.0: {} + minimatch@10.0.3: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -9209,6 +10268,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.6: {} + minimist@1.2.8: {} minipass@7.1.2: {} @@ -9230,6 +10291,29 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 + mocha@10.8.2: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 5.2.2 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 5.1.6 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.0 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 + mrmime@2.0.1: {} ms@2.1.2: {} @@ -9254,6 +10338,55 @@ snapshots: nice-try@1.0.5: {} + nightwatch-axe-verbose@2.4.0: + dependencies: + axe-core: 4.11.1 + + nightwatch@3.15.0(@cucumber/cucumber@10.9.0)(chromedriver@133.0.3): + dependencies: + '@nightwatch/chai': 5.0.3 + '@nightwatch/html-reporter-template': 0.3.0 + '@nightwatch/nightwatch-inspector': 1.0.1 + '@types/chai': 4.3.20 + '@types/selenium-webdriver': 4.35.5 + ansi-to-html: 0.7.2 + aria-query: 5.1.3 + assertion-error: 1.1.0 + boxen: 5.1.2 + chai-nightwatch: 0.5.3 + chalk: 4.1.2 + ci-info: 3.3.0 + cli-table3: 0.6.3 + devtools-protocol: 0.0.1140464 + didyoumean: 1.2.2 + dotenv: 16.3.1 + ejs: 3.1.10 + envinfo: 7.11.0 + glob: 7.2.3 + jsdom: 24.1.3 + lodash: 4.17.21 + minimatch: 3.1.2 + minimist: 1.2.6 + mocha: 10.8.2 + nightwatch-axe-verbose: 2.4.0 + open: 8.4.2 + ora: 5.4.1 + piscina: 4.9.2 + selenium-webdriver: 4.27.0 + semver: 7.5.4 + stacktrace-parser: 0.1.10 + strip-ansi: 6.0.1 + untildify: 4.0.0 + uuid: 8.3.2 + optionalDependencies: + '@cucumber/cucumber': 10.9.0 + chromedriver: 133.0.3 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -9332,10 +10465,17 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.23: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.7: @@ -9375,6 +10515,16 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9384,6 +10534,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -9523,6 +10685,8 @@ snapshots: pathe@2.0.3: {} + pathval@1.1.1: {} + pend@1.2.0: {} picocolors@1.1.1: {} @@ -9557,6 +10721,10 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 3.1.0 + piscina@4.9.2: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + pixelmatch@7.1.0: dependencies: pngjs: 7.0.0 @@ -9692,6 +10860,10 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pstree.remy@1.1.8: {} pump@3.0.3: @@ -9725,10 +10897,16 @@ snapshots: query-selector-shadow-dom@1.0.1: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + react-is@18.3.1: {} read-cache@1.0.0: @@ -9847,6 +11025,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -9867,6 +11047,11 @@ snapshots: dependencies: fast-deep-equal: 2.0.1 + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + ret@0.5.0: {} reusify@1.1.0: {} @@ -9907,6 +11092,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-async@4.0.6: {} run-parallel@1.2.0: @@ -9952,10 +11141,24 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + secure-json-parse@4.1.0: {} seed-random@2.2.0: {} + selenium-webdriver@4.27.0: + dependencies: + '@bazel/runfiles': 6.5.0 + jszip: 3.10.1 + tmp: 0.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + semver@5.7.2: {} semver@6.3.1: {} @@ -9974,6 +11177,10 @@ snapshots: dependencies: type-fest: 4.41.0 + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + set-cookie-parser@2.7.1: {} set-function-length@1.2.2: @@ -10046,6 +11253,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-update-notifier@2.0.0: @@ -10124,6 +11333,10 @@ snapshots: stackframe@1.3.4: {} + stacktrace-parser@0.1.10: + dependencies: + type-fest: 0.7.1 + statuses@2.0.2: {} std-env@3.10.0: {} @@ -10296,6 +11509,8 @@ snapshots: svg-tags@1.0.0: {} + symbol-tree@3.2.4: {} + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -10333,6 +11548,14 @@ snapshots: - bare-buffer - react-native-b4a + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -10342,6 +11565,13 @@ snapshots: - bare-abort-controller - react-native-b4a + tcp-port-used@1.0.2: + dependencies: + debug: 4.3.1 + is2: 2.0.9 + transitivePeerDependencies: + - supports-color + text-decoder@1.2.3: dependencies: b4a: 1.7.3 @@ -10395,8 +11625,19 @@ snapshots: touch@3.1.1: {} + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + traverse@0.3.9: {} tree-kill@1.2.2: {} @@ -10449,8 +11690,14 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + type-fest@0.6.0: {} + type-fest@0.7.1: {} + type-fest@0.8.1: {} type-fest@2.19.0: {} @@ -10526,6 +11773,8 @@ snapshots: unicorn-magic@0.3.0: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unplugin-icons@22.5.0: @@ -10545,6 +11794,8 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 + untildify@4.0.0: {} + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -10578,6 +11829,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + urlpattern-polyfill@10.0.0: {} urlpattern-polyfill@10.1.0: {} @@ -10590,6 +11846,8 @@ snapshots: uuid@10.0.0: {} + uuid@8.3.2: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} @@ -10640,7 +11898,7 @@ snapshots: 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): + vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@24.1.3)(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)) @@ -10665,6 +11923,7 @@ snapshots: optionalDependencies: '@types/node': 25.0.3 happy-dom: 20.0.11 + jsdom: 24.1.3 transitivePeerDependencies: - jiti - less @@ -10682,6 +11941,10 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wait-port@1.1.0: dependencies: chalk: 4.1.2 @@ -10693,7 +11956,6 @@ snapshots: wcwidth@1.0.1: dependencies: defaults: 1.0.4 - optional: true web-streams-polyfill@3.3.3: {} @@ -10815,6 +12077,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -10825,6 +12089,11 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -10892,8 +12161,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + word-wrap@1.2.5: {} + workerpool@6.5.1: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -10923,16 +12198,39 @@ snapshots: ws@8.18.3: {} + xml-name-validator@5.0.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@4.0.0: {} yaml@2.8.1: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -10965,6 +12263,12 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8b4edab..f0c8bb9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,4 +4,5 @@ packages: - 'packages/script' - 'packages/service' - 'packages/app' + - 'packages/nightwatch-devtools' - 'example' From 217349618f5544adc25905268f3e52a4a06390d8 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 17 Feb 2026 14:38:26 +0530 Subject: [PATCH 2/4] Enhanced command capture --- .../example/nightwatch.conf.cjs | 5 +- packages/nightwatch-devtools/src/index.ts | 349 +++++++++--------- packages/nightwatch-devtools/src/session.ts | 230 ++++++------ 3 files changed, 299 insertions(+), 285 deletions(-) diff --git a/packages/nightwatch-devtools/example/nightwatch.conf.cjs b/packages/nightwatch-devtools/example/nightwatch.conf.cjs index 76a8ca4..a03c688 100644 --- a/packages/nightwatch-devtools/example/nightwatch.conf.cjs +++ b/packages/nightwatch-devtools/example/nightwatch.conf.cjs @@ -1,4 +1,4 @@ -// Simple import - just require the package +// Simple import - just require the package const nightwatchDevtools = require('@wdio/nightwatch-devtools').default; module.exports = { @@ -16,6 +16,9 @@ module.exports = { test_settings: { default: { + // Ensure all tests run even if one fails + skip_testcases_on_fail: false, + desiredCapabilities: { browserName: 'chrome', 'goog:chromeOptions': { diff --git a/packages/nightwatch-devtools/src/index.ts b/packages/nightwatch-devtools/src/index.ts index 7747b36..f3cbb53 100644 --- a/packages/nightwatch-devtools/src/index.ts +++ b/packages/nightwatch-devtools/src/index.ts @@ -1,6 +1,6 @@ /** * Nightwatch DevTools Plugin - * + * * Integrates Nightwatch with WebdriverIO DevTools following the WDIO service pattern. * Captures commands, network requests, and console logs during test execution in real-time. */ @@ -32,6 +32,10 @@ class NightwatchDevToolsPlugin { #lastSessionId: string | null = null // Track browser session changes #devtoolsBrowser?: WebdriverIO.Browser + // Command stack pattern (from WDIO DevTools) + #commandStack: Array<{ command: string; callSource?: string; signature: string }> = [] + #lastCommandSig: string | null = null + constructor(options: DevToolsOptions = {}) { this.options = { port: options.port ?? 3000, @@ -47,9 +51,9 @@ class NightwatchDevToolsPlugin { try { log.info('šŸš€ Starting DevTools backend...') const server = await start(this.options) - + // Fastify's server doesn't have addresses() until after listen() - // The port is already set in options after start() completes + // The port is already set in options after start() completes const url = `http://${this.options.hostname}:${this.options.port}` log.info(`āœ“ Backend started on port ${this.options.port}`) log.info(``) @@ -61,7 +65,7 @@ class NightwatchDevToolsPlugin { log.info(` ā³ Waiting for UI to connect...`) log.info(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`) log.info(``) - + // Open DevTools UI in a separate browser window using WDIO's method try { this.#devtoolsBrowser = await remote({ @@ -74,21 +78,21 @@ class NightwatchDevToolsPlugin { } } }) - + await this.#devtoolsBrowser.url(url) log.info('āœ“ DevTools UI opened in separate browser window') } catch (err) { log.error(`Failed to open DevTools UI: ${(err as Error).message}`) log.info(`Please manually open: ${url}`) } - + // Wait for UI to connect log.info('Waiting 10 seconds for UI to connect...') await new Promise(resolve => setTimeout(resolve, 10000)) - + log.info('Starting tests...') - + } catch (err) { log.error(`Failed to start backend: ${(err as Error).message}`) throw err @@ -100,23 +104,27 @@ class NightwatchDevToolsPlugin { * Initialize session and inject script before each test */ async beforeEach(browser: NightwatchBrowser) { + // Reset command stack and signature at the start of each test (like WDIO) + this.#commandStack = [] + this.#lastCommandSig = null + // Check if browser session changed (new session = new test file) const currentSessionId = (browser as any).sessionId if (currentSessionId && this.#lastSessionId && currentSessionId !== this.#lastSessionId) { this.#browserProxied = false } this.#lastSessionId = currentSessionId - + // Initialize on first test if (!this.sessionCapturer) { // Wait a bit more for WebSocket to be ready on first test await new Promise(resolve => setTimeout(resolve, 500)) - + this.sessionCapturer = new SessionCapturer({ port: this.options.port, hostname: this.options.hostname }, browser) - + // Wait for WebSocket to connect before proceeding const connected = await this.sessionCapturer.waitForConnection(3000) if (!connected) { @@ -124,7 +132,7 @@ class NightwatchDevToolsPlugin { } else { log.info('āœ“ Worker WebSocket connected') } - + // TestReporter callback sends suites data upstream in WDIO format this.testReporter = new TestReporter((suitesData: any) => { if (this.sessionCapturer) { @@ -133,7 +141,7 @@ class NightwatchDevToolsPlugin { } }) log.info('āœ“ Session initialized') - + // Send initial metadata const capabilities = browser.capabilities || {} this.sessionCapturer.sendUpstream('metadata', { @@ -148,15 +156,15 @@ class NightwatchDevToolsPlugin { const currentTest = (browser as any).currentTest if (currentTest) { const testFile = (currentTest.module || '').split('/').pop() || currentTest.module || 'unknown' - + // Reset #currentTestFullPath if we're starting a new test file if (this.#currentTestFile !== testFile) { this.#currentTestFullPath = null } - + // Find test file: try stack trace first, then search workspace let fullPath = findTestFileFromStack() || this.#currentTestFullPath - + if (!fullPath && testFile) { // Try searching common test directories const workspaceRoot = process.cwd() @@ -167,7 +175,7 @@ class NightwatchDevToolsPlugin { path.join(workspaceRoot, 'test', testFile + '.js'), path.join(workspaceRoot, testFile + '.js'), ] - + for (const possiblePath of possiblePaths) { if (fs.existsSync(possiblePath)) { fullPath = possiblePath @@ -175,7 +183,7 @@ class NightwatchDevToolsPlugin { } } } - + // Extract suite title and test metadata let suiteTitle = testFile let testNames: string[] = [] @@ -185,11 +193,11 @@ class NightwatchDevToolsPlugin { suiteTitle = metadata.suiteTitle } testNames = metadata.testNames - + // Log extracted metadata for debugging console.log(`[METADATA] Suite: "${suiteTitle}", Tests: [${testNames.map(t => `"${t}"`).join(', ')}]`) } - + // Create/update suite for this test file if (!this.#currentSuiteByFile.has(testFile)) { const suiteStats = { @@ -206,17 +214,17 @@ class NightwatchDevToolsPlugin { hooks: [], _duration: 0 } - + this.testReporter.onSuiteStart(suiteStats) this.#currentSuiteByFile.set(testFile, suiteStats) - + // Capture source file for display in Source tab if (fullPath && fullPath.includes('/')) { this.sessionCapturer.captureSource(fullPath).catch(() => { // Silently ignore source capture errors }) } - + // Add all tests with pending state (like WDIO does) if (testNames.length > 0) { for (const testName of testNames) { @@ -241,17 +249,17 @@ class NightwatchDevToolsPlugin { this.testReporter.updateSuites() } } - + // Find which test is about to run (first unprocessed test) const currentSuite = this.#currentSuiteByFile.get(testFile)! if (!this.#processedTests.has(testFile)) { this.#processedTests.set(testFile, new Set()) } const processedForSuite = this.#processedTests.get(testFile)! - + // Find first test that hasn't been processed yet and mark it as running let currentTestName = testNames.find(name => !processedForSuite.has(name)) - + if (currentTestName) { const testIndex = currentSuite.tests.findIndex( (t: any) => typeof t !== 'string' && t.title === currentTestName @@ -267,10 +275,10 @@ class NightwatchDevToolsPlugin { await new Promise(resolve => setTimeout(resolve, 100)) } } - + // Create temporary test for command tracking const uniqueTempUid = `${currentSuite.uid}::temp-${Date.now()}-${Math.random().toString(36).substring(7)}` - + this.#currentTest = { uid: uniqueTempUid, cid: '0-0', @@ -286,16 +294,16 @@ class NightwatchDevToolsPlugin { _duration: 0, hooks: [] } - + // Store reference to current suite for command tracking this.#currentTestFile = testFile - + const originalUrl = browser.url.bind(browser) const sessionCapturer = this.sessionCapturer - + browser.url = function(url: string) { const result = originalUrl(url) as any - + if (result && typeof result.perform === 'function') { result.perform(async function(this: any) { try { @@ -306,141 +314,101 @@ class NightwatchDevToolsPlugin { } }) } - + return result } as any - + this.isScriptInjected = true log.info('āœ“ Script injection wrapped') } - + // Use Proxy to intercept ALL browser commands if (browser && !this.#browserProxied) { const self = this const sessionCapturer = this.sessionCapturer const browserAny = browser as any - + // Get ALL methods - both own properties and prototype const allMethods = new Set([ ...Object.keys(browser), ...Object.getOwnPropertyNames(Object.getPrototypeOf(browser)) ]) const wrappedMethods: string[] = [] - + allMethods.forEach(methodName => { if (methodName === 'constructor' || typeof browserAny[methodName] !== 'function') { return } - + // Skip internal Nightwatch commands if (INTERNAL_COMMANDS_TO_IGNORE.includes(methodName as any)) { return } - + // Skip methods starting with __ (internal methods) if (methodName.startsWith('__')) { return } - + const originalMethod = browserAny[methodName].bind(browser) - + browserAny[methodName] = function(...args: any[]) { // Get call stack using WDIO DevTools approach const callInfo = getCallSourceFromStack() const callSource = callInfo.callSource - + // Update #currentTestFullPath if we found a valid file if (callInfo.filePath && !self.#currentTestFullPath) { self.#currentTestFullPath = callInfo.filePath } - + + // BEFORE command execution (like WDIO beforeCommand): + // Check for duplicate using signature, push to stack if unique + const cmdSig = JSON.stringify({ command: methodName, args, src: callSource }) + const isDuplicate = self.#lastCommandSig === cmdSig + + if (!isDuplicate && self.#currentTest) { + // Push to command stack + self.#commandStack.push({ command: methodName, callSource, signature: cmdSig }) + self.#lastCommandSig = cmdSig + } + try { // Execute the command const result = originalMethod(...args) - - // For commands that return promises, handle result when it resolves - if (result && typeof result.then === 'function') { - result.then(async (actualResult: any) => { - if (!self.#currentTest || !sessionCapturer) { - return - } - - // Extract the actual value from the resolved result - let extractedValue: any = undefined - if (actualResult && typeof actualResult === 'object' && 'value' in actualResult) { - extractedValue = actualResult.value - } else if (actualResult !== undefined && actualResult !== result && actualResult !== browser && actualResult !== browserAny) { - extractedValue = actualResult - } - - // Check if this command was already captured in the immediate capture path - const lastCommand = sessionCapturer.commandsLog[sessionCapturer.commandsLog.length - 1] - const recentlyCaptured = lastCommand && - lastCommand.command === methodName && - lastCommand.timestamp > Date.now() - 2000 && - JSON.stringify(lastCommand.args) === JSON.stringify(args) - - if (recentlyCaptured) { - // Command was already captured, just update result if needed and it doesn't have performance data - const hasPerformanceData = lastCommand.result && typeof lastCommand.result === 'object' && 'resources' in lastCommand.result - if (extractedValue !== undefined && !hasPerformanceData && lastCommand.result === undefined) { - lastCommand.result = extractedValue - sessionCapturer.sendUpstream('commands', [lastCommand]) - } + + // AFTER command execution (like WDIO afterCommand): + // Pop from stack and capture only if it matches + const stackFrame = self.#commandStack[self.#commandStack.length - 1] + if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) { + self.#commandStack.pop() + + // Serialize result safely + let serializedResult: any = undefined + const isBrowserObject = result === browser || result === browserAny + const isChainableAPI = result && typeof result === 'object' && ('queue' in result || 'sessionId' in result || 'capabilities' in result) + + if (isBrowserObject || isChainableAPI) { + const isWaitCommand = methodName.startsWith('waitFor') + serializedResult = isWaitCommand ? true : undefined + } else if (result && typeof result === 'object') { + if ('value' in result) { + serializedResult = result.value } else { - // Command wasn't captured yet (was stored as pending), capture it now - const pending = (sessionCapturer as any)._pendingCommand - if (pending && pending.methodName === methodName) { - await sessionCapturer.captureCommand( - methodName, - pending.args, - extractedValue, - undefined, - pending.testUid, - pending.callSource, - pending.timestamp - ).catch(() => {}) - delete (sessionCapturer as any)._pendingCommand + try { + serializedResult = JSON.parse(JSON.stringify(result)) + } catch { + serializedResult = String(result) } } - }).catch(() => { - // Ignore promise rejection - error will be captured by original handler - }) - } - - // Serialize result safely (avoid circular references and Nightwatch API objects) - let serializedResult: any = undefined - const isBrowserObject = result === browser || result === browserAny - const isChainableAPI = result && typeof result === 'object' && ('queue' in result || 'sessionId' in result || 'capabilities' in result) - - if (isBrowserObject || isChainableAPI) { - // For commands that return browser object, check if they should be tracked - const isWaitCommand = methodName.startsWith('waitFor') - // Capture waitFor commands immediately with success marker, others as undefined (will get updated by promise) - serializedResult = isWaitCommand ? true : undefined - } else if (result && typeof result === 'object') { - if ('value' in result) { - serializedResult = result.value - } else { - try { - serializedResult = JSON.parse(JSON.stringify(result)) - } catch { - serializedResult = String(result) - } + } else if (result !== undefined) { + serializedResult = result } - } else if (result !== undefined) { - serializedResult = result - } - - // Capture command immediately if we have a result, OR if it's a special command that should be tracked - const isSpecialCommand = ['pause', 'url', 'navigate', 'navigateTo', 'click', 'setValue'].some(cmd => - methodName.toLowerCase().includes(cmd.toLowerCase()) - ) - const shouldCaptureNow = serializedResult !== undefined || isSpecialCommand - - if (self.#currentTest && sessionCapturer) { - if (shouldCaptureNow) { - // Capture immediately (result may be undefined and will be updated by promise) + + // Capture command + if (self.#currentTest && sessionCapturer) { + // captureCommand pushes to log immediately (synchronous) + // then does async performance capture in background sessionCapturer.captureCommand( methodName, args, @@ -449,39 +417,52 @@ class NightwatchDevToolsPlugin { self.#currentTest.uid, callSource ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) - } else { - // Store as pending - will be captured when promise resolves - (sessionCapturer as any)._pendingCommand = { - methodName, - args, - callSource, - timestamp: Date.now(), - testUid: self.#currentTest.uid + + // In Nightwatch's chainable API, ALL commands return the browser object + // and share the same promise chain. Individual promises don't resolve separately. + // Therefore, we send ALL commands immediately after capture. + const lastCommand = sessionCapturer.commandsLog[sessionCapturer.commandsLog.length - 1] as any + if (lastCommand) { + sessionCapturer.sendCommand(lastCommand) } } } - + return result } catch (error) { - // Capture command with error - if (self.#currentTest && sessionCapturer) { - sessionCapturer.captureCommand( - methodName, - args, - undefined, // no result - error instanceof Error ? error : new Error(String(error)), // ensure it's an Error object - self.#currentTest.uid, - callSource - ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) + // Pop from stack on error + const stackFrame = self.#commandStack[self.#commandStack.length - 1] + if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) { + self.#commandStack.pop() + + // Capture command with error + if (self.#currentTest && sessionCapturer) { + sessionCapturer.captureCommand( + methodName, + args, + undefined, + error instanceof Error ? error : new Error(String(error)), + self.#currentTest.uid, + callSource + ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) + + // Send error command immediately + const lastCommand = sessionCapturer.commandsLog[sessionCapturer.commandsLog.length - 1] as any + if (lastCommand) { + sessionCapturer.sendCommand(lastCommand) + } + } + + // DON'T reset signature - keep it for duplicate detection } - + throw error } } - + wrappedMethods.push(methodName) }) - + // Keep proxy active for all tests if (!this.#browserProxied) { this.#browserProxied = true @@ -503,13 +484,13 @@ class NightwatchDevToolsPlugin { const currentTest = (browser as any).currentTest const results = currentTest?.results || {} const testFile = (currentTest.module || '').split('/').pop() || 'unknown' - + // Extract actual test name from results.testcases // BUT: results.testcases contains ALL tests in the suite, not just the current one // We need to find which test just finished const testcases = results.testcases || {} const testcaseNames = Object.keys(testcases) - + // Find the test that matches the current temp UID's commands // or just process the last test if we can't determine const currentSuite = this.#currentSuiteByFile.get(testFile) @@ -519,16 +500,20 @@ class NightwatchDevToolsPlugin { this.#processedTests.set(testFile, new Set()) } const processedForSuite = this.#processedTests.get(testFile)! - + // Process ALL unprocessed tests const unprocessedTests = testcaseNames.filter(name => !processedForSuite.has(name)) - + // Process completed tests one at a time with delays for (const currentTestName of unprocessedTests) { const testcase = testcases[currentTestName] - const testState: 'passed' | 'failed' = (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' + // Determine test state: skipped (0/0), passed (>0/0), or failed + const testState: 'passed' | 'failed' | 'skipped' = + (testcase.passed === 0 && testcase.failed === 0) + ? 'skipped' + : (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' const finalTestUid = `${currentSuite.uid}::${currentTestName}` - + // Map all commands from temp UID to final UID const tempUid = this.#currentTest.uid if (tempUid && tempUid !== finalTestUid) { @@ -540,21 +525,21 @@ class NightwatchDevToolsPlugin { } }) } - + // Find existing test and update it const testIndex = currentSuite.tests.findIndex( (t: any) => typeof t !== 'string' && t.title === currentTestName ) - + if (testIndex !== -1) { // Update existing test with final state currentSuite.tests[testIndex].state = testState currentSuite.tests[testIndex].end = new Date() currentSuite.tests[testIndex]._duration = parseFloat(testcase.time || '0') * 1000 currentSuite.tests[testIndex].uid = finalTestUid - + console.log(`[STATE] Test "${currentTestName}" → ${testState.toUpperCase()}`) - + // Report final state this.testReporter.onTestEnd(currentSuite.tests[testIndex]) } else { @@ -576,11 +561,11 @@ class NightwatchDevToolsPlugin { } currentSuite.tests.push(testStats) } - + // Mark as processed processedForSuite.add(currentTestName) } - + // After processing all tests, check if suite is complete if (processedForSuite.size === testcaseNames.length) { // All tests in this suite are complete @@ -590,7 +575,7 @@ class NightwatchDevToolsPlugin { currentSuite.state = allPassed ? 'passed' : 'failed' console.log(`[STATE] Suite "${currentSuite.title}" → ${currentSuite.state.toUpperCase()} (${currentSuite.tests.length} tests)`) this.testReporter.onSuiteEnd(currentSuite) - + // Give UI time to process suite completion before next suite starts await new Promise(resolve => setTimeout(resolve, 200)) } else { @@ -612,7 +597,7 @@ class NightwatchDevToolsPlugin { } } } - + // Capture trace data from browser await this.sessionCapturer.captureTrace(browser) } catch (err) { @@ -630,14 +615,14 @@ class NightwatchDevToolsPlugin { // Process any remaining incomplete suites for (const [testFile, suite] of this.#currentSuiteByFile.entries()) { const processedTests = this.#processedTests.get(testFile) || new Set() - + // Mark any tests still in "running" state as passed (they completed successfully if we're here) // For pending tests, check if they actually ran by looking at Nightwatch results const currentTest = (browser as any)?.currentTest const results = currentTest?.results || {} const testcases = results.testcases || {} const actualTestNames = Object.keys(testcases) - + suite.tests.forEach((test: any) => { if (test.state === 'running' && test.start) { // Test was started but never finished - assume passed @@ -651,7 +636,11 @@ class NightwatchDevToolsPlugin { const testcase = testcases[test.title] if (testcase) { // Test ran but we didn't track it properly - update it now - const testState: 'passed' | 'failed' = (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' + // Determine test state: skipped (0/0), passed (>0/0), or failed + const testState: 'passed' | 'failed' | 'skipped' = + (testcase.passed === 0 && testcase.failed === 0) + ? 'skipped' + : (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' test.state = testState test.start = test.start || new Date() test.end = new Date() @@ -668,35 +657,51 @@ class NightwatchDevToolsPlugin { } } }) - + // Give UI time to process test completions await new Promise(resolve => setTimeout(resolve, 200)) - + // Now mark suite as complete if (!suite.end) { // Mark suite as complete suite.end = new Date() suite._duration = suite.end.getTime() - (suite.start?.getTime() || 0) + + // Determine suite state based on test states + const hasFailures = suite.tests.some((t: any) => t.state === 'failed') const allPassed = suite.tests.every((t: any) => t.state === 'passed') - suite.state = allPassed ? 'passed' : 'failed' + const hasSkipped = suite.tests.some((t: any) => t.state === 'skipped') + + if (hasFailures) { + suite.state = 'failed' + } else if (allPassed) { + suite.state = 'passed' + } else if (hasSkipped) { + // If there are skipped tests but no failures, mark as passed + // (this matches WebDriverIO behavior) + suite.state = 'passed' + } else { + suite.state = 'failed' + } + console.log(`[STATE] Suite "${suite.title}" → ${suite.state.toUpperCase()} (${suite.tests.length} tests)`) this.testReporter.onSuiteEnd(suite) } } - + // Give UI time to process all final updates before showing completion message await new Promise(resolve => setTimeout(resolve, 200)) - + log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') log.info('āœ… Tests complete!') log.info('šŸ’” Please close the DevTools browser window to exit') log.info(' Or press Ctrl+C to force exit') log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') - + // Keep polling until the WebSocket connection is closed // This indicates the browser window was closed await this.#waitForBrowserClose() - + // Close devtools browser if still open if (this.#devtoolsBrowser) { try { @@ -705,7 +710,7 @@ class NightwatchDevToolsPlugin { // Already closed } } - + log.info('šŸ›‘ Stopping DevTools backend...') await stop() log.info('āœ“ Backend stopped') @@ -742,7 +747,7 @@ class NightwatchDevToolsPlugin { log.info('\nāœ“ Received exit signal (Ctrl+C)') resolve() } - + process.once('SIGINT', sigintHandler) process.once('SIGTERM', sigintHandler) }) @@ -760,7 +765,7 @@ export default function createNightwatchDevTools(options?: DevToolsOptions) { // Set long timeout to allow user to review DevTools UI // The after() hook will wait for browser window to close asyncHookTimeout: 3600000, // 1 hour - + before: async function(this: any) { await plugin.before() }, diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts index 00fd989..ca1ea98 100644 --- a/packages/nightwatch-devtools/src/session.ts +++ b/packages/nightwatch-devtools/src/session.ts @@ -56,7 +56,8 @@ export class SessionCapturer { } #isCapturingConsole = false #browser: NightwatchBrowser | undefined - #lastCommandSig: string | null = null + #commandCounter = 0 // Sequential ID for commands + #sentCommandIds = new Set() // Track which commands have been sent commandsLog: CommandLog[] = [] sources = new Map() @@ -71,17 +72,17 @@ export class SessionCapturer { this.#browser = browser if (hostname && port) { this.#ws = new WebSocket(`ws://${hostname}:${port}/worker`) - + this.#ws.on('open', () => { log.info('āœ“ Worker WebSocket connected to backend') }) - + this.#ws.on('error', (err: unknown) => log.error( `Couldn't connect to devtools backend: ${(err as Error).message}` ) ) - + this.#ws.on('close', () => { log.info('Worker WebSocket disconnected') }) @@ -107,7 +108,7 @@ export class SessionCapturer { // Temporarily disable console patching to prevent infinite loops // We can re-enable this later with better filtering return - + CONSOLE_METHODS.forEach((method) => { const originalMethod = this.#originalConsoleMethods[method] console[method] = (...consoleArgs: any[]) => { @@ -136,7 +137,7 @@ export class SessionCapturer { // Set flag before capturing to prevent recursion this.#isCapturingConsole = true - + const logEntry = createConsoleLogEntry( method, serializedArgs, @@ -146,7 +147,7 @@ export class SessionCapturer { this.sendUpstream('consoleLogs', [logEntry]) const result = originalMethod.apply(console, consoleArgs) - + // Reset flag after everything is done this.#isCapturingConsole = false return result @@ -155,13 +156,13 @@ export class SessionCapturer { } #interceptProcessStreams() { - // Temporarily disable stream interception to prevent infinite loops + // Temporarily disable stream interception to prevent infinite loops // We can re-enable this later with better filtering return - + // Regex to detect spinner/progress characters const spinnerRegex = /[\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f]/ - + const captureTerminalOutput = (outputData: string | Uint8Array) => { const outputText = typeof outputData === 'string' ? outputData : outputData.toString() @@ -177,13 +178,13 @@ export class SessionCapturer { if (spinnerRegex.test(line)) { return } - + // Strip ANSI codes and check if there's actual content const cleanedLine = stripAnsiCodes(line).trim() if (!cleanedLine) { return } - + const logEntry = createConsoleLogEntry( detectLogLevel(cleanedLine), [cleanedLine], @@ -279,21 +280,6 @@ export class SessionCapturer { callSource?: string, timestamp?: number ): Promise { - // Create command signature to detect duplicate captures (e.g., on retries) - const cmdSig = JSON.stringify({ - command, - args, - src: callSource - }) - - // Skip if this is the same command as the last one (retry scenario) - if (this.#lastCommandSig === cmdSig) { - return false - } - - // Update last command signature - this.#lastCommandSig = cmdSig - // Serialize error properly (Error objects don't JSON.stringify well) const serializedError = error ? { name: error.name, @@ -301,7 +287,9 @@ export class SessionCapturer { stack: error.stack } : undefined - const commandLogEntry: CommandLog = { + const commandId = this.#commandCounter++ + const commandLogEntry: CommandLog & { _id?: number } = { + _id: commandId, // Internal ID for tracking command, args, result, @@ -311,95 +299,113 @@ export class SessionCapturer { testUid } - // Capture performance data for navigation commands (url, navigate, etc.) - const isNavigationCommand = ['url', 'navigate', 'navigateTo'].some(cmd => + // IMPORTANT: Push to commandsLog FIRST (synchronously) + // so it's available immediately for sending + this.commandsLog.push(commandLogEntry) + + // THEN do async performance capture for navigation commands + const isNavigationCommand = ['url', 'navigate', 'navigateTo'].some(cmd => command.toLowerCase().includes(cmd.toLowerCase()) ) - + if (isNavigationCommand && this.#browser && !error) { - try { - // Wait a bit for page to load - await new Promise(resolve => setTimeout(resolve, 500)) - - // Execute script to capture performance data - // Nightwatch's execute() requires a function, not a string - const performanceData = await this.#browser.execute(function() { - // @ts-ignore - executed in browser context - const performance = window.performance; - // @ts-ignore - const navigation = performance.getEntriesByType?.('navigation')?.[0]; + // Do this async work in the background without blocking + // Update the commandLogEntry that's already in the array + this.#capturePerformanceData(commandLogEntry, args).catch((err) => { + console.log(`āš ļø Failed to capture performance data: ${(err as Error).message}`) + }) + } + + return true + } + + async #capturePerformanceData(commandLogEntry: CommandLog & { _id?: number }, args: any[]) { + // Wait a bit for page to load + await new Promise(resolve => setTimeout(resolve, 500)) + + // Execute script to capture performance data + // Nightwatch's execute() requires a function, not a string + const performanceData = await this.#browser!.execute(function() { + // @ts-ignore - executed in browser context + const performance = window.performance; + // @ts-ignore + const navigation = performance.getEntriesByType?.('navigation')?.[0]; + // @ts-ignore + const resources = performance.getEntriesByType?.('resource') || []; + + return { + navigation: navigation ? { // @ts-ignore - const resources = performance.getEntriesByType?.('resource') || []; - - return { - navigation: navigation ? { - // @ts-ignore - url: window.location.href, - timing: { - loadTime: navigation.loadEventEnd - navigation.fetchStart, - domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart, - firstPaint: performance.getEntriesByType?.('paint')?.[0]?.startTime || 0 - } - } : null, - resources: resources.map((r: any) => ({ - name: r.name, - type: r.initiatorType, - size: r.transferSize || r.decodedBodySize || 0, - duration: r.duration - })), - // @ts-ignore - cookies: document.cookie, - documentInfo: { - // @ts-ignore - title: document.title, - // @ts-ignore - url: window.location.href, - // @ts-ignore - referrer: document.referrer - } - }; - }) - - // Nightwatch returns{value: result} or just the result directly - let data: any - if (performanceData && typeof performanceData === 'object') { - // Check if it has a 'value' property (WebDriver format) - if ('value' in performanceData) { - data = (performanceData as any).value - } else { - // It might be the data directly - data = performanceData - } - } - - if (data && data.navigation) { - commandLogEntry.performance = { - navigation: data.navigation, - resources: data.resources + url: window.location.href, + timing: { + loadTime: navigation.loadEventEnd - navigation.fetchStart, + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart, + firstPaint: performance.getEntriesByType?.('paint')?.[0]?.startTime || 0 } - commandLogEntry.cookies = data.cookies - commandLogEntry.documentInfo = data.documentInfo - - // Always set result with performance data for consistency - commandLogEntry.result = { - url: args[0], - loadTime: data.navigation?.timing?.loadTime, - resources: data.resources, - resourceCount: data.resources?.length, - cookies: data.cookies, - title: data.documentInfo?.title - } - - console.log(`āœ“ Captured performance data: ${data.resources?.length || 0} resources, load time: ${data.navigation?.timing?.loadTime || 0}ms`) + } : null, + resources: resources.map((r: any) => ({ + name: r.name, + type: r.initiatorType, + size: r.transferSize || r.decodedBodySize || 0, + duration: r.duration + })), + // @ts-ignore + cookies: document.cookie, + documentInfo: { + // @ts-ignore + title: document.title, + // @ts-ignore + url: window.location.href, + // @ts-ignore + referrer: document.referrer } - } catch (err) { - console.log(`āš ļø Failed to capture performance data: ${(err as Error).message}`) + }; + }) + + // Nightwatch returns {value: result} or just the result directly + let data: any + if (performanceData && typeof performanceData === 'object') { + // Check if it has a 'value' property (WebDriver format) + if ('value' in performanceData) { + data = (performanceData as any).value + } else { + // It might be the data directly + data = performanceData } } - this.commandsLog.push(commandLogEntry) - this.sendUpstream('commands', [commandLogEntry]) - return true + if (data && data.navigation) { + commandLogEntry.performance = { + navigation: data.navigation, + resources: data.resources + } + commandLogEntry.cookies = data.cookies + commandLogEntry.documentInfo = data.documentInfo + + // Always set result with performance data for consistency + commandLogEntry.result = { + url: args[0], + loadTime: data.navigation?.timing?.loadTime, + resources: data.resources, + resourceCount: data.resources?.length, + cookies: data.cookies, + title: data.documentInfo?.title + } + + console.log(`āœ“ Captured performance data: ${data.resources?.length || 0} resources, load time: ${data.navigation?.timing?.loadTime || 0}ms`) + } + } + + /** + * Send a command to the UI (only if not already sent) + */ + sendCommand(command: CommandLog & { _id?: number }) { + if (command._id !== undefined && !this.#sentCommandIds.has(command._id)) { + this.#sentCommandIds.add(command._id) + // Remove internal ID before sending + const { _id, ...commandToSend } = command + this.sendUpstream('commands', [commandToSend]) + } } /** @@ -474,14 +480,14 @@ export class SessionCapturer { const injectResult = await browser.execute(injectionScript, [scriptContent]) log.info(`Injection command executed: ${JSON.stringify(injectResult)}`) - + // Wait for script to execute await browser.pause(300) - + // Check if collector exists using string-based execute const checkScript = 'return typeof window.wdioTraceCollector !== "undefined"' const checkResult = await browser.execute(checkScript) - + // Nightwatch wraps results in { value: ... } const hasCollector = (checkResult as any)?.value === true @@ -519,7 +525,7 @@ export class SessionCapturer { } return window.wdioTraceCollector.getTraceData(); `) - + const traceData = (result as any)?.value if (!traceData) { From 58f797169fa854295ed768addd4e26f4f8d0c669 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 19 Feb 2026 16:38:17 +0530 Subject: [PATCH 3/4] Command capture enhancement --- .../app/src/components/sidebar/explorer.ts | 37 +- packages/backend/src/index.ts | 22 +- packages/nightwatch-devtools/README.md | 63 +- .../example/nightwatch.conf.js | 361 --------- .../nightwatch-devtools/example/validate.cjs | 59 -- packages/nightwatch-devtools/package.json | 1 - packages/nightwatch-devtools/src/constants.ts | 48 +- .../src/helpers/browserProxy.ts | 262 ++++++ .../src/{ => helpers}/capturePerformance.ts | 4 +- .../src/helpers/suiteManager.ts | 126 +++ .../src/helpers/testManager.ts | 165 ++++ .../src/{ => helpers}/utils.ts | 0 packages/nightwatch-devtools/src/index.ts | 750 +++++------------- packages/nightwatch-devtools/src/reporter.ts | 130 ++- packages/nightwatch-devtools/src/session.ts | 7 +- packages/nightwatch-devtools/src/types.ts | 28 +- packages/service/src/launcher.ts | 5 +- 17 files changed, 1006 insertions(+), 1062 deletions(-) delete mode 100644 packages/nightwatch-devtools/example/nightwatch.conf.js delete mode 100644 packages/nightwatch-devtools/example/validate.cjs create mode 100644 packages/nightwatch-devtools/src/helpers/browserProxy.ts rename packages/nightwatch-devtools/src/{ => helpers}/capturePerformance.ts (94%) create mode 100644 packages/nightwatch-devtools/src/helpers/suiteManager.ts create mode 100644 packages/nightwatch-devtools/src/helpers/testManager.ts rename packages/nightwatch-devtools/src/{ => helpers}/utils.ts (100%) diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 0fd3f05..8a824e6 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -31,6 +31,13 @@ import type { DevtoolsSidebarFilter } from './filter.js' const EXPLORER = 'wdio-devtools-sidebar-explorer' +const STATE_MAP: Record = { + 'running': TestState.RUNNING, + 'failed': TestState.FAILED, + 'passed': TestState.PASSED, + 'skipped': TestState.SKIPPED +} + @customElement(EXPLORER) export class DevtoolsSidebarExplorer extends CollapseableEntry { #testFilter: DevtoolsSidebarFilter | undefined @@ -364,6 +371,24 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { return entry.state === 'failed' } + #computeEntryState(entry: TestStats | SuiteStats): TestState { + const state = (entry as any).state + + // Check explicit state first + const mappedState = STATE_MAP[state] + if (mappedState) return mappedState + + // For suites, compute state from children + if ('tests' in entry) { + if (this.#isRunning(entry)) return TestState.RUNNING + if (this.#hasFailed(entry)) return TestState.FAILED + return TestState.PASSED + } + + // For individual tests, check if still running + return !entry.end ? TestState.RUNNING : TestState.PASSED + } + #getTestEntry(entry: TestStats | SuiteStats): TestEntry { if ('tests' in entry) { const entries = [...entry.tests, ...entry.suites] @@ -371,11 +396,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { uid: entry.uid, label: entry.title, type: 'suite', - state: this.#isRunning(entry) - ? TestState.RUNNING - : this.#hasFailed(entry) - ? TestState.FAILED - : TestState.PASSED, + state: this.#computeEntryState(entry), callSource: (entry as any).callSource, specFile: (entry as any).file, fullTitle: entry.title, @@ -391,11 +412,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { uid: entry.uid, label: entry.title, type: 'test', - state: !entry.end - ? TestState.RUNNING - : entry.state === 'failed' - ? TestState.FAILED - : TestState.PASSED, + state: this.#computeEntryState(entry), callSource: (entry as any).callSource, specFile: (entry as any).file, fullTitle: (entry as any).fullTitle || entry.title, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b6c24ab..88f87d4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -30,17 +30,17 @@ export function broadcastToClients(message: string) { }) } -export async function start(opts: DevtoolsBackendOptions = {}) { +export async function start(opts: DevtoolsBackendOptions = {}): Promise<{ server: FastifyInstance; port: number }> { const host = opts.hostname || 'localhost' // Use getPort to find an available port, starting with the preferred port const preferredPort = opts.port || DEFAULT_PORT const port = await getPort({ port: preferredPort }) - + // Log if we had to use a different port if (opts.port && port !== opts.port) { log.warn(`Port ${opts.port} is already in use, using port ${port} instead`) } - + const appPath = await getDevtoolsApp() server = Fastify({ logger: true }) @@ -99,22 +99,22 @@ export async function start(opts: DevtoolsBackendOptions = {}) { log.info( `received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}` ) - + // Parse message to check if it's a clearCommands message try { const parsed = JSON.parse(message.toString()) - + // If this is a clearCommands message, transform it to clear-execution-data format if (parsed.scope === 'clearCommands') { const testUid = parsed.data?.testUid log.info(`Clearing commands for test: ${testUid || 'all'}`) - + // Create a synthetic message that DataManager will understand const clearMessage = JSON.stringify({ scope: 'clearExecutionData', data: { uid: testUid } }) - + clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(clearMessage) @@ -125,7 +125,7 @@ export async function start(opts: DevtoolsBackendOptions = {}) { } catch (e) { // Not JSON or parsing failed, forward as-is } - + // Forward all other messages as-is clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { @@ -138,7 +138,7 @@ export async function start(opts: DevtoolsBackendOptions = {}) { log.info(`Starting WebdriverIO Devtools application on port ${port}`) await server.listen({ port, host }) - return server + return { server, port } } export async function stop() { @@ -147,7 +147,7 @@ export async function stop() { } log.info('Shutting down WebdriverIO Devtools application') - + // Close all WebSocket connections first clients.forEach((client) => { if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { @@ -155,7 +155,7 @@ export async function stop() { } }) clients.clear() - + await server.close() } diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md index 71a4eb7..79932c0 100644 --- a/packages/nightwatch-devtools/README.md +++ b/packages/nightwatch-devtools/README.md @@ -8,7 +8,7 @@ Brings the powerful WebdriverIO DevTools visual debugging interface to Nightwatc See everything in real-time: - šŸ“‹ **Commands** - Every action executed -- šŸ–„ļø **Console** - Browser console logs +- šŸ–„ļø **Console** - Browser console logs - 🌐 **Network** - HTTP requests/responses - āœ… **Tests** - Suite structure and results - šŸ“ **Sources** - Test file contents @@ -28,16 +28,18 @@ Add to your Nightwatch config: ```javascript // nightwatch.conf.js +const nightwatchDevtools = require('@wdio/nightwatch-devtools').default; + module.exports = { src_folders: ['tests'], - - plugins: ['@wdio/nightwatch-devtools'], - + test_settings: { default: { desiredCapabilities: { browserName: 'chrome' - } + }, + // Add DevTools globals with lifecycle hooks + globals: nightwatchDevtools() } } } @@ -51,7 +53,7 @@ nightwatch The DevTools UI will automatically: 1. Start backend server on port 3000 -2. Open in a new browser window +2. Open in a new browser window 3. Stream test data in real-time 4. Stay open after tests finish (close manually to exit) @@ -66,7 +68,6 @@ Run it: ```bash cd packages/nightwatch-devtools pnpm build -pnpm validate # Quick check pnpm example # Run tests with DevTools UI ``` @@ -81,43 +82,31 @@ This is a **thin adapter** (~210 lines) that: Same backend, same UI, same capture as WDIO - just different framework hooks! -## Validation - -Verify the plugin is working: - -```bash -pnpm validate -``` - -Output: -``` -āœ… Plugin compiled (dist/ exists) -āœ… Plugin module loaded -āœ… Plugin exports default class -āœ… Plugin can be instantiated -āœ… All required lifecycle methods present -✨ Plugin validation successful! -``` - ## Options ```javascript -plugins: [ - ['@wdio/nightwatch-devtools', { - port: 3000, // DevTools server port - hostname: 'localhost' // DevTools server hostname - }] -] +const nightwatchDevtools = require('@wdio/nightwatch-devtools').default; + +module.exports = { + test_settings: { + default: { + globals: nightwatchDevtools({ + port: 3000, // DevTools server port (default: 3000) + hostname: 'localhost' // DevTools server hostname (default: 'localhost') + }) + } + } +} ``` ## What Gets Captured -āœ… Test suites and hierarchy -āœ… Test pass/fail status -āœ… Execution timing -āœ… Error messages and stack traces -āœ… Browser console logs (automatic) -āœ… Network requests (automatic) +āœ… Test suites and hierarchy +āœ… Test pass/fail status +āœ… Execution timing +āœ… Error messages and stack traces +āœ… Browser console logs (automatic) +āœ… Network requests (automatic) āœ… DOM mutations (automatic) Browser-side capture works automatically via `@wdio/devtools-script`. diff --git a/packages/nightwatch-devtools/example/nightwatch.conf.js b/packages/nightwatch-devtools/example/nightwatch.conf.js deleted file mode 100644 index b261e70..0000000 --- a/packages/nightwatch-devtools/example/nightwatch.conf.js +++ /dev/null @@ -1,361 +0,0 @@ -// -// Refer to the online docs for more details: -// https://nightwatchjs.org/guide/configuration/nightwatch-configuration-file.html -// -// _ _ _ _ _ _ _ -// | \ | |(_) | | | | | | | | -// | \| | _ __ _ | |__ | |_ __ __ __ _ | |_ ___ | |__ -// | . ` || | / _` || '_ \ | __|\ \ /\ / / / _` || __| / __|| '_ \ -// | |\ || || (_| || | | || |_ \ V V / | (_| || |_ | (__ | | | | -// \_| \_/|_| \__, ||_| |_| \__| \_/\_/ \__,_| \__| \___||_| |_| -// __/ | -// |___/ -// - -module.exports = { - // An array of folders (excluding subfolders) where your tests are located; - // if this is not specified, the test source must be passed as the second argument to the test runner. - src_folders: [], - - // See https://nightwatchjs.org/guide/concepts/page-object-model.html - page_objects_path: ['node_modules/nightwatch/examples/pages/'], - - // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-commands.html - custom_commands_path: ['node_modules/nightwatch/examples/custom-commands/'], - - // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-assertions.html - custom_assertions_path: '', - - // See https://nightwatchjs.org/guide/extending-nightwatch/adding-plugins.html - plugins: [], - - // See https://nightwatchjs.org/guide/concepts/test-globals.html#external-test-globals - globals_path : '', - - // Set this to true to disable bounding boxes on terminal output. Useful when running in some CI environments. - disable_output_boxes: false, - - webdriver: {}, - - test_workers: { - enabled: true, - workers: 'auto' - }, - - test_settings: { - default: { - disable_error_log: false, - launch_url: 'https://nightwatchjs.org', - - screenshots: { - enabled: false, - path: 'screens', - on_failure: true - }, - - desiredCapabilities: { - browserName : 'firefox' - }, - - webdriver: { - start_process: true, - server_path: '' - } - }, - - safari: { - desiredCapabilities : { - browserName : 'safari', - alwaysMatch: { - acceptInsecureCerts: false - } - }, - webdriver: { - start_process: true, - server_path: '' - } - }, - - firefox: { - desiredCapabilities : { - browserName : 'firefox', - acceptInsecureCerts: true, - 'moz:firefoxOptions': { - args: [ - // '-headless', - // '-verbose' - ] - } - }, - webdriver: { - start_process: true, - server_path: '', - cli_args: [ - // very verbose geckodriver logs - // '-vv' - ] - } - }, - - chrome: { - desiredCapabilities : { - browserName : 'chrome', - 'goog:chromeOptions' : { - // More info on Chromedriver: https://sites.google.com/a/chromium.org/chromedriver/ - // - // w3c:false tells Chromedriver to run using the legacy JSONWire protocol (not required in Chrome 78) - w3c: true, - args: [ - //'--no-sandbox', - //'--ignore-certificate-errors', - //'--allow-insecure-localhost', - //'--headless' - ] - } - }, - - webdriver: { - start_process: true, - server_path: '', - cli_args: [ - // '--verbose' - ] - } - }, - - edge: { - desiredCapabilities : { - browserName : 'MicrosoftEdge', - 'ms:edgeOptions' : { - w3c: true, - // More info on EdgeDriver: https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/capabilities-edge-options - args: [ - //'--headless' - ] - } - }, - - webdriver: { - start_process: true, - // Download msedgedriver from https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/ - // and set the location below: - server_path: '', - cli_args: [ - // '--verbose' - ] - } - }, - - ////////////////////////////////////////////////////////////////////////////////// - // Configuration for when using cucumber-js (https://cucumber.io) | - // | - // It uses the bundled examples inside the nightwatch examples folder; feel free | - // to adapt this to your own project needs | - ////////////////////////////////////////////////////////////////////////////////// - 'cucumber-js': { - src_folders: ['examples/cucumber-js/features/step_definitions'], - - test_runner: { - // set cucumber as the runner - type: 'cucumber', - - // define cucumber specific options - options: { - //set the feature path - feature_path: 'node_modules/nightwatch/examples/cucumber-js/*/*.feature', - - // start the webdriver session automatically (enabled by default) - // auto_start_session: true - - // use parallel execution in Cucumber - // workers: 2 // set number of workers to use (can also be defined in the cli as --workers=2 - } - } - }, - - ////////////////////////////////////////////////////////////////////////////////// - // Configuration for when using the browserstack.com cloud service | - // | - // Please set the username and access key by setting the environment variables: | - // - BROWSERSTACK_USERNAME | - // - BROWSERSTACK_ACCESS_KEY | - // .env files are supported | - ////////////////////////////////////////////////////////////////////////////////// - browserstack: { - selenium: { - host: 'hub.browserstack.com', - port: 443 - }, - // More info on configuring capabilities can be found on: - // https://www.browserstack.com/automate/capabilities?tag=selenium-4 - desiredCapabilities: { - 'bstack:options' : { - userName: '${BROWSERSTACK_USERNAME}', - accessKey: '${BROWSERSTACK_ACCESS_KEY}', - } - }, - - disable_error_log: true, - webdriver: { - timeout_options: { - timeout: 60000, - retry_attempts: 3 - }, - keep_alive: true, - start_process: false - } - }, - - 'browserstack.local': { - extends: 'browserstack', - desiredCapabilities: { - 'browserstack.local': true - } - }, - - 'browserstack.chrome': { - extends: 'browserstack', - desiredCapabilities: { - browserName: 'chrome', - chromeOptions : { - w3c: true - } - } - }, - - 'browserstack.firefox': { - extends: 'browserstack', - desiredCapabilities: { - browserName: 'firefox' - } - }, - - 'browserstack.ie': { - extends: 'browserstack', - desiredCapabilities: { - browserName: 'internet explorer', - browserVersion: '11.0' - } - }, - - 'browserstack.safari': { - extends: 'browserstack', - desiredCapabilities: { - browserName: 'safari' - } - }, - - 'browserstack.local_chrome': { - extends: 'browserstack.local', - desiredCapabilities: { - browserName: 'chrome' - } - }, - - 'browserstack.local_firefox': { - extends: 'browserstack.local', - desiredCapabilities: { - browserName: 'firefox' - } - }, - ////////////////////////////////////////////////////////////////////////////////// - // Configuration for when using the SauceLabs cloud service | - // | - // Please set the username and access key by setting the environment variables: | - // - SAUCE_USERNAME | - // - SAUCE_ACCESS_KEY | - ////////////////////////////////////////////////////////////////////////////////// - saucelabs: { - selenium: { - host: 'ondemand.saucelabs.com', - port: 443 - }, - // More info on configuring capabilities can be found on: - // https://docs.saucelabs.com/dev/test-configuration-options/ - desiredCapabilities: { - 'sauce:options' : { - username: '${SAUCE_USERNAME}', - accessKey: '${SAUCE_ACCESS_KEY}', - screenResolution: '1280x1024' - // https://docs.saucelabs.com/dev/cli/sauce-connect-proxy/#--region - // region: 'us-west-1' - // https://docs.saucelabs.com/dev/test-configuration-options/#tunnelidentifier - // parentTunnel: '', - // tunnelIdentifier: '', - } - }, - disable_error_log: false, - webdriver: { - start_process: false - } - }, - 'saucelabs.chrome': { - extends: 'saucelabs', - desiredCapabilities: { - browserName: 'chrome', - browserVersion: 'latest', - javascriptEnabled: true, - acceptSslCerts: true, - timeZone: 'London', - chromeOptions : { - w3c: true - } - } - }, - 'saucelabs.firefox': { - extends: 'saucelabs', - desiredCapabilities: { - browserName: 'firefox', - browserVersion: 'latest', - javascriptEnabled: true, - acceptSslCerts: true, - timeZone: 'London' - } - }, - ////////////////////////////////////////////////////////////////////////////////// - // Configuration for when using the Selenium service, either locally or remote, | - // like Selenium Grid | - ////////////////////////////////////////////////////////////////////////////////// - selenium_server: { - // Selenium Server is running locally and is managed by Nightwatch - // Install the NPM package @nightwatch/selenium-server or download the selenium server jar file from https://github.com/SeleniumHQ/selenium/releases/, e.g.: selenium-server-4.1.1.jar - selenium: { - start_process: true, - port: 4444, - server_path: '', // Leave empty if @nightwatch/selenium-server is installed - command: 'standalone', // Selenium 4 only - cli_args: { - //'webdriver.gecko.driver': '', - //'webdriver.chrome.driver': '' - } - }, - webdriver: { - start_process: false, - default_path_prefix: '/wd/hub' - } - }, - - 'selenium.chrome': { - extends: 'selenium_server', - desiredCapabilities: { - browserName: 'chrome', - chromeOptions : { - w3c: true - } - } - }, - - 'selenium.firefox': { - extends: 'selenium_server', - desiredCapabilities: { - browserName: 'firefox', - 'moz:firefoxOptions': { - args: [ - // '-headless', - // '-verbose' - ] - } - } - } - } -}; diff --git a/packages/nightwatch-devtools/example/validate.cjs b/packages/nightwatch-devtools/example/validate.cjs deleted file mode 100644 index 109b0cb..0000000 --- a/packages/nightwatch-devtools/example/validate.cjs +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Quick validation script to test the plugin without running actual browser tests - * This verifies the plugin can be loaded and instantiated correctly - */ - -const path = require('path'); - -async function validate() { - console.log('šŸ” Validating Nightwatch DevTools Plugin...\n'); - - try { - // Check if dist exists - const distPath = path.join(__dirname, '..', 'dist'); - require('fs').accessSync(distPath); - console.log('āœ… Plugin compiled (dist/ exists)'); - - // Try to load the plugin - const plugin = require(path.join(__dirname, '..', 'dist', 'index.js')); - console.log('āœ… Plugin module loaded'); - - // Check if it exports a class - if (typeof plugin.default === 'function') { - console.log('āœ… Plugin exports default class'); - - // Try to instantiate it - const instance = new plugin.default({ port: 3001 }); - console.log('āœ… Plugin can be instantiated'); - - // Check for required methods - const requiredMethods = ['before', 'beforeSuite', 'beforeEach', 'afterEach', 'after']; - const hasAllMethods = requiredMethods.every(method => typeof instance[method] === 'function'); - - if (hasAllMethods) { - console.log('āœ… All required lifecycle methods present:', requiredMethods.join(', ')); - } else { - console.log('āŒ Missing some lifecycle methods'); - return false; - } - - console.log('\n✨ Plugin validation successful!'); - console.log('\nNext steps:'); - console.log('1. Make sure Chrome/Chromium is installed'); - console.log('2. Run: pnpm rebuild chromedriver'); - console.log('3. Run: pnpm example'); - - return true; - } else { - console.log('āŒ Plugin does not export a class'); - return false; - } - } catch (error) { - console.error('āŒ Validation failed:', error.message); - return false; - } -} - -validate().then(success => { - process.exit(success ? 0 : 1); -}); diff --git a/packages/nightwatch-devtools/package.json b/packages/nightwatch-devtools/package.json index 7a9621d..75a2db1 100644 --- a/packages/nightwatch-devtools/package.json +++ b/packages/nightwatch-devtools/package.json @@ -12,7 +12,6 @@ "build": "tsc", "watch": "tsc --watch", "clean": "rm -rf dist", - "validate": "node example/validate.cjs", "example": "nightwatch -c example/nightwatch.conf.cjs" }, "keywords": [ diff --git a/packages/nightwatch-devtools/src/constants.ts b/packages/nightwatch-devtools/src/constants.ts index fde9876..a3d8c0f 100644 --- a/packages/nightwatch-devtools/src/constants.ts +++ b/packages/nightwatch-devtools/src/constants.ts @@ -30,10 +30,10 @@ export const INTERNAL_COMMANDS_TO_IGNORE = [ 'currentContext', 'setChromeOptions', 'setDeviceName', - 'perform', // Internal command queue executor - 'execute', // We'll filter our own performance capture scripts + 'perform', + 'execute', 'executeAsync', - 'executeScript' // Used internally for performance data capture + 'executeScript' ] as const /** @@ -73,3 +73,45 @@ export const LOG_SOURCES = { * Pattern: ESC [ (optional ?)(digits/semicolons)(letter) */ export const ANSI_REGEX = /\x1b\[[?]?[0-9;]*[A-Za-z]/g + +/** + * Default values + */ +export const DEFAULTS = { + CID: '0-0', + TEST_NAME: 'unknown', + FILE_NAME: 'unknown', + RETRIES: 0, + DURATION: 0 +} as const + +/** + * Timing constants (in milliseconds) + */ +export const TIMING = { + UI_RENDER_DELAY: 150, + TEST_START_DELAY: 100, + SUITE_COMPLETE_DELAY: 200, + UI_CONNECTION_WAIT: 10000, + BROWSER_CLOSE_WAIT: 2000, + INITIAL_CONNECTION_WAIT: 500, + BROWSER_POLL_INTERVAL: 1000 +} as const + +/** + * Test states + */ +export const TEST_STATE = { + PENDING: 'pending', + RUNNING: 'running', + PASSED: 'passed', + FAILED: 'failed', + SKIPPED: 'skipped' +} as const + +export type TestState = typeof TEST_STATE[keyof typeof TEST_STATE] + +/** + * Temporary UID generation pattern + */ +export const TEMP_UID_PREFIX = 'temp' diff --git a/packages/nightwatch-devtools/src/helpers/browserProxy.ts b/packages/nightwatch-devtools/src/helpers/browserProxy.ts new file mode 100644 index 0000000..404f82e --- /dev/null +++ b/packages/nightwatch-devtools/src/helpers/browserProxy.ts @@ -0,0 +1,262 @@ +/** + * Browser Proxy + * Handles browser command interception and tracking + */ + +import logger from '@wdio/logger' +import { INTERNAL_COMMANDS_TO_IGNORE } from '../constants.js' +import { getCallSourceFromStack } from './utils.js' +import type { SessionCapturer } from '../session.js' +import type { TestManager } from './testManager.js' +import type { NightwatchBrowser } from '../types.js' + +const log = logger('@wdio/nightwatch-devtools:browserProxy') + +interface CommandStackFrame { + command: string + callSource?: string + signature: string +} + +export class BrowserProxy { + private browserProxied = false + private commandStack: CommandStackFrame[] = [] + private lastCommandSig: string | null = null + private currentTestFullPath: string | null = null + + constructor( + private sessionCapturer: SessionCapturer, + private testManager: TestManager, + private getCurrentTest: () => any + ) {} + + /** + * Reset command tracking for new test + */ + resetCommandTracking(): void { + this.commandStack = [] + this.lastCommandSig = null + } + + /** + * Get current test file path + */ + getCurrentTestFullPath(): string | null { + return this.currentTestFullPath + } + + /** + * Set current test file path + */ + setCurrentTestFullPath(path: string | null): void { + this.currentTestFullPath = path + } + + /** + * Wrap browser.url to inject script after navigation + */ + wrapUrlMethod(browser: NightwatchBrowser): void { + const originalUrl = browser.url.bind(browser) + const sessionCapturer = this.sessionCapturer + + browser.url = function(url: string) { + const result = originalUrl(url) as any + + if (result && typeof result.perform === 'function') { + result.perform(async function(this: any) { + try { + log.info(`Injecting script after navigation to: ${url}`) + await sessionCapturer.injectScript(this) + } catch (err) { + log.error(`Failed to inject script: ${(err as Error).message}`) + } + }) + } + + return result + } as any + + log.info('āœ“ Script injection wrapped') + } + + /** + * Wrap all browser commands to capture them + */ + wrapBrowserCommands(browser: NightwatchBrowser): void { + if (this.browserProxied) return + + const browserAny = browser as any + const allMethods = new Set([ + ...Object.keys(browser), + ...Object.getOwnPropertyNames(Object.getPrototypeOf(browser)) + ]) + const wrappedMethods: string[] = [] + + allMethods.forEach(methodName => { + if (methodName === 'constructor' || typeof browserAny[methodName] !== 'function') { + return + } + + if (INTERNAL_COMMANDS_TO_IGNORE.includes(methodName as any) || methodName.startsWith('__')) { + return + } + + const originalMethod = browserAny[methodName].bind(browser) + + browserAny[methodName] = (...args: any[]) => { + return this.handleCommandExecution(browser, browserAny, methodName, originalMethod, args) + } + + wrappedMethods.push(methodName) + }) + + this.browserProxied = true + log.info(`āœ“ Wrapped ${wrappedMethods.length} browser methods`) + } + + /** + * Handle command execution with tracking + */ + private handleCommandExecution( + browser: NightwatchBrowser, + browserAny: any, + methodName: string, + originalMethod: Function, + args: any[] + ): any { + // Detect test boundaries + const currentNightwatchTest = browserAny.currentTest + const currentTestName = this.testManager.detectTestBoundary(currentNightwatchTest) + + // Start test if this is its first command + this.testManager.startTestIfPending(currentTestName) + + // Get call source + const callInfo = getCallSourceFromStack() + if (callInfo.filePath && !this.currentTestFullPath) { + this.currentTestFullPath = callInfo.filePath + } + + // Check for duplicate commands + const cmdSig = JSON.stringify({ command: methodName, args, src: callInfo.callSource }) + const isDuplicate = this.lastCommandSig === cmdSig + + if (!isDuplicate) { + this.commandStack.push({ + command: methodName, + callSource: callInfo.callSource, + signature: cmdSig + }) + this.lastCommandSig = cmdSig + } + + try { + const result = originalMethod(...args) + + // Capture command after execution + const stackFrame = this.commandStack[this.commandStack.length - 1] + if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) { + this.commandStack.pop() + this.captureCommandResult(methodName, args, result, callInfo.callSource, browser, browserAny) + } + + return result + } catch (error) { + // Capture command error + const stackFrame = this.commandStack[this.commandStack.length - 1] + if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) { + this.commandStack.pop() + this.captureCommandError(methodName, args, error, callInfo.callSource) + } + + throw error + } + } + + /** + * Capture command result + */ + private captureCommandResult( + methodName: string, + args: any[], + result: any, + callSource: string | undefined, + browser: NightwatchBrowser, + browserAny: any + ): void { + const currentTest = this.getCurrentTest() + if (!currentTest) return + + // Serialize result + let serializedResult: any = undefined + const isBrowserObject = result === browser || result === browserAny + const isChainableAPI = result && typeof result === 'object' && + ('queue' in result || 'sessionId' in result || 'capabilities' in result) + + if (isBrowserObject || isChainableAPI) { + const isWaitCommand = methodName.startsWith('waitFor') + serializedResult = isWaitCommand ? true : undefined + } else if (result && typeof result === 'object') { + if ('value' in result) { + serializedResult = result.value + } else { + try { + serializedResult = JSON.parse(JSON.stringify(result)) + } catch { + serializedResult = String(result) + } + } + } else if (result !== undefined) { + serializedResult = result + } + + // Capture and send command immediately + this.sessionCapturer.captureCommand( + methodName, + args, + serializedResult, + undefined, + currentTest.uid, + callSource + ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) + + const lastCommand = this.sessionCapturer.commandsLog[this.sessionCapturer.commandsLog.length - 1] + if (lastCommand) { + this.sessionCapturer.sendCommand(lastCommand) + } + } + + /** + * Capture command error + */ + private captureCommandError( + methodName: string, + args: any[], + error: any, + callSource: string | undefined + ): void { + const currentTest = this.getCurrentTest() + if (!currentTest) return + + this.sessionCapturer.captureCommand( + methodName, + args, + undefined, + error instanceof Error ? error : new Error(String(error)), + currentTest.uid, + callSource + ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) + + const lastCommand = this.sessionCapturer.commandsLog[this.sessionCapturer.commandsLog.length - 1] + if (lastCommand) { + this.sessionCapturer.sendCommand(lastCommand) + } + } + + /** + * Check if browser is already proxied + */ + isProxied(): boolean { + return this.browserProxied + } +} diff --git a/packages/nightwatch-devtools/src/capturePerformance.ts b/packages/nightwatch-devtools/src/helpers/capturePerformance.ts similarity index 94% rename from packages/nightwatch-devtools/src/capturePerformance.ts rename to packages/nightwatch-devtools/src/helpers/capturePerformance.ts index c64ca09..6ec7934 100644 --- a/packages/nightwatch-devtools/src/capturePerformance.ts +++ b/packages/nightwatch-devtools/src/helpers/capturePerformance.ts @@ -35,7 +35,9 @@ export const getCapturePerformanceScript = (): string => { responseEnd: resource.responseEnd }; }), - cookies: document.cookie, + cookies: (function() { + try { return document.cookie; } catch (e) { return ''; } + })(), documentInfo: { url: window.location.href, title: document.title, diff --git a/packages/nightwatch-devtools/src/helpers/suiteManager.ts b/packages/nightwatch-devtools/src/helpers/suiteManager.ts new file mode 100644 index 0000000..dfa01f4 --- /dev/null +++ b/packages/nightwatch-devtools/src/helpers/suiteManager.ts @@ -0,0 +1,126 @@ +/** + * Suite Manager + * Handles test suite creation and management + */ + +import logger from '@wdio/logger' +import { DEFAULTS, TIMING, TEST_STATE } from '../constants.js' +import { determineTestState, type SuiteStats, type TestStats, type NightwatchTestCase } from '../types.js' +import type { TestReporter } from '../reporter.js' + +const log = logger('@wdio/nightwatch-devtools:suiteManager') + +export class SuiteManager { + private currentSuiteByFile = new Map() + + constructor(private testReporter: TestReporter) {} + + /** + * Get or create suite for a test file + */ + getOrCreateSuite( + testFile: string, + suiteTitle: string, + fullPath: string | null, + testNames: string[] + ): SuiteStats { + if (!this.currentSuiteByFile.has(testFile)) { + const suiteStats: SuiteStats = { + uid: '', + cid: DEFAULTS.CID, + title: suiteTitle, + fullTitle: suiteTitle, + file: fullPath || testFile, + type: 'suite' as const, + start: new Date(), + state: TEST_STATE.PENDING, + end: null, + tests: [], + suites: [], + hooks: [], + _duration: DEFAULTS.DURATION + } + + suiteStats.uid = this.testReporter.generateStableUid(suiteStats.file, suiteStats.title) + + // Create test entries with pending state + if (testNames.length > 0) { + for (const testName of testNames) { + const fullTitle = `${suiteTitle} ${testName}` + // Generate stable UID using same method as onTestStart + const testUid = this.testReporter.generateStableUid(fullPath || testFile, fullTitle) + const testEntry: TestStats = { + uid: testUid, + cid: DEFAULTS.CID, + title: testName, + fullTitle: fullTitle, + parent: suiteStats.uid, + state: TEST_STATE.PENDING as TestStats['state'], + start: new Date(), + end: null, + type: 'test' as const, + file: fullPath || testFile, + retries: DEFAULTS.RETRIES, + _duration: DEFAULTS.DURATION, + hooks: [] + } + suiteStats.tests.push(testEntry) + } + // Don't send updates here - onSuiteStart will send it + } + + this.currentSuiteByFile.set(testFile, suiteStats) + this.testReporter.onSuiteStart(suiteStats) + } + + return this.currentSuiteByFile.get(testFile)! + } + + /** + * Get suite for a test file + */ + getSuite(testFile: string): SuiteStats | undefined { + return this.currentSuiteByFile.get(testFile) + } + + /** + * Mark suite as running + */ + markSuiteAsRunning(suite: SuiteStats): void { + suite.state = TEST_STATE.RUNNING + this.testReporter.updateSuites() + } + + /** + * Finalize suite with test results + */ + finalizeSuite(suite: SuiteStats): void { + if (suite.end) return // Already finalized + + suite.end = new Date() + suite._duration = suite.end.getTime() - (suite.start?.getTime() || 0) + + const hasFailures = suite.tests.some((t: any) => t.state === TEST_STATE.FAILED) + const allPassed = suite.tests.every((t: any) => t.state === TEST_STATE.PASSED) + const hasSkipped = suite.tests.some((t: any) => t.state === TEST_STATE.SKIPPED) + + if (hasFailures) { + suite.state = TEST_STATE.FAILED + } else if (allPassed) { + suite.state = TEST_STATE.PASSED + } else if (hasSkipped) { + suite.state = TEST_STATE.PASSED + } else { + suite.state = TEST_STATE.FAILED + } + + this.testReporter.onSuiteEnd(suite) + } + + /** + * Get all suites + */ + getAllSuites(): Map { + return this.currentSuiteByFile + } +} diff --git a/packages/nightwatch-devtools/src/helpers/testManager.ts b/packages/nightwatch-devtools/src/helpers/testManager.ts new file mode 100644 index 0000000..dd29ef2 --- /dev/null +++ b/packages/nightwatch-devtools/src/helpers/testManager.ts @@ -0,0 +1,165 @@ +/** + * Test Manager + * Handles test lifecycle, state management, and boundary detection + */ + +import logger from '@wdio/logger' +import { TEST_STATE, DEFAULTS } from '../constants.js' +import { determineTestState, type TestStats, type SuiteStats, type NightwatchTestCase } from '../types.js' +import type { TestReporter } from '../reporter.js' + +const log = logger('@wdio/nightwatch-devtools:testManager') + +export class TestManager { + private processedTests = new Map>() + private lastKnownTestName: string | null = null + + constructor(private testReporter: TestReporter) {} + + /** + * Update test state and report to UI + */ + updateTestState( + test: TestStats, + state: TestStats['state'], + endTime?: Date, + duration?: number + ): void { + test.state = state + if (endTime) test.end = endTime + if (duration !== undefined) test._duration = duration + + if (state === TEST_STATE.PASSED) { + this.testReporter.onTestPass(test) + } else if (state === TEST_STATE.FAILED) { + this.testReporter.onTestFail(test) + } else if (state === TEST_STATE.RUNNING) { + this.testReporter.onTestStart(test) + } + + if (state !== TEST_STATE.RUNNING) { + this.testReporter.onTestEnd(test) + } + } + + /** + * Find test in suite by title + */ + findTestInSuite(suite: SuiteStats, testTitle: string): TestStats | undefined { + return suite.tests.find( + (t): t is TestStats => typeof t !== 'string' && t.title === testTitle + ) + } + + /** + * Mark test as processed to avoid duplicate reporting + */ + markTestAsProcessed(testFile: string, testTitle: string): void { + if (!this.processedTests.has(testFile)) { + this.processedTests.set(testFile, new Set()) + } + this.processedTests.get(testFile)!.add(testTitle) + } + + /** + * Check if test has been processed + */ + isTestProcessed(testFile: string, testTitle: string): boolean { + return this.processedTests.get(testFile)?.has(testTitle) ?? false + } + + /** + * Get processed tests for a file + */ + getProcessedTests(testFile: string): Set { + return this.processedTests.get(testFile) || new Set() + } + + /** + * Detect test boundary and finalize previous test if needed + * Returns the current test name + */ + detectTestBoundary(currentNightwatchTest: any): string { + const currentTestName = currentNightwatchTest?.name || DEFAULTS.TEST_NAME + + // If test name changed, finalize previous test + if (this.lastKnownTestName && + currentTestName !== this.lastKnownTestName && + currentTestName !== DEFAULTS.TEST_NAME) { + + const testFile = currentNightwatchTest.module?.split('/').pop() || DEFAULTS.FILE_NAME + const currentSuite = this.testReporter.getCurrentSuite() + + if (currentSuite) { + const prevTest = this.findTestInSuite(currentSuite, this.lastKnownTestName) + if (prevTest && prevTest.state === TEST_STATE.RUNNING) { + const prevTestCase = currentNightwatchTest.results?.testcases?.[this.lastKnownTestName] + + if (prevTestCase) { + const testState = determineTestState(prevTestCase) + this.updateTestState(prevTest, testState, new Date(), parseFloat(prevTestCase.time || '0') * 1000) + this.markTestAsProcessed(testFile, this.lastKnownTestName) + } + } + } + } + + // Update last known test name + if (currentTestName !== DEFAULTS.TEST_NAME) { + this.lastKnownTestName = currentTestName + } + + return currentTestName + } + + /** + * Start a pending test if this is its first command + */ + startTestIfPending(currentTestName: string): void { + if (currentTestName === DEFAULTS.TEST_NAME || this.lastKnownTestName !== currentTestName) { + return + } + + const currentSuite = this.testReporter.getCurrentSuite() + if (!currentSuite) return + + const test = this.findTestInSuite(currentSuite, currentTestName) + if (test && test.state === TEST_STATE.PENDING) { + test.start = new Date() + test.end = null + this.updateTestState(test, TEST_STATE.RUNNING as TestStats['state']) + } + } + + /** + * Finalize all incomplete tests in a suite + */ + finalizeSuiteTests(suite: SuiteStats, testcases: Record): void { + suite.tests.forEach((test: any) => { + if (test.state === TEST_STATE.RUNNING && test.start) { + // Test was started but never finished - assume passed + test.state = TEST_STATE.PASSED + test.end = new Date() + test._duration = test.end.getTime() - (test.start?.getTime() || 0) + this.updateTestState(test, TEST_STATE.PASSED as TestStats['state']) + } else if (test.state === TEST_STATE.PENDING) { + const testcase = testcases[test.title] + if (testcase) { + const testState = determineTestState(testcase) + test.start = test.start || new Date() + this.updateTestState(test, testState, new Date(), parseFloat(testcase.time || '0') * 1000) + } else { + // Test never ran - mark as skipped + this.updateTestState(test, TEST_STATE.SKIPPED as TestStats['state'], new Date(), 0) + } + } + }) + } + + /** + * Reset internal state for new test files + */ + reset(): void { + this.lastKnownTestName = null + } +} diff --git a/packages/nightwatch-devtools/src/utils.ts b/packages/nightwatch-devtools/src/helpers/utils.ts similarity index 100% rename from packages/nightwatch-devtools/src/utils.ts rename to packages/nightwatch-devtools/src/helpers/utils.ts diff --git a/packages/nightwatch-devtools/src/index.ts b/packages/nightwatch-devtools/src/index.ts index f3cbb53..9dc60f0 100644 --- a/packages/nightwatch-devtools/src/index.ts +++ b/packages/nightwatch-devtools/src/index.ts @@ -7,14 +7,24 @@ import * as fs from 'node:fs' import * as path from 'node:path' +import * as os from 'node:os' import { start, stop } from '@wdio/devtools-backend' import logger from '@wdio/logger' import { remote } from 'webdriverio' import { SessionCapturer } from './session.js' import { TestReporter } from './reporter.js' -import { TraceType, type DevToolsOptions, type NightwatchBrowser } from './types.js' -import { INTERNAL_COMMANDS_TO_IGNORE } from './constants.js' -import { findTestFileFromStack, findTestFileByName, extractTestMetadata, getCallSourceFromStack } from './utils.js' +import { TestManager } from './helpers/testManager.js' +import { SuiteManager } from './helpers/suiteManager.js' +import { BrowserProxy } from './helpers/browserProxy.js' +import { + TraceType, + determineTestState, + type DevToolsOptions, + type NightwatchBrowser, + type TestStats +} from './types.js' +import { DEFAULTS, TIMING, TEST_STATE } from './constants.js' +import { findTestFileFromStack, findTestFileByName, extractTestMetadata } from './helpers/utils.js' const log = logger('@wdio/nightwatch-devtools') @@ -22,19 +32,15 @@ class NightwatchDevToolsPlugin { private options: Required private sessionCapturer!: SessionCapturer private testReporter!: TestReporter + private testManager!: TestManager + private suiteManager!: SuiteManager + private browserProxy!: BrowserProxy private isScriptInjected = false - #currentSuiteByFile = new Map() #currentTest: any = null #currentTestFile: string | null = null - #currentTestFullPath: string | null = null // Store full path from callSource - #processedTests = new Map>() // Track which tests have been created per suite - #browserProxied = false - #lastSessionId: string | null = null // Track browser session changes + #lastSessionId: string | null = null #devtoolsBrowser?: WebdriverIO.Browser - - // Command stack pattern (from WDIO DevTools) - #commandStack: Array<{ command: string; callSource?: string; signature: string }> = [] - #lastCommandSig: string | null = null + #userDataDir?: string constructor(options: DevToolsOptions = {}) { this.options = { @@ -50,12 +56,12 @@ class NightwatchDevToolsPlugin { async before() { try { log.info('šŸš€ Starting DevTools backend...') - const server = await start(this.options) + const { server, port } = await start(this.options) - // Fastify's server doesn't have addresses() until after listen() - // The port is already set in options after start() completes - const url = `http://${this.options.hostname}:${this.options.port}` - log.info(`āœ“ Backend started on port ${this.options.port}`) + // Update options with the actual port used (may differ if preferred port was busy) + this.options.port = port + const url = `http://${this.options.hostname}:${port}` + log.info(`āœ“ Backend started on port ${port}`) log.info(``) log.info(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`) log.info(` 🌐 Opening DevTools UI in browser...`) @@ -68,13 +74,26 @@ class NightwatchDevToolsPlugin { // Open DevTools UI in a separate browser window using WDIO's method try { + // Create unique user data directory for this instance to prevent conflicts + this.#userDataDir = path.join(os.tmpdir(), `nightwatch-devtools-${port}-${Date.now()}`) + + // Create the directory if it doesn't exist + if (!fs.existsSync(this.#userDataDir)) { + fs.mkdirSync(this.#userDataDir, { recursive: true }) + } + this.#devtoolsBrowser = await remote({ - logLevel: 'silent', + logLevel: 'error', // Show errors if browser fails to start automationProtocol: 'devtools', capabilities: { browserName: 'chrome', 'goog:chromeOptions': { - args: ['--window-size=1600,1200'] + args: [ + '--window-size=1600,1200', + `--user-data-dir=${this.#userDataDir}`, + '--no-first-run', + '--no-default-browser-check' + ] } } }) @@ -83,12 +102,13 @@ class NightwatchDevToolsPlugin { log.info('āœ“ DevTools UI opened in separate browser window') } catch (err) { log.error(`Failed to open DevTools UI: ${(err as Error).message}`) + log.error(`Error stack: ${(err as Error).stack}`) log.info(`Please manually open: ${url}`) } // Wait for UI to connect - log.info('Waiting 10 seconds for UI to connect...') - await new Promise(resolve => setTimeout(resolve, 10000)) + log.info(`Waiting ${TIMING.UI_CONNECTION_WAIT / 1000} seconds for UI to connect...`) + await new Promise(resolve => setTimeout(resolve, TIMING.UI_CONNECTION_WAIT)) log.info('Starting tests...') @@ -104,28 +124,28 @@ class NightwatchDevToolsPlugin { * Initialize session and inject script before each test */ async beforeEach(browser: NightwatchBrowser) { - // Reset command stack and signature at the start of each test (like WDIO) - this.#commandStack = [] - this.#lastCommandSig = null - - // Check if browser session changed (new session = new test file) + const currentTest = (browser as any).currentTest + const testFile = (currentTest?.module || '').split('/').pop() || 'unknown' const currentSessionId = (browser as any).sessionId + + // Check if browser session changed (happens with parallel workers) if (currentSessionId && this.#lastSessionId && currentSessionId !== this.#lastSessionId) { - this.#browserProxied = false + log.info(`Browser session changed - reinitializing for new worker`) + this.isScriptInjected = false + // Reset session capturer for new browser session + this.sessionCapturer = null as any } this.#lastSessionId = currentSessionId - // Initialize on first test + // Initialize on first test OR when browser session changes if (!this.sessionCapturer) { - // Wait a bit more for WebSocket to be ready on first test - await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise(resolve => setTimeout(resolve, TIMING.INITIAL_CONNECTION_WAIT)) this.sessionCapturer = new SessionCapturer({ port: this.options.port, hostname: this.options.hostname }, browser) - // Wait for WebSocket to connect before proceeding const connected = await this.sessionCapturer.waitForConnection(3000) if (!connected) { log.error('āŒ Worker WebSocket failed to connect!') @@ -133,13 +153,21 @@ class NightwatchDevToolsPlugin { log.info('āœ“ Worker WebSocket connected') } - // TestReporter callback sends suites data upstream in WDIO format + // Initialize managers this.testReporter = new TestReporter((suitesData: any) => { if (this.sessionCapturer) { - // suitesData is already in WDIO format: [{ uid: {...suite} }] this.sessionCapturer.sendUpstream('suites', suitesData) } }) + + this.testManager = new TestManager(this.testReporter) + this.suiteManager = new SuiteManager(this.testReporter) + this.browserProxy = new BrowserProxy( + this.sessionCapturer, + this.testManager, + () => this.#currentTest + ) + log.info('āœ“ Session initialized') // Send initial metadata @@ -152,21 +180,20 @@ class NightwatchDevToolsPlugin { }) } - // Get current test info - use WDIO DevTools approach with stack trace - const currentTest = (browser as any).currentTest + // Get current test info and find test file if (currentTest) { - const testFile = (currentTest.module || '').split('/').pop() || currentTest.module || 'unknown' - - // Reset #currentTestFullPath if we're starting a new test file - if (this.#currentTestFile !== testFile) { - this.#currentTestFullPath = null - } + const testFile = (currentTest.module || '').split('/').pop() || currentTest.module || DEFAULTS.FILE_NAME // Find test file: try stack trace first, then search workspace - let fullPath = findTestFileFromStack() || this.#currentTestFullPath + let fullPath: string | null = findTestFileFromStack() || null + const cachedPath = this.browserProxy.getCurrentTestFullPath() + + // Only use cached path if it matches the current testFile + if (!fullPath && cachedPath && cachedPath.includes(testFile)) { + fullPath = cachedPath + } if (!fullPath && testFile) { - // Try searching common test directories const workspaceRoot = process.cwd() const possiblePaths = [ path.join(workspaceRoot, 'example/tests', testFile + '.js'), @@ -193,283 +220,89 @@ class NightwatchDevToolsPlugin { suiteTitle = metadata.suiteTitle } testNames = metadata.testNames - - // Log extracted metadata for debugging - console.log(`[METADATA] Suite: "${suiteTitle}", Tests: [${testNames.map(t => `"${t}"`).join(', ')}]`) } - // Create/update suite for this test file - if (!this.#currentSuiteByFile.has(testFile)) { - const suiteStats = { - uid: '', // Will be set by generateStableUid - cid: '0-0', - title: suiteTitle, - fullTitle: suiteTitle, - file: fullPath || testFile, - type: 'suite' as const, - start: new Date(), - end: null, // Will be set when all tests complete - tests: [] as any[], - suites: [], - hooks: [], - _duration: 0 - } - - this.testReporter.onSuiteStart(suiteStats) - this.#currentSuiteByFile.set(testFile, suiteStats) - - // Capture source file for display in Source tab - if (fullPath && fullPath.includes('/')) { - this.sessionCapturer.captureSource(fullPath).catch(() => { - // Silently ignore source capture errors - }) - } + // Get or create suite for this test file + const currentSuite = this.suiteManager.getOrCreateSuite(testFile, suiteTitle, fullPath, testNames) - // Add all tests with pending state (like WDIO does) - if (testNames.length > 0) { - for (const testName of testNames) { - const testUid = `${suiteStats.uid}::${testName}` - const testEntry: any = { - uid: testUid, - cid: '0-0', - title: testName, - fullTitle: `${suiteTitle} ${testName}`, - parent: suiteStats.uid, - state: 'pending' as const, - start: new Date(), - end: null, - type: 'test' as const, - file: fullPath || testFile, - retries: 0, - _duration: 0, - hooks: [] - } - suiteStats.tests.push(testEntry) - } - this.testReporter.updateSuites() - } + // Capture source file for display + if (fullPath && fullPath.includes('/')) { + this.sessionCapturer.captureSource(fullPath).catch(() => {}) } - // Find which test is about to run (first unprocessed test) - const currentSuite = this.#currentSuiteByFile.get(testFile)! - if (!this.#processedTests.has(testFile)) { - this.#processedTests.set(testFile, new Set()) - } - const processedForSuite = this.#processedTests.get(testFile)! + // Handle running test from previous beforeEach + const runningTest = currentSuite.tests.find( + (t: any) => typeof t !== 'string' && t.state === TEST_STATE.RUNNING + ) as TestStats | undefined - // Find first test that hasn't been processed yet and mark it as running - let currentTestName = testNames.find(name => !processedForSuite.has(name)) + if (runningTest) { + const currentTest = (browser as any).currentTest + const testcases = currentTest?.results?.testcases || {} - if (currentTestName) { - const testIndex = currentSuite.tests.findIndex( - (t: any) => typeof t !== 'string' && t.title === currentTestName - ) - if (testIndex !== -1) { - currentSuite.tests[testIndex].state = 'running' - currentSuite.tests[testIndex].start = new Date() - currentSuite.tests[testIndex].end = null - // Send onTestStart for the first test - this.testReporter.onTestStart(currentSuite.tests[testIndex]) - console.log(`[STATE] Test "${currentTestName}" → RUNNING`) - // Small delay to let UI render the spinner before test starts executing - await new Promise(resolve => setTimeout(resolve, 100)) - } - } - - // Create temporary test for command tracking - const uniqueTempUid = `${currentSuite.uid}::temp-${Date.now()}-${Math.random().toString(36).substring(7)}` - - this.#currentTest = { - uid: uniqueTempUid, - cid: '0-0', - title: currentTestName || 'test', - fullTitle: currentTestName || 'test', - parent: currentSuite.uid, - state: 'running' as const, - start: new Date(), - end: new Date(), - type: 'test' as const, - file: fullPath || testFile, - retries: 0, - _duration: 0, - hooks: [] - } + if (testcases[runningTest.title]) { + const testcase = testcases[runningTest.title] + const testState = determineTestState(testcase) + runningTest.state = testState + runningTest.end = new Date() + runningTest._duration = parseFloat(testcase.time || '0') * 1000 - // Store reference to current suite for command tracking - this.#currentTestFile = testFile + this.testManager.updateTestState(runningTest, testState) + this.testManager.markTestAsProcessed(testFile, runningTest.title) - const originalUrl = browser.url.bind(browser) - const sessionCapturer = this.sessionCapturer + await new Promise(resolve => setTimeout(resolve, TIMING.UI_RENDER_DELAY)) + } else { + const endTime = new Date() + const duration = endTime.getTime() - (runningTest.start?.getTime() || 0) - browser.url = function(url: string) { - const result = originalUrl(url) as any + this.testManager.updateTestState(runningTest, TEST_STATE.PASSED as TestStats['state'], endTime, duration) + this.testManager.markTestAsProcessed(testFile, runningTest.title) - if (result && typeof result.perform === 'function') { - result.perform(async function(this: any) { - try { - log.info(`Injecting script after navigation to: ${url}`) - await sessionCapturer.injectScript(this) - } catch (err) { - log.error(`Failed to inject script: ${(err as Error).message}`) - } - }) + await new Promise(resolve => setTimeout(resolve, TIMING.UI_RENDER_DELAY)) } + } - return result - } as any - - this.isScriptInjected = true - log.info('āœ“ Script injection wrapped') - } - - // Use Proxy to intercept ALL browser commands - if (browser && !this.#browserProxied) { - const self = this - const sessionCapturer = this.sessionCapturer - const browserAny = browser as any - - // Get ALL methods - both own properties and prototype - const allMethods = new Set([ - ...Object.keys(browser), - ...Object.getOwnPropertyNames(Object.getPrototypeOf(browser)) - ]) - const wrappedMethods: string[] = [] - - allMethods.forEach(methodName => { - if (methodName === 'constructor' || typeof browserAny[methodName] !== 'function') { - return - } + // Find and start next test + const processedTests = this.testManager.getProcessedTests(testFile) + const currentTestName = testNames.find(name => !processedTests.has(name)) - // Skip internal Nightwatch commands - if (INTERNAL_COMMANDS_TO_IGNORE.includes(methodName as any)) { - return + if (currentTestName) { + // Mark suite as running on first test + if (processedTests.size === 0) { + this.suiteManager.markSuiteAsRunning(currentSuite) } - // Skip methods starting with __ (internal methods) - if (methodName.startsWith('__')) { - return - } - - const originalMethod = browserAny[methodName].bind(browser) - - browserAny[methodName] = function(...args: any[]) { - // Get call stack using WDIO DevTools approach - const callInfo = getCallSourceFromStack() - const callSource = callInfo.callSource - - // Update #currentTestFullPath if we found a valid file - if (callInfo.filePath && !self.#currentTestFullPath) { - self.#currentTestFullPath = callInfo.filePath - } - - // BEFORE command execution (like WDIO beforeCommand): - // Check for duplicate using signature, push to stack if unique - const cmdSig = JSON.stringify({ command: methodName, args, src: callSource }) - const isDuplicate = self.#lastCommandSig === cmdSig - - if (!isDuplicate && self.#currentTest) { - // Push to command stack - self.#commandStack.push({ command: methodName, callSource, signature: cmdSig }) - self.#lastCommandSig = cmdSig - } - - try { - // Execute the command - const result = originalMethod(...args) - - // AFTER command execution (like WDIO afterCommand): - // Pop from stack and capture only if it matches - const stackFrame = self.#commandStack[self.#commandStack.length - 1] - if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) { - self.#commandStack.pop() - - // Serialize result safely - let serializedResult: any = undefined - const isBrowserObject = result === browser || result === browserAny - const isChainableAPI = result && typeof result === 'object' && ('queue' in result || 'sessionId' in result || 'capabilities' in result) - - if (isBrowserObject || isChainableAPI) { - const isWaitCommand = methodName.startsWith('waitFor') - serializedResult = isWaitCommand ? true : undefined - } else if (result && typeof result === 'object') { - if ('value' in result) { - serializedResult = result.value - } else { - try { - serializedResult = JSON.parse(JSON.stringify(result)) - } catch { - serializedResult = String(result) - } - } - } else if (result !== undefined) { - serializedResult = result - } - - // Capture command - if (self.#currentTest && sessionCapturer) { - // captureCommand pushes to log immediately (synchronous) - // then does async performance capture in background - sessionCapturer.captureCommand( - methodName, - args, - serializedResult, - undefined, - self.#currentTest.uid, - callSource - ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) - - // In Nightwatch's chainable API, ALL commands return the browser object - // and share the same promise chain. Individual promises don't resolve separately. - // Therefore, we send ALL commands immediately after capture. - const lastCommand = sessionCapturer.commandsLog[sessionCapturer.commandsLog.length - 1] as any - if (lastCommand) { - sessionCapturer.sendCommand(lastCommand) - } - } - } - - return result - } catch (error) { - // Pop from stack on error - const stackFrame = self.#commandStack[self.#commandStack.length - 1] - if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) { - self.#commandStack.pop() - - // Capture command with error - if (self.#currentTest && sessionCapturer) { - sessionCapturer.captureCommand( - methodName, - args, - undefined, - error instanceof Error ? error : new Error(String(error)), - self.#currentTest.uid, - callSource - ).catch((err: any) => log.error(`Failed to capture ${methodName}: ${err.message}`)) - - // Send error command immediately - const lastCommand = sessionCapturer.commandsLog[sessionCapturer.commandsLog.length - 1] as any - if (lastCommand) { - sessionCapturer.sendCommand(lastCommand) - } - } - - // DON'T reset signature - keep it for duplicate detection - } - - throw error - } + const test = this.testManager.findTestInSuite(currentSuite, currentTestName) + if (test) { + test.state = TEST_STATE.RUNNING as TestStats['state'] + test.start = new Date() + test.end = null + this.testReporter.onTestStart(test) + + // Use the real test UID for command tracking (no temporary UIDs!) + this.#currentTest = test + + await new Promise(resolve => setTimeout(resolve, TIMING.TEST_START_DELAY)) + } else { + // This should never happen if suite was properly initialized + log.warn(`Test "${currentTestName}" not found in suite "${currentSuite.title}" - skipping command tracking`) + this.#currentTest = null } + } - wrappedMethods.push(methodName) - }) + this.#currentTestFile = testFile - // Keep proxy active for all tests - if (!this.#browserProxied) { - this.#browserProxied = true - log.info(`āœ“ Wrapped ${wrappedMethods.length} browser methods`) - log.info(` Methods: ${wrappedMethods.slice(0, 20).join(', ')}${wrappedMethods.length > 20 ? '...' : ''}`) + // Wrap browser.url for script injection + if (!this.isScriptInjected) { + this.browserProxy.wrapUrlMethod(browser) + this.isScriptInjected = true } + + // Reset command tracking + this.browserProxy.resetCommandTracking() } + + // Wrap browser commands + this.browserProxy.wrapBrowserCommands(browser) } /** @@ -479,126 +312,55 @@ class NightwatchDevToolsPlugin { async afterEach(browser: NightwatchBrowser) { if (browser && this.sessionCapturer) { try { - // Update test stats with result - if (this.#currentTest) { - const currentTest = (browser as any).currentTest - const results = currentTest?.results || {} - const testFile = (currentTest.module || '').split('/').pop() || 'unknown' - - // Extract actual test name from results.testcases - // BUT: results.testcases contains ALL tests in the suite, not just the current one - // We need to find which test just finished - const testcases = results.testcases || {} - const testcaseNames = Object.keys(testcases) - - // Find the test that matches the current temp UID's commands - // or just process the last test if we can't determine - const currentSuite = this.#currentSuiteByFile.get(testFile) - if (currentSuite && testcaseNames.length > 0) { - // Get or create set of processed tests for this suite - if (!this.#processedTests.has(testFile)) { - this.#processedTests.set(testFile, new Set()) + const currentTest = (browser as any).currentTest + const results = currentTest?.results || {} + const testFile = (currentTest.module || '').split('/').pop() || DEFAULTS.FILE_NAME + const testcases = results.testcases || {} + const testcaseNames = Object.keys(testcases) + + const currentSuite = this.suiteManager.getSuite(testFile) + if (currentSuite) { + const processedTests = this.testManager.getProcessedTests(testFile) + + // Handle case with no results yet + if (testcaseNames.length === 0) { + const runningTest = currentSuite.tests.find( + (t: any) => typeof t !== 'string' && t.state === TEST_STATE.RUNNING + ) as TestStats | undefined + + if (runningTest && !processedTests.has(runningTest.title)) { + const testState: TestStats['state'] = (results.errors > 0 || results.failed > 0) ? + TEST_STATE.FAILED : TEST_STATE.PASSED + const endTime = new Date() + const duration = endTime.getTime() - (runningTest.start?.getTime() || 0) + + this.testManager.updateTestState(runningTest, testState, endTime, duration) + this.testManager.markTestAsProcessed(testFile, runningTest.title) } - const processedForSuite = this.#processedTests.get(testFile)! - - // Process ALL unprocessed tests - const unprocessedTests = testcaseNames.filter(name => !processedForSuite.has(name)) + } else { + // Process tests with results + const unprocessedTests = testcaseNames.filter(name => !processedTests.has(name)) - // Process completed tests one at a time with delays for (const currentTestName of unprocessedTests) { const testcase = testcases[currentTestName] - // Determine test state: skipped (0/0), passed (>0/0), or failed - const testState: 'passed' | 'failed' | 'skipped' = - (testcase.passed === 0 && testcase.failed === 0) - ? 'skipped' - : (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' - const finalTestUid = `${currentSuite.uid}::${currentTestName}` - - // Map all commands from temp UID to final UID - const tempUid = this.#currentTest.uid - if (tempUid && tempUid !== finalTestUid) { - const commandsToUpdate: any[] = [] - this.sessionCapturer.commandsLog.forEach(cmd => { - if (cmd.testUid === tempUid) { - cmd.testUid = finalTestUid - commandsToUpdate.push(cmd) - } - }) - } + const testState = determineTestState(testcase) - // Find existing test and update it - const testIndex = currentSuite.tests.findIndex( - (t: any) => typeof t !== 'string' && t.title === currentTestName - ) - - if (testIndex !== -1) { - // Update existing test with final state - currentSuite.tests[testIndex].state = testState - currentSuite.tests[testIndex].end = new Date() - currentSuite.tests[testIndex]._duration = parseFloat(testcase.time || '0') * 1000 - currentSuite.tests[testIndex].uid = finalTestUid - - console.log(`[STATE] Test "${currentTestName}" → ${testState.toUpperCase()}`) - - // Report final state - this.testReporter.onTestEnd(currentSuite.tests[testIndex]) - } else { - // Test not found, add it (shouldn't happen) - const testStats = { - uid: finalTestUid, - cid: '0-0', - title: currentTestName, - fullTitle: `${currentSuite.title} ${currentTestName}`, - parent: currentSuite.uid, - state: testState, - start: this.#currentTest.start, - end: new Date(), - type: 'test' as const, - file: currentTest.module || testFile, - retries: 0, - _duration: parseFloat(testcase.time || '0') * 1000, - hooks: [] - } - currentSuite.tests.push(testStats) + const test = this.testManager.findTestInSuite(currentSuite, currentTestName) + if (test) { + this.testManager.updateTestState(test, testState, new Date(), parseFloat(testcase.time || '0') * 1000) } - // Mark as processed - processedForSuite.add(currentTestName) + this.testManager.markTestAsProcessed(testFile, currentTestName) } - // After processing all tests, check if suite is complete - if (processedForSuite.size === testcaseNames.length) { - // All tests in this suite are complete - currentSuite.end = new Date() - currentSuite._duration = currentSuite.end.getTime() - (currentSuite.start?.getTime() || 0) - const allPassed = currentSuite.tests.every((t: any) => t.state === 'passed') - currentSuite.state = allPassed ? 'passed' : 'failed' - console.log(`[STATE] Suite "${currentSuite.title}" → ${currentSuite.state.toUpperCase()} (${currentSuite.tests.length} tests)`) - this.testReporter.onSuiteEnd(currentSuite) - - // Give UI time to process suite completion before next suite starts - await new Promise(resolve => setTimeout(resolve, 200)) - } else { - // There are more tests to run - mark next as running - const nextTestName = testcaseNames.find(name => !processedForSuite.has(name)) - if (nextTestName) { - const nextTestIndex = currentSuite.tests.findIndex( - (t: any) => typeof t !== 'string' && t.title === nextTestName - ) - if (nextTestIndex !== -1) { - currentSuite.tests[nextTestIndex].state = 'running' - currentSuite.tests[nextTestIndex].start = new Date() - currentSuite.tests[nextTestIndex].end = null - console.log(`[STATE] Test "${nextTestName}" → RUNNING`) - this.testReporter.onTestStart(currentSuite.tests[nextTestIndex]) - await new Promise(resolve => setTimeout(resolve, 100)) - } - } + // Check if suite is complete + if (processedTests.size === testcaseNames.length) { + this.suiteManager.finalizeSuite(currentSuite) + await new Promise(resolve => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY)) } } } - // Capture trace data from browser await this.sessionCapturer.captureTrace(browser) } catch (err) { log.error(`Failed to capture trace: ${(err as Error).message}`) @@ -613,144 +375,72 @@ class NightwatchDevToolsPlugin { async after(browser?: NightwatchBrowser) { try { // Process any remaining incomplete suites - for (const [testFile, suite] of this.#currentSuiteByFile.entries()) { - const processedTests = this.#processedTests.get(testFile) || new Set() - - // Mark any tests still in "running" state as passed (they completed successfully if we're here) - // For pending tests, check if they actually ran by looking at Nightwatch results - const currentTest = (browser as any)?.currentTest - const results = currentTest?.results || {} - const testcases = results.testcases || {} - const actualTestNames = Object.keys(testcases) - - suite.tests.forEach((test: any) => { - if (test.state === 'running' && test.start) { - // Test was started but never finished - assume passed - test.state = 'passed' - test.end = new Date() - test._duration = test.end.getTime() - (test.start?.getTime() || 0) - console.log(`[STATE] Test "${test.title}" → PASSED (finalized)`) - this.testReporter.onTestEnd(test) - } else if (test.state === 'pending') { - // Check if this test actually ran - const testcase = testcases[test.title] - if (testcase) { - // Test ran but we didn't track it properly - update it now - // Determine test state: skipped (0/0), passed (>0/0), or failed - const testState: 'passed' | 'failed' | 'skipped' = - (testcase.passed === 0 && testcase.failed === 0) - ? 'skipped' - : (testcase.passed > 0 && testcase.failed === 0) ? 'passed' : 'failed' - test.state = testState - test.start = test.start || new Date() - test.end = new Date() - test._duration = parseFloat(testcase.time || '0') * 1000 - console.log(`[STATE] Test "${test.title}" → ${testState.toUpperCase()} (from results)`) - this.testReporter.onTestEnd(test) - } else { - // Test was never actually run - skip it - test.state = 'skipped' - test.end = new Date() - test._duration = 0 - console.log(`[STATE] Test "${test.title}" → SKIPPED (never started)`) - this.testReporter.onTestEnd(test) - } - } - }) + const currentTest = (browser as any)?.currentTest + const testcases = currentTest?.results?.testcases || {} - // Give UI time to process test completions - await new Promise(resolve => setTimeout(resolve, 200)) - - // Now mark suite as complete - if (!suite.end) { - // Mark suite as complete - suite.end = new Date() - suite._duration = suite.end.getTime() - (suite.start?.getTime() || 0) - - // Determine suite state based on test states - const hasFailures = suite.tests.some((t: any) => t.state === 'failed') - const allPassed = suite.tests.every((t: any) => t.state === 'passed') - const hasSkipped = suite.tests.some((t: any) => t.state === 'skipped') - - if (hasFailures) { - suite.state = 'failed' - } else if (allPassed) { - suite.state = 'passed' - } else if (hasSkipped) { - // If there are skipped tests but no failures, mark as passed - // (this matches WebDriverIO behavior) - suite.state = 'passed' - } else { - suite.state = 'failed' - } - - console.log(`[STATE] Suite "${suite.title}" → ${suite.state.toUpperCase()} (${suite.tests.length} tests)`) - this.testReporter.onSuiteEnd(suite) - } + for (const [testFile, suite] of this.suiteManager.getAllSuites().entries()) { + this.testManager.finalizeSuiteTests(suite, testcases) + await new Promise(resolve => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY)) + this.suiteManager.finalizeSuite(suite) } - // Give UI time to process all final updates before showing completion message - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise(resolve => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY)) log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') log.info('āœ… Tests complete!') - log.info('šŸ’” Please close the DevTools browser window to exit') - log.info(' Or press Ctrl+C to force exit') + log.info('') + log.info(` DevTools UI: http://${this.options.hostname}:${this.options.port}`) + log.info('') + log.info('šŸ’” Please close the DevTools browser window to finish...') + log.info(' (or press Ctrl+C to exit and keep browser open)') log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') - // Keep polling until the WebSocket connection is closed - // This indicates the browser window was closed - await this.#waitForBrowserClose() - - // Close devtools browser if still open + // Keep the process alive by polling the devtools browser (WDIO pattern) + // When browser closes naturally, we clean up. + // When Ctrl+C happens, browser survives and we skip cleanup. if (this.#devtoolsBrowser) { - try { - await this.#devtoolsBrowser.deleteSession() - } catch { - // Already closed + logger.setLevel('devtools', 'warn') + let exitBySignal = false + + // Handle Ctrl+C: exit process but let browser survive + const signalHandler = () => { + exitBySignal = true + log.info('\nāœ“ Exiting... Browser window will remain open') + process.exit(0) } - } + process.once('SIGINT', signalHandler) + process.once('SIGTERM', signalHandler) - log.info('šŸ›‘ Stopping DevTools backend...') - await stop() - log.info('āœ“ Backend stopped') - } catch (err) { - log.error(`Failed to stop backend: ${(err as Error).message}`) - } - } + // Poll browser until it closes + while (true) { + try { + await this.#devtoolsBrowser.getTitle() + await new Promise((res) => setTimeout(res, TIMING.BROWSER_POLL_INTERVAL)) + } catch { + if (!exitBySignal) { + log.info('Browser window closed, stopping DevTools app') + break + } + } + } - /** - * Wait for browser window to close by polling browser status (WDIO method) - */ - async #waitForBrowserClose() { - if (!this.#devtoolsBrowser) { - return - } + // Only clean up if browser was closed (not Ctrl+C) + if (!exitBySignal) { + try { + await this.#devtoolsBrowser.deleteSession() + } catch (err: any) { + log.warn('Session already closed or could not be deleted:', err.message) + } - return new Promise((resolve) => { - // Poll browser every second to check if it's still open - const checkInterval = setInterval(async () => { - try { - await this.#devtoolsBrowser!.getTitle() - // Browser is still open, continue waiting - } catch { - // Browser closed - clearInterval(checkInterval) - log.info('āœ“ Browser window closed') - resolve() + // Stop the backend + log.info('šŸ›‘ Stopping DevTools backend...') + await stop() + log.info('āœ“ Backend stopped') } - }, 1000) - - // Also handle Ctrl+C gracefully - const sigintHandler = () => { - clearInterval(checkInterval) - log.info('\nāœ“ Received exit signal (Ctrl+C)') - resolve() } - - process.once('SIGINT', sigintHandler) - process.once('SIGTERM', sigintHandler) - }) + } catch (err) { + log.error(`Failed to stop backend: ${(err as Error).message}`) + } } } @@ -763,7 +453,7 @@ export default function createNightwatchDevTools(options?: DevToolsOptions) { return { // Set long timeout to allow user to review DevTools UI - // The after() hook will wait for browser window to close + // The after() hook waits for the browser window to be closed asyncHookTimeout: 3600000, // 1 hour before: async function(this: any) { diff --git a/packages/nightwatch-devtools/src/reporter.ts b/packages/nightwatch-devtools/src/reporter.ts index 46e3e14..c638288 100644 --- a/packages/nightwatch-devtools/src/reporter.ts +++ b/packages/nightwatch-devtools/src/reporter.ts @@ -1,5 +1,5 @@ import logger from '@wdio/logger' -import { extractTestMetadata } from './utils.js' +import { extractTestMetadata } from './helpers/utils.js' import type { SuiteStats, TestStats } from './types.js' const log = logger('@wdio/nightwatch-devtools:Reporter') @@ -8,16 +8,33 @@ const log = logger('@wdio/nightwatch-devtools:Reporter') const signatureCounters = new Map() /** - * Generate stable UID based on test/suite metadata + * Generate stable UID based on test/suite metadata (WDIO approach) + * Use only stable identifiers (file + fullTitle) that don't change between runs */ function generateStableUid(item: SuiteStats | TestStats): string { - const signature = `${item.file}::${item.fullTitle}` - const currentCount = signatureCounters.get(signature) || 0 - signatureCounters.set(signature, currentCount + 1) - - return currentCount > 0 - ? `${signature}::${currentCount}` - : signature + const rawItem = item as any + + // Use file and fullTitle as stable identifiers + // DO NOT use cid or parent as they can vary based on run context + const parts = [rawItem.file || '', String(rawItem.fullTitle || item.title)] + + const signature = parts.join('::') + const count = signatureCounters.get(signature) || 0 + signatureCounters.set(signature, count + 1) + + if (count > 0) { + parts.push(String(count)) + } + + // Generate hash for stable, short UIDs + const hash = parts + .join('::') + .split('') + .reduce((acc, char) => { + return ((acc << 5) - acc + char.charCodeAt(0)) | 0 + }, 0) + + return `stable-${Math.abs(hash).toString(36)}` } /** @@ -40,16 +57,27 @@ export class TestReporter { } /** - * Generate stable UID for test/suite - public method + * Generate stable UID for test/suite - public method (WDIO approach) */ generateStableUid(filePath: string, name: string): string { - const signature = `${filePath}::${name}` - const currentCount = signatureCounters.get(signature) || 0 - signatureCounters.set(signature, currentCount + 1) - - return currentCount > 0 - ? `${signature}::${currentCount}` - : signature + const parts = [filePath, name] + const signature = parts.join('::') + const count = signatureCounters.get(signature) || 0 + signatureCounters.set(signature, count + 1) + + if (count > 0) { + parts.push(String(count)) + } + + // Generate hash for stable, short UIDs + const hash = parts + .join('::') + .split('') + .reduce((acc, char) => { + return ((acc << 5) - acc + char.charCodeAt(0)) | 0 + }, 0) + + return `stable-${Math.abs(hash).toString(36)}` } /** @@ -59,8 +87,10 @@ export class TestReporter { this.#currentSpecFile = suiteStats.file this.#currentSuite = suiteStats - // Generate stable UID - suiteStats.uid = generateStableUid(suiteStats) + // Generate stable UID only if not already set + if (!suiteStats.uid) { + suiteStats.uid = generateStableUid(suiteStats) + } // Extract test names from source file if (suiteStats.file && !this.#testNamesCache.has(suiteStats.file)) { @@ -83,29 +113,44 @@ export class TestReporter { this.#sendUpstream() } + /** + * Get the current suite + */ + getCurrentSuite(): SuiteStats | undefined { + return this.#currentSuite + } + /** * Called when a test starts */ onTestStart(testStats: TestStats) { - // Generate stable UID if not already set + // Generate stable UID (hashed, so consistent even if called multiple times) if (!testStats.uid || testStats.uid.includes('temp-')) { testStats.uid = generateStableUid(testStats) } - // Update existing test in current suite (don't add duplicates) - if (this.#currentSuite) { - const testIndex = this.#currentSuite.tests.findIndex( - (t) => typeof t !== 'string' && t.title === testStats.title + // Search for test by title within parent suite + for (const suite of this.#allSuites) { + const testIndex = suite.tests.findIndex( + (t) => { + if (typeof t === 'string') return false + // Match by title and parent suite + return t.title === testStats.title && t.parent === suite.uid + } ) if (testIndex !== -1) { // Update existing test - this.#currentSuite.tests[testIndex] = testStats - } else { - // Test not found, add it (legacy behavior) - this.#currentSuite.tests.push(testStats) + suite.tests[testIndex] = testStats + this.#sendUpstream() + return } } + // Test not found in any suite, add it to current suite (legacy behavior) + if (this.#currentSuite) { + this.#currentSuite.tests.push(testStats) + } + this.#sendUpstream() } @@ -113,13 +158,14 @@ export class TestReporter { * Called when a test ends */ onTestEnd(testStats: TestStats) { - // Update the test in current suite - if (this.#currentSuite) { - const testIndex = this.#currentSuite.tests.findIndex( + // Search all suites for this test (not just current suite) + for (const suite of this.#allSuites) { + const testIndex = suite.tests.findIndex( (t) => (typeof t === 'string' ? t : t.uid) === testStats.uid ) if (testIndex !== -1) { - this.#currentSuite.tests[testIndex] = testStats + suite.tests[testIndex] = testStats + break } } @@ -130,13 +176,14 @@ export class TestReporter { * Called when a test passes */ onTestPass(testStats: TestStats) { - // Update the test in current suite - if (this.#currentSuite) { - const testIndex = this.#currentSuite.tests.findIndex( + // Search all suites for this test (not just current suite) + for (const suite of this.#allSuites) { + const testIndex = suite.tests.findIndex( (t) => (typeof t === 'string' ? t : t.uid) === testStats.uid ) if (testIndex !== -1) { - this.#currentSuite.tests[testIndex] = testStats + suite.tests[testIndex] = testStats + break } } @@ -147,13 +194,14 @@ export class TestReporter { * Called when a test fails */ onTestFail(testStats: TestStats) { - // Update the test in current suite - if (this.#currentSuite) { - const testIndex = this.#currentSuite.tests.findIndex( + // Search all suites for this test (not just current suite) + for (const suite of this.#allSuites) { + const testIndex = suite.tests.findIndex( (t) => (typeof t === 'string' ? t : t.uid) === testStats.uid ) if (testIndex !== -1) { - this.#currentSuite.tests[testIndex] = testStats + suite.tests[testIndex] = testStats + break } } @@ -222,7 +270,7 @@ export class TestReporter { #sendUpstream() { // Convert suites to WDIO format: array of objects with UID as key const payload: Record[] = [] - + for (const suite of this.#allSuites) { if (suite && suite.uid) { // Each suite becomes an object with its UID as the key diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts index ca1ea98..5bc586f 100644 --- a/packages/nightwatch-devtools/src/session.ts +++ b/packages/nightwatch-devtools/src/session.ts @@ -5,7 +5,7 @@ import logger from '@wdio/logger' import { WebSocket } from 'ws' import { CONSOLE_METHODS, LOG_SOURCES, ANSI_REGEX, LOG_LEVEL_PATTERNS } from './constants.js' import type { CommandLog, ConsoleLog, LogLevel, NightwatchBrowser } from './types.js' -import { getCapturePerformanceScript } from './capturePerformance.js' +import { getCapturePerformanceScript } from './helpers/capturePerformance.js' const require = createRequire(import.meta.url) const log = logger('@wdio/nightwatch-devtools:SessionCapturer') @@ -350,7 +350,10 @@ export class SessionCapturer { duration: r.duration })), // @ts-ignore - cookies: document.cookie, + cookies: (function() { + // @ts-ignore - executed in browser context + try { return document.cookie; } catch (e) { return ''; } + })(), documentInfo: { // @ts-ignore title: document.title, diff --git a/packages/nightwatch-devtools/src/types.ts b/packages/nightwatch-devtools/src/types.ts index 15a96b0..a6c01f5 100644 --- a/packages/nightwatch-devtools/src/types.ts +++ b/packages/nightwatch-devtools/src/types.ts @@ -68,9 +68,9 @@ export interface TestStats { title: string fullTitle: string parent: string - state: 'passed' | 'failed' | 'skipped' | 'pending' + state: 'passed' | 'failed' | 'skipped' | 'pending' | 'running' start: Date - end: Date + end: Date | null type: 'test' file: string retries: number @@ -79,6 +79,28 @@ export interface TestStats { hooks?: any[] } +/** + * Nightwatch test case result from results.testcases + */ +export interface NightwatchTestCase { + passed: number + failed: number + errors: number + skipped: number + time: string + assertions: any[] +} + +/** + * Determine test state from Nightwatch testcase results + */ +export function determineTestState(testcase: NightwatchTestCase): 'passed' | 'failed' | 'skipped' { + if (testcase.passed === 0 && testcase.failed === 0) { + return 'skipped' + } + return testcase.passed > 0 && testcase.failed === 0 ? 'passed' : 'failed' +} + export interface SuiteStats { uid: string cid: string @@ -87,6 +109,8 @@ export interface SuiteStats { type: 'suite' file: string start: Date + state?: 'pending' | 'running' | 'passed' | 'failed' | 'skipped' + end?: Date | null tests: (string | TestStats)[] suites: SuiteStats[] hooks: any[] diff --git a/packages/service/src/launcher.ts b/packages/service/src/launcher.ts index fd9494c..0769a1d 100644 --- a/packages/service/src/launcher.ts +++ b/packages/service/src/launcher.ts @@ -28,13 +28,10 @@ export class DevToolsAppLauncher { }) return } - const { server } = await start({ + const { port } = await start({ port: this.#options.port, hostname: this.#options.hostname }) - const address = server.address() - const port = - address && typeof address === 'object' ? address.port : undefined if (!port) { return console.log(`Failed to start server on port ${port}`) From ab349a77372554004efb82f76a15d54c171514b5 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 25 Feb 2026 14:41:49 +0530 Subject: [PATCH 4/4] Eslint fix --- example/wdio.conf.ts | 2 +- .../app/src/components/sidebar/constants.ts | 8 +++++++ .../app/src/components/sidebar/explorer.ts | 24 +++++++++---------- packages/app/src/controller/DataManager.ts | 11 +++++++-- packages/backend/src/index.ts | 11 ++++++--- .../example/tests/login.test.js | 12 +++++----- .../example/tests/sample.test.js | 16 ++++++------- packages/nightwatch-devtools/src/constants.ts | 2 +- 8 files changed, 51 insertions(+), 35 deletions(-) diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts index 1fd07d9..1d5bc16 100644 --- a/example/wdio.conf.ts +++ b/example/wdio.conf.ts @@ -63,7 +63,7 @@ export const config: Options.Testrunner = { capabilities: [ { browserName: 'chrome', - browserVersion: '144.0.7559.133', // specify chromium browser version for testing + browserVersion: '145.0.7632.110', // specify chromium browser version for testing 'goog:chromeOptions': { args: [ '--headless', diff --git a/packages/app/src/components/sidebar/constants.ts b/packages/app/src/components/sidebar/constants.ts index 85ff538..d5f9290 100644 --- a/packages/app/src/components/sidebar/constants.ts +++ b/packages/app/src/components/sidebar/constants.ts @@ -1,3 +1,11 @@ +import { TestState } from './types.js' + +export const STATE_MAP: Record = { + running: TestState.RUNNING, + failed: TestState.FAILED, + passed: TestState.PASSED, + skipped: TestState.SKIPPED +} import type { RunCapabilities } from './types.js' export const DEFAULT_CAPABILITIES: RunCapabilities = { diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 8a824e6..c7520fc 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -17,7 +17,7 @@ import type { TestRunDetail } from './types.js' import { TestState } from './types.js' -import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES } from './constants.js' +import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES, STATE_MAP } from './constants.js' import '~icons/mdi/play.js' import '~icons/mdi/stop.js' @@ -31,13 +31,6 @@ import type { DevtoolsSidebarFilter } from './filter.js' const EXPLORER = 'wdio-devtools-sidebar-explorer' -const STATE_MAP: Record = { - 'running': TestState.RUNNING, - 'failed': TestState.FAILED, - 'passed': TestState.PASSED, - 'skipped': TestState.SKIPPED -} - @customElement(EXPLORER) export class DevtoolsSidebarExplorer extends CollapseableEntry { #testFilter: DevtoolsSidebarFilter | undefined @@ -376,12 +369,18 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { // Check explicit state first const mappedState = STATE_MAP[state] - if (mappedState) return mappedState + if (mappedState) { + return mappedState + } // For suites, compute state from children if ('tests' in entry) { - if (this.#isRunning(entry)) return TestState.RUNNING - if (this.#hasFailed(entry)) return TestState.FAILED + if (this.#isRunning(entry)) { + return TestState.RUNNING + } + if (this.#hasFailed(entry)) { + return TestState.FAILED + } return TestState.PASSED } @@ -480,8 +479,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {

No tests to display

Debug: suites=${this.suites?.length || 0}, - rootSuites=${uniqueSuites.length}, - filtered=${suites.length} + rootSuites=${uniqueSuites.length}, filtered=${suites.length}

`} diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts index ee234f2..d36062d 100644 --- a/packages/app/src/controller/DataManager.ts +++ b/packages/app/src/controller/DataManager.ts @@ -49,10 +49,17 @@ export const isTestRunningContext = createContext( ) interface SocketMessage< - T extends keyof TraceLog | 'testStopped' | 'clearExecutionData' = keyof TraceLog | 'testStopped' | 'clearExecutionData' + T extends keyof TraceLog | 'testStopped' | 'clearExecutionData' = + | keyof TraceLog + | 'testStopped' + | 'clearExecutionData' > { scope: T - data: T extends keyof TraceLog ? TraceLog[T] : T extends 'clearExecutionData' ? { uid?: string } : unknown + data: T extends keyof TraceLog + ? TraceLog[T] + : T extends 'clearExecutionData' + ? { uid?: string } + : unknown } export class DataManagerController implements ReactiveController { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 88f87d4..6e162b5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -30,7 +30,9 @@ export function broadcastToClients(message: string) { }) } -export async function start(opts: DevtoolsBackendOptions = {}): Promise<{ server: FastifyInstance; port: number }> { +export async function start( + opts: DevtoolsBackendOptions = {} +): Promise<{ server: FastifyInstance; port: number }> { const host = opts.hostname || 'localhost' // Use getPort to find an available port, starting with the preferred port const preferredPort = opts.port || DEFAULT_PORT @@ -122,7 +124,7 @@ export async function start(opts: DevtoolsBackendOptions = {}): Promise<{ server }) return } - } catch (e) { + } catch { // Not JSON or parsing failed, forward as-is } @@ -150,7 +152,10 @@ export async function stop() { // Close all WebSocket connections first clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + if ( + client.readyState === WebSocket.OPEN || + client.readyState === WebSocket.CONNECTING + ) { client.terminate() } }) diff --git a/packages/nightwatch-devtools/example/tests/login.test.js b/packages/nightwatch-devtools/example/tests/login.test.js index e2efcf0..ca2ae78 100644 --- a/packages/nightwatch-devtools/example/tests/login.test.js +++ b/packages/nightwatch-devtools/example/tests/login.test.js @@ -1,6 +1,5 @@ -describe('The Internet Guinea Pig Website', function() { - - it('should log into the secure area with valid credentials', async function(browser) { +describe('The Internet Guinea Pig Website', function () { + it('should log into the secure area with valid credentials', async function (browser) { console.log('[TEST] Navigating to login page') browser .url('https://the-internet.herokuapp.com/login') @@ -12,7 +11,9 @@ describe('The Internet Guinea Pig Website', function() { .setValue('#password', 'SuperSecretPassword!') .click('button[type="submit"]') - console.log('[TEST] Verifying flash message: You logged into a secure area!') + console.log( + '[TEST] Verifying flash message: You logged into a secure area!' + ) await browser .waitForElementVisible('#flash') .assert.textContains('#flash', 'You logged into a secure area!') @@ -20,7 +21,7 @@ describe('The Internet Guinea Pig Website', function() { console.log('[TEST] Flash message verified successfully') }) - it('should show error with invalid credentials', async function(browser) { + it('should show error with invalid credentials', async function (browser) { console.log('[TEST] Navigating to login page') await browser .url('https://the-internet.herokuapp.com/login') @@ -39,5 +40,4 @@ describe('The Internet Guinea Pig Website', function() { console.log('[TEST] Flash message verified successfully') }) - }) diff --git a/packages/nightwatch-devtools/example/tests/sample.test.js b/packages/nightwatch-devtools/example/tests/sample.test.js index fe135e3..ff2e4b2 100644 --- a/packages/nightwatch-devtools/example/tests/sample.test.js +++ b/packages/nightwatch-devtools/example/tests/sample.test.js @@ -1,23 +1,21 @@ -describe('Sample Nightwatch Test with DevTools', function() { - - it('should navigate to example.com and check title', async function(browser) { +describe('Sample Nightwatch Test with DevTools', function () { + it('should navigate to example.com and check title', async function (browser) { await browser .url('https://example.com') .waitForElementVisible('body', 5000) .assert.titleContains('Example') .assert.visible('h1') - + const result = await browser.getText('h1') browser.assert.ok(result.includes('Example'), 'H1 contains "Example"') - }); + }) - it('should perform basic interactions', async function(browser) { + it('should perform basic interactions', async function (browser) { await browser .url('https://www.google.com') .waitForElementVisible('body', 5000) .assert.visible('textarea[name="q"]') .setValue('textarea[name="q"]', 'WebdriverIO DevTools') .pause(1000) - }); - -}); + }) +}) diff --git a/packages/nightwatch-devtools/src/constants.ts b/packages/nightwatch-devtools/src/constants.ts index a3d8c0f..8458654 100644 --- a/packages/nightwatch-devtools/src/constants.ts +++ b/packages/nightwatch-devtools/src/constants.ts @@ -109,7 +109,7 @@ export const TEST_STATE = { SKIPPED: 'skipped' } as const -export type TestState = typeof TEST_STATE[keyof typeof TEST_STATE] +export type TestState = (typeof TEST_STATE)[keyof typeof TEST_STATE] /** * Temporary UID generation pattern