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.
This commit is contained in:
russell@unturf.com 2026-04-22 18:31:53 -04:00
parent 9a0253e724
commit b79fddfb51
16 changed files with 827 additions and 17 deletions

View file

@ -0,0 +1,93 @@
# testcafe-0001: selector filterNodes + expandSelectorResults — O(N×M) indexOf per node
**Target:** DevExpress/testcafe
**Severity:** MEDIUM-HIGH
**CWE:** CWE-407 (Inefficient Algorithmic Complexity)
**MOAD:** MOAD-0001 (A Sedimentary Defect)
**File:** `src/client-functions/selectors/add-api.js:30-48, 51-72`
**Language:** JavaScript
**Status:** open
## Description
TestCafe's `Selector` filter and derivative expansion run in the browser as
client functions. `filterNodes` dedups query results against a CSS match list
via `matchingArr.indexOf(node) > -1` inside a per-node loop, giving O(N×M)
where N is input nodes and M is CSS matches. `expandSelectorResults` walks
N origin nodes, expands each into K derivatives, and dedups each derivative
against the growing `result` array via `result.indexOf(...) < 0` — O(N×K×R)
where R grows up to N×K.
Every `Selector('.card').filter(x)` in every test hits this path. Pages with
large DOM trees and chained selectors amplify the cost.
## Root Cause
```javascript
// add-api.js:18-49 filterNodes, string-filter branch
const matching = querySelectorRoot.querySelectorAll(filter);
const matchingArr = [];
for (let i = 0; i < matching.length; i++)
matchingArr.push(matching[i]);
filter = node => matchingArr.indexOf(node) > -1; // O(M) per filter call
for (let j = 0; j < nodes.length; j++) {
if (filter(nodes[j], j, originNode, ...filterArgs)) // inner O(M)
result.push(nodes[j]);
}
// Total: O(N * M)
// add-api.js:51-72 expandSelectorResults
for (let i = 0; i < nodes.length; i++) { // O(N)
const derivativeNodes = populateDerivativeNodes(nodes[i]);
if (derivativeNodes) {
for (let j = 0; j < derivativeNodes.length; j++) { // O(K)
if (result.indexOf(derivativeNodes[j]) < 0) // O(|result|)
result.push(derivativeNodes[j]);
}
}
}
// Worst case: O(N * K * (N*K)) = O(N^2 * K^2) when all derivatives unique
```
## Fix
Use a `Set` for O(1) membership. For DOM Node dedup, `Set<Node>` is keyed by
object identity, which matches the existing `indexOf` semantics exactly.
```javascript
// filterNodes: build a Set from matching NodeList once
const matchingSet = new Set();
for (let i = 0; i < matching.length; i++)
matchingSet.add(matching[i]);
filter = node => matchingSet.has(node); // O(1)
// expandSelectorResults: parallel seen-Set tracks which nodes have been pushed
const seen = new Set();
for (let i = 0; i < nodes.length; i++) {
const derivativeNodes = populateDerivativeNodes(nodes[i]);
if (derivativeNodes) {
for (let j = 0; j < derivativeNodes.length; j++) {
if (!seen.has(derivativeNodes[j])) {
seen.add(derivativeNodes[j]);
result.push(derivativeNodes[j]);
}
}
}
}
```
Cost drops to O(N+M) for filterNodes and O(N×K) for expandSelectorResults.
## Severity Note
Client-side code injected into the test page, called per-selector per-filter.
Long test suites and selector-heavy pages compound the cost. Impact most
visible on DOM tests against large data grids, dashboards, and virtualized
lists.
## Complexity Gate
- filterNodes N=M=1000: fixed must complete in <5ms
- expandSelectorResults N=K=100: fixed must complete in <5ms
- k-scaling 5×: time ratio must be <17.5× (O(k) ≈5×, not O(k²) ≈25×)

View file

@ -0,0 +1,84 @@
# 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
```typescript
// 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.
```typescript
// 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×)