java-topology/docs/tickets/webdriverio-0002-mspo-aggregator-selector-dedup-find.md
russell@unturf.com b79fddfb51 browser-automation wave 2: testcafe-0001 + webdriverio-0002
testcafe-0001: Selector filterNodes (string-filter branch) and
  expandSelectorResults both dedup via Array.indexOf on growing result
  arrays. filterNodes: O(N*M) per selector filter. expandSelectorResults:
  O(N^2 * K^2) worst case when derivatives unique. Fix: Set<Node> keyed
  by object identity. Bench: 398x at N=2000 filter, 1966x at N=K=150
  expand.

webdriverio-0002: MSPO aggregator dedups per-test entries via Array.find
  on growing bucket array. O(N^2) per test bucket, same pattern repeats
  in unknown-suite merger. Fix: companion Map<bucketKey, Set<selector>>
  for O(1) dedup. Bench: 493x at N=2000.

UNDF IDs: 1290 (testcafe), 1291 (webdriverio-0002). All 17 tests pass.
2026-04-22 18:31:53 -04:00

3 KiB
Raw Blame History

webdriverio-0002: MSPO aggregator — O(N²) selector dedup via Array.find

Target: webdriverio/webdriverio Severity: MEDIUM CWE: CWE-407 (Inefficient Algorithmic Complexity) MOAD: MOAD-0001 (A Sedimentary Defect) File: packages/wdio-appium-service/src/mobileSelectorPerformanceOptimizer/aggregator.ts:343, 369 Language: TypeScript Status: open

Description

The Mobile Selector Performance Optimizer (MSPO) aggregates selector performance data across tests. Its aggregation pipeline dedups per-test selector entries via Array.find(d => d.selector === data.selector) inside the per-entry accumulation loop. For N collected entries per test, dedup is O(N²). Large mobile test suites that exercise many unique selectors per test hit this scaling.

A second instance of the same pattern lives in the unknown-suite merger loop at line 369, running during post-test attribution when MSPO stitches orphan test entries back into their parent suites.

Root Cause

// aggregator.ts:341-347  per-entry accumulation
for (const data of collectedData) {
    if (!grouped[specFile][suiteName][testName]) {
        grouped[specFile][suiteName][testName] = [];
    }
    const existing = grouped[specFile][suiteName][testName]
        .find(d => d.selector === data.selector);     // O(N) scan per entry

    if (!existing) {
        grouped[specFile][suiteName][testName].push(data);
    }
}
// Total: O(N^2) per test

// aggregator.ts:367-374  unknown-suite merger
for (const data of unknownSuite[testName]) {
    const existing = suites[knownSuiteName][testName]
        .find(d => d.selector === data.selector);     // O(N) scan per entry
    if (!existing) {
        data.suiteName = knownSuiteName;
        suites[knownSuiteName][testName].push(data);
    }
}

Fix

Maintain a parallel Map<string, SelectorPerformanceData> keyed by selector alongside the array. Lookup and insert drop to amortized O(1). Array is kept for output order and downstream consumers that iterate.

// Replace the bare array with a { data: [], bySelector: Map<string, Data> } tuple
// or carry a companion Map<testName, Set<selector>> at the aggregator level.
const seenSelectors = new Set<string>();  // per test bucket
for (const data of collectedData) {
    if (!seenSelectors.has(data.selector)) {
        seenSelectors.add(data.selector);
        grouped[specFile][suiteName][testName].push(data);
    }
}

For the unknown-suite merger, pre-build a Set<string> of existing selector keys in the destination bucket, then iterate source entries with O(1) lookup.

Severity Note

MSPO is opt-in tooling activated via the mobileSelectorPerformanceOptimizer service. Impact scales with test count × unique selectors per test. A 1000- test suite with 50 unique selectors per test aggregates 50,000 entries; current path is O(N²) = 2.5 billion comparisons. The fix restores linear behavior.

Complexity Gate

  • N=1000 entries per test bucket: fixed must complete in <5ms
  • k-scaling 5×: time ratio must be <17.5× (O(k) ≈5×, not O(k²) ≈25×)