Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,17 @@ Documented divergences from the conventions above. They exist today as debt to b
- **Nightwatch: `retain-on-failure` works; the other retry-aware policies degrade.** Its `--retries` re-runs the testcase *internally* without re-firing the plugin's per-test hooks, and the per-testcase results carry no attempt/retry field (retries live only in undocumented, version-varying Nightwatch internals — `suiteRetries.testRetriesCount` / `reporter.testResults.retryTest`), so the ledger sees only the final attempt for the `describe/it` and exports-object interfaces. Cucumber scenarios expose per-scenario hooks, so the feed captures their attempts. Not cleanly fixable without depending on those internals.
- WDIO `specFileRetries` spawns a fresh worker per retry, so cross-process attempts aren't in the (process-scoped) ledger.
- Run identity across worker sockets is env-propagated. `core/run-id.ts` `resolveRunId()` publishes `DEVTOOLS_RUN_ID` (`RUNNER_ENV.RUN_ID`) and every worker socket carries it as `?runId=` (`WORKER_WS_QUERY`), so the backend keeps accumulated run state when the *next spec's* worker connects and wipes it only for a genuinely new run. Without it every connect read as a new run: Preserve & Rerun 409'd for every spec except the last one that ran, and a dashboard opened mid-run replayed only the current spec. The WDIO service stamps it in the launcher's `onPrepare`, before workers fork, so all workers of one run agree; single-process adapters self-stamp on first use. **Gap: multi-process parallel runs in Selenium/Nightwatch** (jest/vitest workers, nightwatch `test_workers`) load the plugin per worker with no launcher-side hook to stamp first, so each worker generates its own id and still reads as a new run — the pre-fix behaviour, not a regression. Deriving the fallback from `process.ppid` would group those siblings, but would also make two sequential single-process runs share an id and inherit each other's state against a standalone dashboard, so the per-process fallback stands.
- **A rerun template selects EITHER by name pattern or by exact id, and the two cannot share a slot.** `shared/src/runner.ts` `RERUN_SLOT` names both, and `backend/src/runner.ts` `#resolveGenericCommand` branches on which one the adapter's template carries: `{{testName}}` is filled from `label`/`fullTitle` through `escapeFilterRegex` because mocha `--grep`, jest `--testNamePattern` and cucumber `--name` all match by regex; `{{testId}}` is filled from `uid`, shell-quoted and **never escaped**, because pytest selects by nodeid and matches it literally. Measured: `pytest 'test_thing\.py::test_a'` collects nothing and exits 0, so the escaped form fails as a rerun that appears to have run and passed. Which slot a payload can service also decides `isTargetedRerun` — an id template needs a `uid`, not a label.
- A pytest nodeid addresses a file, a class or one test in one syntax (`file.py`, `file.py::Class`, `file.py::Class::test`), and the Python adapter's uids already *are* nodeids at all three levels, so one slot covers every row the tree offers — no per-level filter flag and no cucumber-style feature special case. Verified end-to-end: substituting a test nodeid collects 1, a file nodeid collects 3.
- `selenium-devtools-py/src/selenium_devtools/rerun.py` derives both commands from pytest's own view of its invocation (`config.invocation_params.args` plus `config.args` for which of them were positional) rather than parsing argv itself: inferring positionals needs a table of every option that takes a value, and dropping a value while keeping its option makes that option swallow the appended id. Capabilities are **derived from which commands got built**, never declared — the backend's fallback for a rerun it was given no command for is the wdio binary, so an advertised-but-unserviceable control is worse than an absent one. A plain script publishes a launch command only and advertises Run-all alone.
- Selectors are stripped from a targeted rerun (`-k`, `-m`, `--deselect`, `--lf`/`--ff`/`--sw` family, `-n`/`--numprocesses`/`--dist`): the rerun already names its test, so a surviving filter can only narrow further — usually to nothing, which pytest reports as a clean exit. Positionals go too, or a rerun's own child would union the inherited nodeid with the next one and each generation would run one test more. The xdist flags also go because each worker would connect under its own run id.
- The rerun spawns in pytest's **rootdir** (`RUNNER_ENV.RUNNER_CWD`, stamped before the backend is launched so its process inherits it): a nodeid is reported relative to rootdir while a positional path resolves against the process's cwd, so anywhere else makes every nodeid a path that does not exist. The launch command's positionals are absolutised for the same reason. The variable is *replaced* on a second `enable()` in one process but only while it still holds **the value we wrote** (tracked, and deliberately surviving `reset()`): our leftover would otherwise spawn the next run's reruns in the previous project, while a value someone exported since is an instruction. A boolean "we wrote it once" cannot serve both — it says nothing about whether the current value is still ours. The remaining ambiguity is accepted and untouchable: a caller who exports the *same* path we already stamped is byte-identical to our leftover in the only channel there is, so that override is replaced; pinning a directory across runs works by exporting it before the first `enable()`, which is never claimed as ours. Residual: an option carrying a *relative* path (`-c`, `--junitxml`) resolves against rootdir on a rerun, and an already-running dashboard keeps the directory it was started in.
- **A rerun does not travel down the worker socket.** `POST /api/tests/run` spawns a fresh process; the socket carries only `clientConnected`/`clientDisconnected`. So the single-`workerSocket` limitation is about which process the *dashboard state* belongs to under `pytest -n`, not about routing the rerun.
- **A spawned rerun must be pointed back at the backend that asked for it, or it reports into a dashboard nobody is looking at.** `REUSE_ENV` (`DEVTOOLS_APP_REUSE`/`_HOST`/`_PORT`) is how the backend does that, and an adapter that ignores it launches a *second* backend and a *second* window: measured on the Python adapter, a rerun opened a new dashboard carrying the rerun's data while the window the user pressed Rerun in stayed as it was — which reads as a rerun that captured nothing. `backend.py` `reuse_target()` now attaches to it **ahead of `DEVTOOLS_PORT`** (that variable is an ambient preference inherited from the parent; the handshake names the backend that requested *this* run), and the window gate lives in `lifecycle.auto_open_enabled()` rather than at the `enable()` call site so it is directly testable. An incomplete handshake deliberately still opens a window — no usable target means the child launched its own backend, and then the window is the only way to see it.
- A plain script's tree is one synthetic suite holding one synthetic test, and both denote the whole run, so its launch command doubles as its rerun template (no slot — the backend substitutes nothing) and all three controls are honest. Refusing the row-scoped ones instead would disable the button beside the only row the tree has.
- **Two unrelated events share the `clearExecutionData` scope, and the receiver cannot tell them apart from the uid.** A run STARTING (`backend/src/index.ts` `handleTestRun`, one per `POST /api/tests/run`) and ONE ENTRY resetting inside a run already in flight (`nightwatch-devtools/src/cucumber-lifecycle.ts`, which re-emits a scenario suite and must not wipe its siblings) arrive under the same scope with the same shape. The app inferred the difference by comparing the uid against `rerunState.activeRerunSuiteUid` — a latch that outlived its rerun, so the *next* run start at a different scope read as a child clear of the last one and **skipped its wipe entirely**: rerun a suite, then the file or Tests, and the Actions/Console/Network tabs kept the previous run's rows and grew with each rerun. `ClearExecutionDataWsPayload.runStart` now states it on the wire (it has to be on the wire, not local to the clicking window — popouts see only WS events), and the app clears both latches when it is set. A backend test asserts the flag actually ships: the app-side fix reads it, so dropping it would restore the bug with every app test still green.
- Still open, same class: `app/src/components/browser/snapshot.ts` `#videos` is only ever pushed to, so the screencast "Recording N" dropdown accumulates every session of every run for the life of the page (observed at 17). That component listens only to the `screencast-ready` window event and never learns a run started.
- **A rerun's process collects a SUBSET, so anything it derives from "this collection" is wrong for the tree it merges into.** Two bugs of that one shape, both found by rerunning a single pytest test: (a) `SuiteStats.order` — which `test-entry-state.ts` `orderedChildren` sorts a suite's tests and child suites by — was pytest's `enumerate(session.items)` index, so a rerun restamped its one test as position 0 and the row jumped above the class it was written below. It is now the item's **source line**, a property of the test rather than of the collection; within a module pytest collects in definition order, so the two agree wherever both are meaningful (a plugin that reorders collection is the exception, and there the line is the more stable answer anyway). (b) `suite-merge.ts` `resetStaleChildrenOnRerun` flipped every settled child *suite* to `pending` whenever an incoming suite arrived `pending` — but a single-test rerun re-emits the parent as `pending` carrying only the one test it collected, so a sibling class suite was set spinning and never reported again, keeping the spinner for the rest of the session with all of its own tests still green. `mergeTests` already froze sibling *tests* on `activeRerunTestUid`; that guard now covers child suites too. A suite on the path to the target is unaffected either way — it re-reports its own state.
- **Chrome discards all WebDriver-synthesized input to a tab after a breached credential is submitted.** The first time a test types a `(username, password)` pair that Chrome's password-leak check finds in a breach corpus into an `<input type="password">` and submits a form whose destination no longer shows that login form, Chrome queries `passwordsleakcheck-pa.googleapis.com` and ~0.3-0.9 s later stops delivering **all** synthesized input — mouse *and* keyboard — to that tab. chromedriver returns HTTP 200 for every subsequent Element Click / Send Keys; nothing reaches the page. Untrusted JS (`element.click()`) still works and direct CDP `Input.dispatchMouseEvent`/`dispatchKeyEvent` are equally dead, so this is Chrome, not chromedriver and not our capture. `tomsmith` / `SuperSecretPassword!` — the-internet's demo credential — triggers it; changing only the *username* does not, nor does a random password.
- **Workaround: add `--host-resolver-rules=MAP passwordsleakcheck-pa.googleapis.com 127.0.0.1` to the browser args.** Both examples do. Verified 3/3 on the WDIO mocha example and on the Nightwatch example, where it also fixes the **within-one-test** logout click that a session reset never could. `--guest` also works (3/3); `--incognito` works at the raw-WebDriver level but WebdriverIO rejects it at session creation; disabling the password manager via `prefs` does **not** (6/6 still fail).
- **Not a version regression, not headless-specific, not the site, not "the Nth navigation".** Measured identically on Chrome 149.0.7827.155 / 150.0.7871.124 / 151.0.7922.77 / 152.0.7977.30 with matched chromedrivers (5/5 each), headless and headed, and on a purely local two-page static form. It fires **once per browser profile** on a wall clock — a liveness probe that never navigates again goes dead 904 ms after the submit — so the historical ~25% intermittency was the race between the next input command and that round trip. Do **not** pin `browserVersion` to 149; every part of the earlier "Chrome 150 regression, fixed in 151" attribution is contradicted.
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/components/sidebar/test-entry-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ function isSuiteFragment(entry: Fragment): entry is SuiteStatsFragment {
* still executes first. jasmine matches it. Those runners set no `order` and
* keep this shape.
*
* pytest instead runs in collection order and interleaves module-level tests
* with classes, so it stamps `order` and the two buckets are merged by it. Only
* pytest instead interleaves module-level tests with classes, so it stamps
* `order` and the two buckets are merged by it. That stamp is the child's
* source line, not its position in the run: a rerun collects one test, and an
* index from that collection would say 0 and move the row to the top. Only
* applied when EVERY child carries one — a partially stamped suite would sort
* the unstamped children into a position nothing asked for.
*/
Expand Down
12 changes: 11 additions & 1 deletion packages/app/src/controller/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,18 @@ export class DataManagerController implements ReactiveController {
}

#handleClearExecutionScope(data: unknown): void {
const { uid, entryType, clearSuiteTree } =
const { uid, entryType, clearSuiteTree, runStart } =
data as SocketMessage<'clearExecutionData'>['data']
// A run is starting, so nothing is in flight for the child-clear rules
// below to apply to. Those latches track ONE rerun; left standing they
// made the next rerun at a different scope look like a child clear of the
// last one, and its wipe was skipped — so pressing Rerun on a suite and
// then on the file (or on Tests) kept the previous run's actions, console
// and network rows and grew them run after run.
if (runStart) {
rerunState.activeRerunSuiteUid = undefined
this.#activeRerunTestUid = undefined
}
this.clearExecutionData(uid, entryType)
if (clearSuiteTree) {
this.suitesContextProvider.setValue([])
Expand Down
15 changes: 14 additions & 1 deletion packages/app/src/controller/suite-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,27 @@ export function mergeChildSuites(
// mark them 'pending' so they render as a spinner instead of a stale check.
// Exception: child-scope rerun (activeRerunSuiteUid differs from the
// incoming feature suite's uid) — sibling scenarios keep terminal states.
//
// Also skipped during a SINGLE-TEST rerun, which `mergeTests` already freezes
// siblings for — this is the same rule for the other kind of sibling. A child
// suite on the path to the target re-reports its own state anyway, while one
// off the path never reports again, so flipping it to 'pending' left it
// spinning for the rest of the session. Reproduced on pytest, whose tree puts
// a class suite beside a module-level test: rerunning the module-level test
// left the class in flight with all its tests still showing green.
function resetStaleChildrenOnRerun(
mergedSuites: SuiteStatsFragment['suites'] | undefined,
incoming: SuiteStatsFragment,
ctx: MergeContext
): SuiteStatsFragment['suites'] | undefined {
const isChildRerun =
!!ctx.activeRerunSuiteUid && ctx.activeRerunSuiteUid !== incoming.uid
if (incoming.state !== 'pending' || !mergedSuites || isChildRerun) {
if (
incoming.state !== 'pending' ||
!mergedSuites ||
isChildRerun ||
ctx.activeRerunTestUid
) {
return mergedSuites
}
return mergedSuites.map((s) =>
Expand Down
50 changes: 50 additions & 0 deletions packages/app/tests/data-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,56 @@ describe('DataManagerController', () => {
expect(manager.suitesContextProvider.value).toEqual([])
})

it('wipes execution data on every run start, not just the first', async () => {
// The second rerun of a session used to keep the first one's rows: a
// suite rerun latches its uid to recognise Nightwatch's mid-run child
// clears, and the latch outlived the run — so the next run start, at a
// different scope, was misread as a child clear and skipped its wipe.
const { manager, deliver } = await boot()
deliver('suites', suitesFrame(suite('login-suite')))

deliver(WS_SCOPE.clearExecutionData, {
uid: 'login-suite',
entryType: 'suite',
runStart: true
})
deliver('commands', [command()])
deliver('consoleLogs', [{ type: 'log', args: ['from the first rerun'] }])
deliver('networkRequests', [request()])

// A second run at a DIFFERENT scope — the file, or Tests.
deliver(WS_SCOPE.clearExecutionData, {
uid: RUN_ALL_UID,
entryType: 'suite',
runStart: true
})

expect(manager.commandsContextProvider.value).toEqual([])
expect(manager.consoleLogsContextProvider.value).toEqual([])
expect(manager.networkRequestsContextProvider.value).toEqual([])
})

it('still spares a sibling when one entry resets mid-run', async () => {
// Nightwatch re-emits a cucumber scenario suite while the run is in
// flight, and only that scenario's data may go. That clear carries no
// `runStart`, which is what distinguishes it.
const { manager, deliver } = await boot()
deliver('suites', suitesFrame(suite('feature')))
deliver(WS_SCOPE.clearExecutionData, {
uid: 'feature',
entryType: 'suite',
runStart: true
})
deliver('commands', [command()])

deliver(WS_SCOPE.clearExecutionData, {
uid: 'feature/scenario-2',
entryType: 'suite'
})

expect(manager.commandsContextProvider.value).toHaveLength(1)
})

it('fails the tests still in flight when the run is stopped', async () => {
const { manager, deliver } = await boot()
deliver(
Expand Down
33 changes: 33 additions & 0 deletions packages/app/tests/suite-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,39 @@ describe('mergeSuite', () => {
)
})

it('keeps a sibling child suite settled during a single-test rerun', () => {
// pytest's tree puts a class suite beside a module-level test. Rerunning
// that test re-emits the file suite as 'pending' with only the one test it
// collected — the class is never mentioned again, so flipping it to
// 'pending' left it spinning for the rest of the session with all of its
// own tests still showing green. Sibling TESTS are already frozen by
// mergeTests; this is the same rule for the other kind of sibling.
const existing = suite('file.py', {
tests: [test('file.py::test_module_level')],
suites: [
suite('file.py::TestLogin', {
state: 'passed',
tests: [test('file.py::TestLogin::test_valid')]
})
]
})
const incoming = suite('file.py', {
state: 'pending',
tests: [test('file.py::test_module_level', { state: 'pending' })],
suites: []
})

const merged = mergeSuite(
existing,
incoming,
ctx({ activeRerunTestUid: 'file.py::test_module_level' })
)

const cls = merged.suites?.find((s) => s.uid === 'file.py::TestLogin')
expect(cls?.state).toBe('passed')
expect(cls?.end).toBeDefined()
})

it('strips undefined/null state from incoming to preserve existing state', () => {
const existing = suite('s', { state: 'passed' })
const incoming = suite('s', {
Expand Down
4 changes: 3 additions & 1 deletion packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,12 @@ async function handleTestRun(
`run ${body.entryType} uid=${body.uid} framework=${body.framework} spec=${body.specFile} title=${JSON.stringify(body.fullTitle)}`
)
// Broadcast a clear so popouts (which only see WS events) wipe too.
// `runStart` says this clear is a run beginning rather than one entry
// resetting mid-run, which the receiver cannot tell from the uid alone.
broadcastToClients(
JSON.stringify({
scope: WS_SCOPE.clearExecutionData,
data: { uid: body.uid, entryType: body.entryType }
data: { uid: body.uid, entryType: body.entryType, runStart: true }
})
)
// Plain Rerun hides the Compare tab by dropping all baselines.
Expand Down
Loading
Loading