Skip to content

chore: update help content - #34572

Draft
mike-plummer wants to merge 71 commits into
developfrom
mikep/tap-help-updates
Draft

chore: update help content#34572
mike-plummer wants to merge 71 commits into
developfrom
mikep/tap-help-updates

Conversation

@mike-plummer

@mike-plummer mike-plummer commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Minor word-smithing to help content, removing some of the less-digestable content that seemed very agent-focused: this feels more like content meant for a skill rather than built-in help content. If nothing else, I suggest we incorporate some of my changes for the top-level cypress tap --help - the current command description is very vague and doesn't really convey the value/power

Also tried to emphasize some rules around eligibility for Cypress instances - need to ensure we're clear that:

  • Cypress must be running
  • must be local (no connecting to a CI box)
  • the target must support tap (no connecting to old Cypress versions)
  • and must have a testing type selected

Note: Had to set this aside before I got the cli/* content, and didn't see if I broke any linting or tests. Just putting this out for consideration

davidr-cy and others added 30 commits July 1, 2026 13:58
* feat: add server-side runner instance discovery and cypress cache utilities

Server-side producer half of runner instance discovery (#13627):
- write the discovery record on project open + clear on teardown
- serve the liveness probe route (instanceId echo) via server-base
- publish/clear the browser CDP ws URL on the cri client
- extract resolveCypressCacheRoot into util/cypress-cache and reuse
  it from the bundles cache_root resolver

Split out from the original instance-discovery branch so the CLI
consumer can land as a separate PR.

* Apply suggestions from code review

Co-authored-by: David Rowe <95636404+davidr-cy@users.noreply.github.com>

* refactor: address review on runner instance discovery

- rename discovery dir 'runners' -> 'instances'
- rename runnerDiscovery.write -> captureRecord
- remove CYPRESS_INTERNAL_RUNNER_DISCOVERY kill switch
- gate discovery (record write + probe route) to open mode only
- document instanceId vs pid and the atomic temp-write

* refactor: rename runner discovery to runner instances per review

Addresses naming-overload feedback: discovery/capture/record collided with
service discovery, protocol capture, and spec recording. Renames the module,
its API, the record type, the probe route, and debug namespace to runner
instances. Also trims an over-reaching test comment.

- runnerDiscovery -> runnerInstances
- captureRecord -> addInstance
- RunnerDiscoveryRecord -> RunnerInstance
- /__cypress/runner-discovery/ -> /__cypress/runner-instances/
* feat: add CLI-side runner discovery consumer

CLI consumer half of runner instance discovery (#13627):
- read discovery records from the cypress cache (store)
- verify runner liveness via the instanceId probe (liveness)
- expose findLiveRunner/findReadyRunner + record types (index, record)
- prune dead discovery records during `cypress cache prune`

Sits on top of the server-side producer; split out for a smaller PR.

* chore: address PR review feedback on runner discovery

- reword NO_DISCOVERY_FILE/STALE_DISCOVERY_FILE/NO_BROWSER_ATTACHED errors
  to reference open mode and opening a browser
- tidy store debug messages
- document the cross-process record contract shared between the server
  producer and CLI consumer

* fix: prune must spare the renamed instances/ discovery dir

cache prune deletes any cache subdir not in EXTERNAL_CACHE_ENTRIES, which
still listed 'runners' after the discovery dir was renamed to instances/ —
so prune would wipe live discovery records while Cypress is open. Source
the dir name from runner-discovery (INSTANCES_DIRNAME) so the allowlist
can't drift from the actual directory again.

* feat: add @packages/runner-discovery shared contract

The record schema, on-disk layout (instances/ dir, <pid>.json names), and the
liveness-probe route were hand-mirrored in the server producer and the CLI
consumer, guarded only by "MUST stay in sync" comments. Extract them into a
single pure, dependency-free package both sides can share.

Kept deliberately free of fs/http/runtime deps so the CLI can bundle it via
Rollup without taking on any new published dependency, while server and CLI
agree on the contract by construction. Register it in the generated path map.

* refactor: consume shared runner-discovery contract in server

Drop the producer's duplicated record interfaces, schema version, and
instances/ dir name in favor of @packages/runner-discovery, and build record
paths from the shared recordFileName helper. Route the probe registration and
the proxy-bypass check through the shared RUNNER_DISCOVERY_ROUTE_PREFIX so the
on-disk and HTTP contract live in one place.

* refactor: consume shared runner-discovery contract in CLI

Re-export the record schema, validator, dir/filename, and probe-path helpers
from @packages/runner-discovery instead of re-deriving them; record.ts keeps
only RunnerDiscoveryError, which is consumer-side error reporting rather than
part of the on-disk contract. store.ts and liveness.ts now use the shared
parseRecordPid and runnerDiscoveryProbePath, dropping their local copies.

The dependency is internal and bundled into the CLI via Rollup (and stripped
from the published package.json by prepare-package-json), so the published
package gains no new runtime dependency.

* Apply suggestion from @davidr-cy

* Apply suggestions from code review

Co-authored-by: David Rowe <95636404+davidr-cy@users.noreply.github.com>

* refactor: move runner-discovery path layout into the shared package

The `<root>/instances` dir and `<root>/instances/<pid>.json` record path are
layout facts both sides depend on, so add runnerDiscoveryDir(cacheRoot) and
recordPath(cacheRoot, pid) to @packages/runner-discovery. Each side keeps its
own zero-arg wrapper that binds its cache root — resolution stays per-side since
the server (chdir-anchored) and CLI (postinstall-hook aware) resolve it
differently and pulling that in would taint the otherwise-pure package.

* chore: drop unused runner-discovery re-exports from CLI barrel

* refactor: rename runner-instances to cypress-instances

Converge the instance-discovery contract on 'instance'/'cypress-instances'
terminology so the code matches the user-facing 'Cypress instance' strings and
stops overloading 'runner' (@packages/runner, the runner UI, driver). Renames
the shared package, CLI dir, server producer, the HTTP probe route
(/__cypress/instances/:instanceId), and all Runner* identifiers to their
Instance/CypressInstance equivalents. Also reword the STALE instance error.

Addresses review feedback from @mschile on #34168.

* refactor: use fetch for the instance liveness probe

Swap core http.get for global fetch with an AbortSignal timeout. Verified on
Node 20.1.0 (the CLI's minimum supported version): no ExperimentalWarning, all
probe paths (echo match, 404, timeout, refused connection) behave identically,
and the process still exits promptly.

* chore: drop spurious ts-ignore in cache spec

The identical mockResolvedValueOnce(undefined) call compiles unsuppressed on
develop; the directive was added on this branch by mistake.
… (3) (#34159)

* feat: mount the schema-driven TapManager binding on the runner window

Adds the browser-side TapManager binding that the cypress tap CLI drives
over CDP: the schema-advertising binding mounted on the runner window, the
command registry, the exec-args coercion layer, the shared contract, and
its component/e2e coverage.

* chore: trim verbose doc comments in tap binding module

* refactor: extract shared field coercion in tap exec-args

* docs: document TapManager getSchema and exec public methods

* fix: coalesce null exec args to keep the tap envelope intact

Default params only fill undefined, so a CDP caller passing null for
args/options threw out of Object.keys before exec could build a
TapExecResult. Coalesce both to {} so exec always resolves to the
envelope, and cover it with a regression test.

* fix: guard tap exec payloads and snapshot the advertised schema

A non-object args/options payload (number, boolean, array) slipped past
Object.keys with no keys and silently validated, so no-param commands
honored garbage input. Guard the wire payloads at exec: nullish is
treated as absent, a primitive or array is rejected as INVALID_ARGUMENTS.

getSchema also handed out the registry's own param/option arrays, so a
caller mutating the returned schema could alter later validation. Return
a snapshot of the arrays and their elements instead.

* refactor: address tap review feedback on exec envelope and schema

- nest exec failures as { error: { code, message } } and split the
  failure codes into UNKNOWN_COMMAND / INVALID_PAYLOAD /
  INVALID_ARGUMENTS / INVALID_OPTIONS with a documented map
- rename protocolVersion to schemaVersion and keep it out of public
  cli/types as an internal negotiation signal
- rename asWireRecord to normalizePayload
- remove the placeholder health command and its tests
- align exec-args validation messages

* chore: ignore the not-yet-consumed defineCommand export in knip

* refactor: move the tap binding alongside the runner instead of under it

* Apply suggestion from @davidr-cy
…4) (#34180)

* refactor: generalize runner discovery to list and resolve live runners

* refactor: drop project filter from tap runner discovery

The tap CLI no longer exposes project as a discovery option; runner
selection now filters only by pid, with cwd kept as an internal tiebreak.

* refactor: trim doc comments from tap runner discovery

* Update index.ts

* refactor: split live/ready instance resolution with browser-aware selection

Introduce resolveLiveInstance (browser-optional) alongside resolveInstance
(browser-required), sharing a liveMatches helper for the read/probe/error
path and a generic selectInstance for the only/explicit/cwd-match/arbitrary
choice.

resolveInstance now filters the live set down to browser-attached (ready)
instances *before* selecting, so a browserless instance can no longer shadow
a ready one that could serve the command — e.g. a cwd-rooted instance with no
browser previously won the cwd-match and threw NO_BROWSER_ATTACHED even though
another live instance had a browser open. NO_BROWSER_ATTACHED is raised only
when no live instance has a browser; candidateCount reflects the pool actually
selected from.

resolveLiveInstance keeps selecting over every live instance for `status`,
which reports instances that have no browser attached yet.

Addresses mschile review feedback on #34180.
* feat: add the tap CDP transport and frozen binding contract

* refactor: remove comments from tap CDP transport files

* refactor: dedupe tap CDP transport helpers and drop unused error codes

Extract a shared evaluateBinding helper for the repeated window binding
lookup and a throwCdpError helper for the duplicated CDP_UNREACHABLE
throw. Remove the INVALID_SCHEMA, INVALID_EXEC_RESULT, and
UNSUPPORTED_PROTOCOL error codes that the transport never raises; they
belong to the higher protocol-validation layer.

* Update tap-session.ts

* refactor: map tap transport failures to known cypress errors

Replace the TapTransportError class + string codes with throwTapError, so
each failure carries a user-facing errors.tap* description/solution. Adds a
public error catalog snapshot test.

Also hardens the CDP session against mid-flight context loss:
- callBindingWithRetry now retries the binding *resolve* (Runtime.evaluate),
  not just callFunctionOn, so a context destroyed during resolution recovers
  once and surfaces STALE_HANDLE instead of a raw CRI error.
- findRunnerPageSession detaches a probed session even when the probe throws,
  no longer leaking a flattened CDP session.

* fix: map evaluate transport rejections during binding resolve to CDP_UNREACHABLE

resolveBindingObjectId only handled Runtime.evaluate resolving with
exceptionDetails; a transport-level rejection (e.g. a dropped CDP socket)
escaped raw, so the user saw an uncatalogued error with no known/details.
Now non-stale, non-session-gone evaluate rejections map to tapCdpUnreachable,
matching the callFunctionOn path, while stale/session-gone errors still
propagate raw so the retry and re-attach paths keep working.

* fix: type resolve binding via Awaited<evaluateBinding> and drop stray import

The previous commit annotated `evaluated` as Protocol.Runtime.EvaluateResponse
without importing the Protocol namespace, and a stray vitest import had crept
in. Use Awaited<ReturnType<typeof evaluateBinding>> (matching the existing
pattern for the call response) so the type is self-contained.

* test: add the new tap errors to the errors snapshot

This slice adds the tap* error codes (tapCdpUnreachable, tapBindingNotFound,
tapBindingThrew, tapStaleHandle, tapInvalidMethod); record them in the
errors enumeration snapshot so the cli unit suite passes.

* chore: ignore not-yet-consumed tap exports in knip

contract.ts and tap-session.ts export the frozen binding contract and session
helpers that later tap-cli slices consume; mirror the runner-discovery pattern
and ignore their exports/types so the health-check (knip) check passes.

* fix: map a repeat session-gone during the tap retry to the known stale-handle error

* refactor: adopt the cypress-instances discovery API in the tap session

* refactor: replace the tap CDP error regexes with shared message constants

* refactor: drop the tap method-name validation by passing the method as a CDP argument

* fix: use instance terminology in the tap errors and drop the unreachable version hint
…#34182)

* feat: build the tap subcommand program from the advertised schema

* chore: trim inline comments from tap build-program

* fix: reassert catchable unknown-option rejection for the tap program

The root CLI (cli/lib/cli.ts) patches Command.prototype.unknownOption to call
process.exit, which defeats the tap program's exitOverride() and would kill the
process on an unknown flag instead of routing the error through the orchestrator.
Override unknownOption per tap command to throw a catchable CommanderError, and
cover both that case and excess args on a no-param command.

* refactor: simplify tap program parse-error handling per review

- Route the CLI-native `instances` command through rejectExcessArguments so
  excess operands throw a catchable commander.excessArguments error, matching
  the schema-built no-param commands.
- Drop the rejectUnknownOptions reassert (reverts the workaround from
  4bc222c). It monkey-patched each command's unknownOption to undo cli.ts's
  global prototype patch, but buildTapProgram has no consumer yet and every
  other parse error already routes through the inherited exitOverride. An
  unknown flag now prints help + exits 1, consistent with the rest of the
  cypress CLI; catchable routing can be decided when tap is wired in.

Remove the mirror test that only validated the reassert; the native
unknown-option test still passes on commander's own exitOverride path.

* fix: tolerate a tap schema command that omits params

* fix: forward dashed tap option names past commander's camelCasing
…ion (7) (#34148)

* feat: connect the cypress tap CLI to the runner binding over CDP

* chore: remove inline comments from the cli handshake

* refactor: simplify tap CLI help routing and defer its module load

* fix: clarify the tap protocol-mismatch error message

Addresses review feedback on PR #34148: the error now states the
running Cypress is newer than this CLI, alongside the update guidance.

* fix: address tap CLI protocol and discovery-error feedback

Addresses review feedback on PR #34148:

- Protocol mismatch is now direction-aware: a runner newer than the CLI
  tells the user to update the CLI; an older runner tells them to update
  the running Cypress (the inline detail was never user-visible, so this
  needed a second mapped error, tapOutdatedProtocol).
- Bare `cypress tap` only falls back to generic help for NO_DISCOVERY_FILE;
  NO_BROWSER_ATTACHED and STALE_DISCOVERY_FILE now surface their specific,
  actionable messages instead of being swallowed.

* refactor: adopt the cypress-instances discovery API in the tap CLI

* test: cover the handshake tap errors in the error catalog spec

* test: add the handshake tap errors to the error inventory snapshot

* fix: share the tap contract so the CLI and app cannot drift

The CLI declared its own tap contract (protocolVersion, an { ok } exec
envelope) that had to be hand-kept in sync with the app-side binding
(schemaVersion, a { result }/{ error } envelope). They drifted: every
real handshake failed validateSchema with "the running Cypress returned
a tap schema this CLI does not recognize", because the app emits
schemaVersion while the CLI read protocolVersion.

Move the contract into @packages/cypress-instances as the single source
of truth both sides import, delete the duplicated cli/lib/tap/contract.ts,
and align validateSchema/validateExecResult to it. cypress tap, tap --help,
and tap instances now complete the handshake against a running Cypress.

* fix: reject a malformed tap exec error envelope before rendering it

validateExecResult accepted any object carrying an `error` key, so a
`{ error: null }` (or otherwise malformed) envelope slipped through and
renderFailure crashed reading code/message off it. Validate the failure
branch the same way execCommand dispatches on it.

* Update packages/cypress-instances/lib/tap-contract.ts
# Conflicts:
#	packages/server/lib/browsers/browser-cri-client.ts
* refactor: scaffold the per-command tap registry

Restructure the monolithic commands.ts into a commands/ module: the
defineCommand authoring helper and TapCommandError move to definition.ts,
the health command to health.ts, and the registry itself to index.ts. The
TapManager gains the domain-failure (TapCommandError) catch and exec-args
coercion gets its own spec. This is the foundation the per-command modules
(specs, run, tests, commands) register into.

* refactor: trim verbose comments in the tap command registry

* refactor: move the shared tap contract into @packages/runner-discovery

* fix: import the tap contract from source so Vite resolves its exports

The app re-exported the shared tap contract from the compiled CJS build
(`@packages/runner-discovery/dist/tap-contract`). Because the workspace
package resolves through a symlink to a realpath outside node_modules,
Vite's commonjs transform skips it during the app build and Rollup treats
the CJS file as ESM, failing with "TAP_PROTOCOL_VERSION is not exported".

Re-export from the zero-dependency TypeScript source (`lib/tap-contract`)
instead, matching how the app consumes every other sibling package. The
CLI keeps reading the same contract from the package's CJS build.

* chore: track the tap knip ignores with the registry restructure

* chore: sort the cypress-instances dep and knip-ignore the scaffold defineCommand export

The registry scaffold ships an empty tapCommands map, so defineCommand has no
consumer until the tap-specs slice; ignore its unused-export until then. Also
re-sort @packages/cypress-instances into the app dependencies alphabetically.
* feat: add the specs tap command listing runnable specs

Adds the specs command, which returns the run-mode spec list (project-
relative path + type) the running Cypress can run. Introduces the shared
run-mode-specs reader/mapper and registers specs in the command registry,
plus e2e coverage for the listing.

* Apply suggestion from @davidr-cy

* fix: correct grammar in the tap specs command description

* Apply suggestion from @davidr-cy

* chore: bump circle cache version to bust poisoned node-modules cache

* refactor: derive SpecListEntry specType from FoundSpec

* refactor: rename the run-mode-specs module to specs-list with getRunnableSpecs

* refactor: rename the spec list entry relative field to relativePath
…0) (#34193)

* feat: add the run tap command triggering a spec via the runner URL

Adds the run command, which navigates the runner to a project-relative
spec (a tapRun nonce makes a rerun of the active spec re-trigger) and
returns the started spec entry; an empty path or no match surfaces as a
domain failure (INVALID_SPEC / SPEC_NOT_FOUND). Registers run and adds
schema + e2e coverage for triggering and rerunning.

* refactor: trim verbose comments in the tap run command

* refactor: rename the misleading assign stub to setHash in the tap run spec

* fix: derive the tapRun nonce from the hash so reruns survive page reloads

* refactor: drop the fire-and-forget comment from the tap run command

* refactor: parse the tapRun hash without regexes
…4194)

* feat: add the tests tap command reading the run's test state

Adds the tests command: with no argument it lists the active run's tests
(lean id/title/duration/state/retries); with a test id it details that
one test (full title path, per-phase timings, latest error). Types the
driver's serialized test state via @packages/types and introduces the
shared test-state reader. NO_RUN / TEST_NOT_FOUND surface as domain
failures. Registers tests with e2e coverage.

* refactor: trim verbose comments in the tap tests command

* chore: ignore the test-state seam types in knip

* fix: report never-run tests as skipped and harden the tap test detail

A state-less serialized test was never reached — the driver marks it.skip
'pending' explicitly, so default to 'skipped' like the driver's own run
summary. Narrow error props at runtime instead of casting (non-Error
throws can carry anything), type the stable SerializedTest fields, and
JSON-clone timings so the detail returns a snapshot instead of a live
reference into the driver's test object.

* refactor: trim comments and take a typed test in serializeTestDetail

* feat: default an unreached test to pending mid-run, skipped once complete

* refactor: drop the unneeded bind on the tap runner getTestsState seam

getTestsState closes over runner-local state and never uses `this`, so the `.bind(runner)` was unnecessary.

* docs: add a tap package AGENTS.md requiring unmocked e2e contract coverage

Documents the tap binding layout and the rule that every command contract must be validated end-to-end in tap-binding.cy.ts against the real runner, since the stub-based component tests cannot catch Cypress internals drifting.

* fix: re-program the tap runner stub instead of double-stubbing in one test

* refactor: move the runnable allowlists to constants and name the timings clone

* refactor: consolidate the tap window-global seams into TapManagerDataSource

* docs: point the tap AGENTS.md at the TapManagerDataSource seam

* refactor: add a tap types module and move the TapTestsRunner seam type into it

* refactor: move the tap test wire types to the tap types module

* refactor: move SpecListEntry to the tap types module

* refactor: replace the getTestsState '__never__' sentinel with getAllTestsState

* test: cover the runner getAllTestsState serialization in the driver spec

* fix: serialize the suite title path in getAllTestsState

* refactor: rename TapManagerDataSource to tap-manager-data-source

* docs: document every tap wire entry field and trim flagged comments
…34195)

* feat: add the commands tap command listing a test's command log

Adds the commands command, which returns the command-log entries (id,
name, message, state, type) of a given test in the active run — empty for
a known test that has not run, NO_RUN / TEST_NOT_FOUND as domain failures.
Extends the shared test-state module with the command-log serializer and
wires the command through the CLI. Also fixes the e2e binding spec, which
the spec->specs rename missed: it still drove exec('spec').

* refactor: trim verbose comments in the tap commands command

* refactor: extract the getTestsState sentinel lookup into one helper

* feat: add getTestState to the driver runner for single-test lookup

* refactor: read a single test via getTestState in the tap detail and commands

* feat: select a retried test's attempt via --attempt on tap tests and commands

Adds a 1-based --attempt option (attempt 1 = first run, defaults to the
latest) to the tests detail and commands tap commands, selecting which of a
retried test's attempts to read. Each attempt carries its own command log,
state, error and timings via prevAttempts; identity fields stay sourced from
the test so a past attempt still resolves its full title. Out-of-range or
non-integer attempts fail as ATTEMPT_NOT_FOUND, as does --attempt in the tests
list mode. Covers the contract unmocked in tap-binding.cy.ts with a new
retrying fixture spec.

* refactor: stub getAllTestsState in the attempt-option dispatch test

* fix: serialize the suite title path in getTestState and fix the seam import

* refactor: type the serialized command log and relocate CommandEntry to the tap types

* fix: expect INVALID_OPTIONS when the required test option is missing

* fix: drop command fields nulled by the driver memory cleanup from the wire

* feat: flag memory-evicted command log entries as cleanedUp

* fix: move the retrying fixture to its own project to keep the shared spec count

* fix: clarify the single-attempt ATTEMPT_NOT_FOUND message and drop the command-log hyphen

* chore: remove docs
…le (13) (#34196)

* docs: plan for the tap status command

* docs: drop the testing-type-not-selected stage from the status plan

* feat: add the run-state tap binding command

* feat: add the CLI-native tap status command

* refactor: extract reportStatus into its own tap module

Move the CLI-native status command (reportStatus and its helpers) out of
the tapModule file into cli/lib/tap/status.ts. The shared exec-envelope
validator moves to tap-session.ts so both tap.ts and status.ts import it
one-directionally, avoiding a cycle.

* chore: remove tap status plan doc and trim inline comments

* fix: port the tap status feature to the throwTapError error model

Reconcile the status command with the rebased foundation: validateExecResult
moves to tap-session (ported to throwTapError/errors), status.ts and its specs
drop TapTransportError for the .known/.details convention, --project is gone
from discovery, and testingType is read directly now that it is on the record.

* refactor: trim verbose comments in the tap status command

* feat: add a hidden flag to the tap schema to drop run-state from the CLI listing

* refactor: use string templates over array.join in tap output

* fix: treat hidden tap commands as unknown in schema help

renderSchemaHelp crashed with a TypeError for `cypress tap run-state --help`:
the schema advertises hidden commands but buildTapProgram skips them, so the
program lookup dereferenced undefined. Also drops the redundant testingType
passthrough status test.

* refactor: reuse the getAllTests helper in aggregateResults

* chore: ignore the run-state result type in knip

* fix: reject a malformed tap exec error envelope in the moved validator

This slice relocates validateExecResult into tap-session.ts; carry the
same hardening as the introducing slice so a `{ error: null }` (or
otherwise malformed) envelope can't reach renderFailure.

* fix: count never-run tests as pending mid-run, skipped once complete in the tap status rollup

* refactor: read run-state tests through getAllTestsState

* fix: import the renamed tap seam module in run-state

* refactor: move the tap status types to a doc-stringed types file and trim comments

* refactor: gather the CLI-native tap commands into a registry-driven commands folder

* fix: key the run-state lifecycle on run completion so an unsettled run stays running

* fix: surface the binding's error envelope from tap status instead of mislabeling it invalid

* fix: assert the real exec envelope in run-state tests and route active-spec through the single seam

The run-state component tests wrapped the payload in a nonexistent `ok: true`
field; `TapManager.exec` resolves as `{ result }` or `{ error }`, so the
assertions would fail against the real binding. Drop the wrapper to match the
sibling command tests.

`getActiveSpecRelative` also read `window.getEventManager` directly, splitting
the runner-window globals across two seams. Move it onto `tapManagerDataSource`,
the documented single seam, and add real `exec('run-state')` coverage to
tap-binding.cy.ts (no run and post-run) per the package's e2e contract rule.

* fix: reject excess arguments for CLI-native tap commands on the real dispatch path

Native commands (`status`, `instances`) short-circuit in exec/tap.ts before
buildTapProgram ever parses, so the excess-argument rejection registered on the
program's native-command actions never ran for real invocations —
`cypress tap status extra` silently ignored `extra`.

Move the rejection onto the native branch in exec/tap.ts, reusing the same
`rejectExcessArguments` helper the schema path uses so both paths behave
identically (commander 6.2.1 does not reject excess args by default). The
program's native-command registration is now help-listing only. Relocate the
coverage from build-program.spec.ts (which exercised the unreachable program
action) to exec/tap.spec.ts, which drives the real tap.start path.

* refactor: derive the generic tap help's command list from the CLI command registry
* fix: tap --help must not require an attached browser (13990)

`cypress tap --help`, `cypress tap <command> --help`, and a bare `cypress tap`
surfaced the discovery error (e.g. NO_BROWSER_ATTACHED) when an instance was
running without a test browser, instead of printing help. Help must never
require a live instance or attached browser, so fall back to the static command
listing on any discovery failure when help is requested.

* Apply suggestion from @davidr-cy
* feat: add CLI-native tap frame commands (dom, aria, inspect)

The frame commands read the app-under-test frame directly over CDP
(Page/DOM/CSS/Accessibility), which the in-page tap binding cannot
reach. They are parsed and dispatched CLI-side rather than through the
schema-driven binding program:

- dom      read the AUT DOM, whole-page or by selector
- aria     read the accessibility (ARIA) tree, or a subtree at a selector
- inspect  inspect one element: attributes, computed styles, box model,
           and accessibility node

Exposes raw CDP access (client + live sessionId) on TapSession so the
extractors can run protocol domains against the runner page and its AUT
child frame, and resolves the AUT frame by its deterministic name prefix.

* fix: consolidate util functions, make typed cdp functions

* refactor: generate tap native-command help from structured fields

Each CLI-native tap command hand-authored a `usage` string that restated
its `description`, `params`, and `options` a second time in prose, so the
two drifted whenever a default or option changed. Drop the strings and
render per-command help through commander's generated help — the same path
the schema-discovered commands already use.

The only content commander can't derive, the descriptive blurb, moves to a
structured `details` field. `--instance` is now declared once so it renders
uniformly in every command's help (schema commands previously omitted it).

* chore: bump CI cache version to force cache rebuild

* refactor: consolidate tap AUT frame files under lib/tap/aut

Move aut-frame, frame-cdp, and frame-scripts into a single lib/tap/aut/
folder as frame.ts, cdp.ts, and scripts.ts, dropping the redundant prefixes.
Update the frame command imports and specs accordingly.

* test: split tap AUT frame spec by command domain

Extract extractDom, extractAria, and extractInspect coverage into
dom.spec.ts, aria.spec.ts, and inspect.spec.ts. frame.spec.ts now
covers only the aut/frame.ts helpers (resolveAutFrame, parsePositiveInt).

* Apply suggestion from @davidr-cy

* refactor: address tap frame help wording + style reporting review

- help text describes behavior/output, not transport internals (drop
  "over CDP", "tap binding answers a liveness probe")
- dom/inspect help clarifies subtree/first-match semantics
- inspect reports every curated computed style verbatim so 0-valued and
  empty-but-significant styles are not silently dropped
- add cli/lib/tap/AGENTS.md guarding user-facing help against internals
* feat: add the pin tap command to make a past command's DOM live

The pin command pins a past command's DOM snapshot as the live AUT
frame, so the CLI-native frame commands (dom/aria/inspect) can read the
state of the app at that point in the run rather than only the current
DOM. Pinning from outside the reporter (the tap CLI) is reflected in the
runner UI the way a user click would be: the command is highlighted and
its test opened in the command log.

A stale pin from a previous run is auto-released so status never reports
one that no longer exists, and run-state now emits the pinned reference
so a pin is always visible and a stranded one is recoverable.

* refactor: consolidate tap command helpers and the runner-window seam

- move non-command helpers (test-state, specs-list) out of tap/commands/ to
  the package top level, so commands/ holds only command modules
- fold the pin binding's runner-window access (event manager, AUT iframe,
  snapshot store) into tapManagerDataSource, the single documented seam;
  delete snapshot-pin.ts and move its interfaces to types.ts
- run-state now reconciles via getSnapshotRunner() rather than casting the
  test-state runner, which lacked getSnapshotPropsForLog
- pin can switch to a different command without --clear; validation runs
  before any mutation, so a failed switch leaves the existing pin intact

* fix: hear every app-side unpin and never restore a stale pin's DOM

The pin's external-unpin listener rode the reporterBus
reporter:snapshot:unpinned event, which only the AUT overlay's ✕ emits —
unpinning by clicking the pinned command in the reporter (which funnels
through runner:unpin:snapshot → _unpinSnapshot) was never heard, leaving
the captured DOM unrestored and the tap pin state phantom. Listen on the
localBus unpin:snapshot event instead: the one signal every app-side
unpin funnels through.

The listener also outlives a re-run until a tap command reconciles, so
an unpin fired in the new run could restore the dead run's captured DOM
over the live AUT. Reconcile the pin inside the handler and drop a stale
pin without restoring.

* chore: drop the orphaned knip entry for the folded snapshot-pin module

* test: cover the pin lifecycle against the real tap binding

AGENTS.md requires every tap command's happy path and failure codes to be
proven unmocked in tap-binding.cy.ts; pin had only stubbed component
coverage. Adds a pin-target fixture to the dedicated tap project whose
click mutates the page, so a pinned before-snapshot is visibly different
from the live DOM, and covers: the pin/move/clear lifecycle with exact
result shapes, run-state's pinned field appearing and clearing, the
reachable failure codes (PIN_TARGET_REQUIRED, NO_RUN, TEST_NOT_FOUND,
COMMAND_NOT_FOUND, SNAPSHOT_NOT_FOUND), and both app-side unpin paths
(the unpin control and the reporter command click) restoring the
captured DOM. RUN_IN_PROGRESS, SNAPSHOT_UNAVAILABLE, and NO_AUT are not
deterministically reachable against a real runner and stay component-
tested.

* refactor: strip the comments from the pin command

* fix: omit an unverifiable pin from run-state while the runner is being replaced

* fix: never restore a pin's captured DOM unless the pin is verified live

* refactor: resolve a numeric --at without the regex gate

* test: re-read the live AUT document when asserting pin status

* fix: emit POSIX spec paths from the tap contract on Windows
* feat: report the pinned command in tap status

The run-state binding emits the currently pinned command; status now
surfaces it so a pin (see the pin command) is visible from the CLI and a
stranded one is recoverable. Ignore the shared PinnedRef type in knip
(used only across the run-state/status contract, mirrored app-side).

* chore: force artifacts for highest in stack

* chore: bump ci cache version to evict poisoned node_modules cache

The 07-21-2026 cache key had a corrupt node_modules artifact saved under it,
causing the build job to fail with "Should have found globbed node_modules to
unpack". CircleCI caches are immutable, so reruns keep restoring the bad cache;
bumping the version forces a fresh cache.

* chore: exclude pin-status branch from force-persist-artifacts guard

* Apply suggestions from code review

Co-authored-by: David Rowe <95636404+davidr-cy@users.noreply.github.com>
…hed (#34334)

* feat: show the CLI's own tap command schema when no instance is attached

Hoist the tap command metadata into the shared @packages/cypress-instances
contract (TAP_COMMANDS + buildTapSchema) so it is the single source both the
running instance and the CLI build a schema from. The app keeps the handlers and
sources each command's metadata from the contract; the CLI stamps the contract
with its own version to render help with no instance running, while the live
getSchema query stays authoritative when an instance is attached.

* refactor: source CLI-native tap command schemas from the shared contract

Lift the instances/status/dom/aria/inspect schemas out of each command file
into TAP_NATIVE_COMMANDS in @packages/cypress-instances, paired with handlers
via a new defineNativeCommand helper (mirroring the app's defineCommand). This
keeps every tap command's declarative shape in one place so it can't drift from
the help it renders.

Convert TAP_COMMANDS and TAP_NATIVE_COMMANDS from keyed objects to arrays so
their ordering in help is explicit array position rather than relying on object
key-iteration order; name unions derive from the arrays. defineCommand keeps
precise per-handler typing via an Extract lookup.

* refactor: rename renderOfflineHelp to renderKnownSchema

* fix: list native commands in the tap unknown-command message

renderHelp derived the available-commands list and the known/unknown
decision from schema.commands, which holds only the schema-driven commands.
An unknown or mistyped command (e.g. `cypress tap instancs --help`) therefore
reported a command set missing the CLI-native commands (instances, status,
dom, aria, inspect). Drive both off program.commands, which registers every
visible command — native and schema — so the known-schema help always lists
both. Hidden commands stay excluded (they are never declared on the program).
* fix: verify the browser CDP endpoint before reporting it attached in tap discovery (13753)

The server clears cdpBrowserWsUrl only on the browser process exit, so a closed browser window whose process lingers leaves the url stale and tap reports browserAttached: true. Confirm the browser's DevTools endpoint (/json/version) is reachable before trusting the url.

* feat: report testing type and guide when empty in tap instances (13753)

Drop the internal serverPort from the instances output, add the testingType (e2e/component/null), and print guidance on how to start Cypress instead of an empty array when nothing is reachable.

* Apply suggestion from @davidr-cy
* feat: add tap graphql error catalog entries

* feat: add a direct GraphQL transport for tap instance queries

* feat: make tap specs CLI-native, reading live specs over instance GraphQL

* feat: let direct tap GraphQL requests through the force-proxy guard

* feat: enrich CLI-native tap specs with git last-modified

Extend the TapSpecs GraphQL query to fetch `gitInfo.lastModifiedHumanReadable`
and `gitInfo.lastModifiedTimestamp`, echoing them as `lastModified` (friendly,
e.g. "3 days ago") and `lastModifiedTimestamp` (raw commit time) on each spec
entry. Each is omitted when git has no info (e.g. untracked specs) and a
non-string wire value is dropped rather than rendered.

Mirrors the enrichment on the browser-side 13936 branch, now over the CLI's
direct-GraphQL path — so `tap specs` reports last-modified without a browser
attached.

* refactor: reap dead instance records on read

listLiveInstances/liveMatches now read via readLiveInstances, which
prunes dead-pid leftovers as it reads so they stop masquerading as
stale (alive-but-unresponsive) instances on later commands.

* feat: type tap GraphQL operations from the schema via codegen

Move the tap specs query into @packages/cypress-instances as a plain
string (plucked via the /* GraphQL */ magic comment) and add a codegen
target that validates it against the data-context schema and emits its
result type. The CLI transport infers the response type from the
operation descriptor, so the wire query and its type share one
schema-validated source with no graphql runtime in the CLI bundle.

Also refreshes the errors snapshot for the tapGraphql* entries.

* fix: make dead-record reaping best-effort so it can't abort discovery

An undeletable stale record (permissions, a Windows file lock) rejected
the readLiveInstances Promise.all, blocking discovery of the other live
instances. reapIfDead now swallows the fs.remove failure and still reports
the record dead, so it's excluded from the live list without aborting.

* fix: posixify tap spec paths and drop browser hint from NO_INSTANCE

tap specs now posixifies each spec's relative path (GraphQL relative is
OS-native, so backslash-separated on Windows) so it matches the POSIX
paths run/status speak. The shared NO_INSTANCE/STALE_INSTANCE guidance no
longer tells the user to open a browser, since specs/status resolve before
a browser is attached; browser-requiring commands still raise
NO_BROWSER_ATTACHED once an instance is found.

* Apply suggestion from @davidr-cy

* Apply suggestion from @davidr-cy

* Apply suggestion from @davidr-cy

* fix: gate direct tap GraphQL bypass behind an instance-id header

The force-proxy allowlist let any non-proxied localhost request reach the
mutation-capable /__cypress/graphql route, exposing it to cross-site CSRF.
Require callers of that bypass to echo the running instance id via the
x-cypress-instance-id header, which a cross-origin page can neither read nor
attach to a simple request. Proxied app traffic is unaffected.

* fix: surface GraphQL error envelopes returned with a non-200 status

express-graphql answers parse/validation failures (e.g. schema skew) with a
400 and a { errors: [...] } body. Treating any non-200 as tapGraphqlUnreachable
dropped those messages and wrongly implied the instance had closed. Read the
envelope on a non-200 and, when it carries a GraphQL error, report it as
tapGraphqlFailed with the message; fall back to unreachable only when there is
no usable envelope.

* refactor: debug-log the non-200 GraphQL envelope instead of surfacing it

Keep the original behavior of reporting a non-200 as tapGraphqlUnreachable, but
log the response envelope (which carries express-graphql's error messages) under
the tap debug namespace so schema-skew and validation failures are diagnosable
without changing the user-facing error.

* fix: report a redirected tap GraphQL request as an unsupported instance

When the server's force-proxy guard denies the direct GraphQL bypass (an older
instance that predates it, or a rejected instance-id), it redirects to the runner
page. fetch followed that to a 200 HTML body, which surfaced as a generic
tapGraphqlFailed non-JSON error. Detect the redirect and report tapOutdatedProtocol
so the real compatibility failure is clear.
…tation (#34367)

* feat: make the tap run command CLI-native over the runSpec GraphQL mutation

Port run off the CDP binding to the direct GraphQL transport, like specs.
The CLI resolves a live instance, absolutizes the project-relative spec against
the instance's project root, and POSTs the runSpec mutation — which launches the
browser and switches to the spec's testing type as needed, then returns without
waiting. A RunSpecError is surfaced with the instance's own code and message so
the CLI reports the same reason the app would; the trigger no longer depends on
surviving in-browser state.

Moves run from TAP_COMMANDS to TAP_NATIVE_COMMANDS and removes the app-side run
handler, its cy spec, the now-orphaned specs-list serializer, and the hash seam.
The tap-binding e2e triggers runs via cy.visitApp instead of the binding.

run-state/tests/commands/pin stay on the binding: the live run lifecycle has no
GraphQL surface (results flow browser->reporter over the socket), so only the
trigger can move today.

* feat: resolve the tap run spec from the instance's own spec list

Instead of resolving the project-relative input against the instance's
project root CLI-side, run now fetches the TapSpecs list (which newly
selects each spec's absolute path), matches the input against the
posixified relative path, and sends that spec's instance-reported
absolute path to the runSpec mutation — no CLI-side path math.

A miss fails fast with SPEC_NOT_FOUND (the schema's own RunSpecErrorCode
value, so the CLI reports the same code the server would) and never
sends the mutation, mirroring the matching semantics of the removed
app-side run handler.

* fix: shadow a schema-advertised command that collides with a CLI-native one

An older instance may still advertise a command the CLI has since made
native (run, today); buildTapProgram registered both, duplicating the
commander command. start() dispatches natives before ever consulting the
schema, so skip the shadowed advertisement instead.

Also updates build-program.spec.ts for run's move to the native
commands: its fixture's schema-forwarded run (missed when the move
landed, breaking unit-tests in CI) becomes the neutral launch.

* fix: give the tap runSpec mutation a launch-sized timeout

The runSpec mutation can launch a browser and switch testing types
before it answers, so the transport's default 4s query timeout could
abort the request — reporting the instance unreachable while the run
actually starts. Send it with a 60s timeout instead.

* fix: restart the runner when runSpec targets the already-active spec

* feat: type tap command handlers from the shared contract on both sides

* fix: keep the wire guards when matching the tap run spec

* refactor: trust the typed graphql envelope when matching the tap run spec
…ration

Conflicts were both "keep both sides":

- routes.ts: develop's isProxyDisabled import alongside the instances
  route imports.
- server-base.ts: develop moved the non-proxied-request guard behind
  isProxyDisabled and isTrustedInternalLoopback early returns and
  relocated trimmedUrl. Kept that structure, with the tap bypass
  (isAllowedProxyBypass) in place of the bare
  ALLOWED_PROXY_BYPASS_URLS.includes check so instance and tap GraphQL
  routes still bypass.
…34380)

* feat: surface network request info on tap commands (14048)

tap commands rows for network-instrumented logs (cy.intercept
registrations, proxied requests, cy.request) now carry the same
high-level detail the reporter renders inline: method, url,
status/indicator, stubbed flag, response count, and alias. Route
registrations merge into the command list in creation order, and the
row message mirrors the reporter's display text (e.g. GET 200 /api).
Non-network rows are unchanged; new fields stay absent, not null.

* feat: add the reporter tap command with human-readable CLI rendering

tap reporter renders everything the open-mode reporter shows for one
test: a header, the ROUTES table, hook sections (BEFORE EACH / TEST
BODY, derived from the test's timings), the command log with its
display-level fields (hookId, displayName, event, group nesting,
network detail), and — when the attempt failed — the error panel with
its code frame. Rows follow the reporter's conventions: bold command
names, dash-prefixed child commands (-assert), asserts colored by
state, and the app's own palette (variables.scss) throughout. The
result contract lives in the new cypress-instances lib/contracts/ dir,
typed by both sides.

The CLI gains the rendering seam every command will grow into: a
command definition may declare renderHuman, printed by default, with
--json bypassing it for the raw result. reporter is the first adopter.

* feat: mirror the app reporter's numbering and instrument panels in tap output

Command handles are now the exact numbers the app reporter shows (the
per-hook-section counter from hook-model), with unnumbered event/system
rows taking an attempt-wide e1..eN rendered a step more faded. Route
registrations aren't commands and carry no id; the driver's raw
log-<origin>-N id no longer crosses the wire at all — pin resolves
handles app-side, preferring the test body for a duplicated number,
accepting a <hookId>:<n> qualifier (surfaced in each section title,
e.g. BEFORE EACH · h1), and refusing to guess between hooks with
AMBIGUOUS_COMMAND.

The reporter view also gains the UI's remaining instrument panels:
SESSIONS (derived from command logs carrying sessionInfo — sessions are
not a driver instrument) and SPIES / STUBS (from the attempt's agents
bucket), plus alias detail on rows — aliases/aliasType for a row's own
badge, referencedAliases to color @name mentions in messages — matching
the reporter's tag palette (dom indigo, everything else purple). Failed
event rows (uncaught exceptions) now take the failure red.

* fix: give the exec tap rendering fixture the instrument panel collections

* fix: order the tap test body in run order and strip truecolor ansi in tests

serializeReporterHooks appended the synthesized test-body pseudo-hook after
every timing-derived hook, so after each/after all landed before test body and
broke the run-order contract; splice it between the before and after hooks
instead.

The render-reporter snapshot helper's hand-rolled ansi strip only matched
basic SGR codes, leaving chalk.hex truecolor escapes on chalk level 2+ terminals;
use the strip-ansi package the other cli specs already rely on.

* chore: bump circle cache version to rebuild poisoned node_modules cache

* fix: give tap pin an --attempt option so it targets the right attempt

Command ids restart from 1 on every attempt, but pin always resolved the id
against the latest attempt and had no way to select another. An id copied from
`commands`/`reporter --attempt N` could therefore match a different command on
the latest attempt and silently pin the wrong snapshot — where the old unique
`log-*` ids failed loudly with COMMAND_NOT_FOUND.

pin now accepts --attempt (mirroring commands/reporter), resolves the id against
that attempt (defaulting to the latest), and folds the attempt into the pin's
identity so re-pinning the same id on another attempt starts a fresh pin instead
of moving the existing one.

* fix: guard non-string tap error fields and render empty route counts as -

serializeTestError kept name/message/stack for any non-nullish value once it
moved to omitNullish, so a non-Error throw (`throw { message: 42 }`) could put a
non-string message on the wire; the CLI renderer then crashed on
`message.split`. Keep each field only when it is actually a string.

The ROUTES # column rendered a missing/zero response count as `0`; match the app
reporter (and this file's SPIES/STUBS CALLS column) by showing `-` instead.

* Apply suggestion from @davidr-cy
…o --test (#34381)

* feat: render the spec-level reporter overview when tap reporter has no --test

* feat: render a tap command's details prose in its standalone help

Adds an optional `details` field to the tap command schema and swaps it in
for the one-line `description` when rendering `<command> --help`, matching
what buildNativeProgram already does for CLI-native commands. The reporter
command uses it to explain both of its modes.

Also updates the cypress-instances contract spec for the reporter's
now-optional --test.

* feat: advertise a tap command's details prose in the instance schema

TapManager.getSchema hand-builds the advertised schema from the command
registry and was dropping the new details field, so an attached CLI fell
back to the one-line description. Carry it through, and assert every
command's details round-trips in the getSchema spec.

* feat: flatten the tap spec overview's suites into joined-title sections
* feat: Guard DOM instrospection mid run on tap

* feat: Update RUN_IN_PROGRESS error messages to use a shared constant

Refactor the error handling in the tap command to utilize the new TAP_RUN_IN_PROGRESS_MESSAGE constant for consistency. This change updates the error messages thrown during a running spec to provide clearer guidance to users. Additionally, tests have been updated to reflect the new message format.
davidr-cy and others added 16 commits August 6, 2026 13:27
* feat: add the tap element-selectors binding command

Adds a hidden `element-selectors` command to the tap binding: given a CSS
selector it returns a selector unique to each element that matched, each
carrying the index of the match it names, derived through the driver's own
Cypress.ElementSelector — the generator behind the Selector Playground. Routing
through the driver rather than porting a generator into the CLI means the
derived selectors honor whatever selectorPriority the project configured, so
what tap hands back is the selector the project's own tests would use.

The command reaches the app under test through a new getElementSelectorSource()
seam on the data source, keeping commands off runner globals. A shadow-scoped
result (`:host > …`) is omitted rather than returned, since it resolves against
no document and would fail the moment it was passed back to a command. Because
a match can be omitted, each entry carries its own index rather than relying on
its position in the list.

Matching nothing is an answer, not a failure, so a caller can tell "no
candidates exist" from "the app under test could not be reached". A selector
the browser itself rejects is the one case that is a failure, reported as
INVALID_SELECTOR.

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* chore: drop the native-query comment from getElementSelectorSource

Review feedback: the comment explained a choice the call already reads, and forward-referenced an --at flag that arrives in a later slice.

* fix: cap how many matches tap element-selectors derives

Deriving a unique selector tests candidates against the whole document as it
walks up from the element, so deriving one per match for a selector as broad as
`*` would hold the app's main thread for the size of the page. Cap it, and say
on the result contract that the list is best effort.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: report tap element-selectors matches with no selector as null

Dropping a match no unique selector could be derived for left the caller to
infer it from a gap in the indexes. Keep its entry and say so with a null
selector — the match is still there and --at still reads it. Lower the derive
cap to 10 while here.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* refactor: rename the tap element-selectors command to resolve-selector

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3
… the CLI (#34487)

* fix: bound every tap CDP call so an unresponsive renderer cannot hang the CLI

A CDP reply carries no timer of its own, and the only thing that settles a
pending one other than a matching reply is the browser-level socket closing. So
a page target that stops answering left every tap command that talks to it
pending forever — `status`, `reporter`, `dom`, `aria` and `inspect` each hung
with zero bytes on both streams while `instances` reported `browserAttached:
true`, pointing the caller back at the call that hangs.

Bound the calls by wrapping the CRI client once, where the session hands it out,
rather than at each call site — a protocol call added later is covered without
having to remember to wrap it. Hitting a bound is now RENDERER_UNRESPONSIVE at
exit 1, and `--timeout` raises it for a renderer that is legitimately slow.

The runner-page scan keeps its own shorter bound so a target that cannot answer
falls out of the scan instead of stopping it, and it remembers that it skipped
one: a page that never answered is a different failure from a browser holding no
runner page, and only the former is worth waiting longer on.

`instances` gains `rendererResponsive` so the command that still answers when
everything else is wedged can say which of the two states it is in, without
changing what `browserAttached` has always meant.

* fix: report the tap CDP timeout instead of swallowing it

An expired bound reached `throwTapError`, which wrapped it in a known
error whose renderer prints only the canned description — so a wedged
renderer read as "the browser may have just closed". Rethrowing the
coded error at that funnel also lets the runner-page scan, `instances`
and `status` recognize it, makes `inspect` fail rather than exit 0 with
a partial element, and gives `instances` the `--timeout` it documents.

* refactor: name the tap find-instance timeout for what it finds

"discovery" is dead vocabulary in this repo — the instance contract is
spelled "instance" (@packages/cypress-instances, /__cypress/instances/).

* docs: drop discovery from the tap agent guides

The find-instance vocabulary is what the code uses; a convention doc still
saying discovery keeps regenerating the term it bans.

* refactor: bound tap CDP calls at the client's one send seam

chrome-remote-interface generates every domain shorthand as a call to
client.send, so replacing that single method bounds every protocol call the
session makes — commands, the flat `Domain.method` form, and the raw-client
calls the frame extractors issue — without a Proxy pair, a domain cache, or a
thenable check. Events and close don't route through send, so the two things
that must stay unbounded are excluded structurally rather than by inspection.

Derive the call and find-instance bounds where withTapSession uses them
instead of in a cdpBounds factory, and replace the factory's unit tests with
behavioral ones: a never-answering probe now has to reject after advancing
only FIND_INSTANCE_TIMEOUT_MS, proving it uses the short default rather than
the call bound.

* refactor: drop the tap session's bound-rationale comments

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* fix: print tap coded failures on stderr

An agent driving `cypress tap` reads stdout as the machine-readable channel:
`--json` prints the result there, and a failure is never rendered as JSON. Yet
`logger.error` is `console.log`, so every coded failure landed on stdout too,
leaving a `--json` consumer to parse a line of human error prose. It also split
tap against itself — build-program.ts already reported its own argument errors
with `console.error`.

`logger` gains an `errorToStderr` rather than moving `logger.error`, whose
stream every other CLI command shares; it records into the same `logs` buffer,
so what `logger.print()` collects is unchanged. Results and help stay on stdout.

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* fix: name the targeted instance in the tap unresponsive message

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3
#34485)

* feat: read one element per tap dom/aria/inspect call, chosen with --at

dom, aria, and inspect took a selector and quietly read whatever it matched:
dom concatenated every match, aria and inspect silently took the first. On a
real app most selectors match more than one element, so a reader could not tell
whether what came back was the element they meant.

Each of the three now counts the matches before reading. Exactly one match
reads as before; several answer with the count and how to narrow it, in place
of the read — not an error, since "which one did you mean?" is a legitimate
answer that still exits 0 and honors --json. A new --at <index> names which
match to read, so an ambiguous selector never needs rewriting to make progress.

The count comes from a browser-side querySelectorAll().length, so it costs one
number however heavy the page. With the read now scoped to a single element,
dom drops its per-match concatenation and renders that element's markup on its
own — dedented, since outerHTML keeps the document's indentation and a nested
element would otherwise arrive ragged. The frame URL comes off all three
results: it identified which frame a multi-match read came from, and there is
no longer a multi-match read.

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* refactor: share the tap ambiguity guard as withAmbiguous

Review feedback. dom, aria, and inspect each opened with the same four lines:
resolve the match, hand back the ambiguity answer if there is one, otherwise
read. withAmbiguous takes the read as a callback instead, so the guard is
written once and a new selector-taking command cannot open without it.

resolveMatch loses its doc block along the way: its signature and early returns
already read as what it decides.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: exit 1 when a tap selector matched more than one element

An ambiguous selector on `tap dom`/`aria`/`inspect` printed how many elements it
matched and exited 0, so a caller that only checks the exit code believed it had
read the element it asked for. It had not: the read never ran.

The exit code now says so. The answer itself is unchanged — same rendering, same
`--json` payload, still on stdout — because it names the matches to choose
between, and that is what the caller needs to retry. Collapsing it into a coded
`renderFailure` would have bought convention at the cost of the payload: stdout
empty under `--json`, leaving nothing to pick from.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: match tap index and limit flags instead of coercing them

Number() turns a blank or whitespace-only --at into 0, so a missing or
malformed flag read as index 0. It also accepts '0x10', '1e3', '  7  ',
'+3' and a single-element array for both --at and the limit flags.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: reject tap index and limit flags outside the safe-integer range

A digit-only value still overflows: past 2^53 Number rounds it, and far
enough past that it returns Infinity, which would have disabled the very
caps --max-chars and --max-nodes exist to enforce.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* refactor: tighten the tap ambiguity guard's naming and messages

Rename `resolveMatch` to `resolveAmbiguity` — it answers whether the
selector is ambiguous, not which element matched. Drop the rationale
clause from the selector-less `at` error and phrase the out-of-range
error the way the app-side attempt error does.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* refactor: name the tap index flag as --at in its messages

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3
* fix: handle manually pinned command

* feat: add test for tap's captured DOM retention during command eviction

This update introduces a new test case to ensure that tap's captured DOM is preserved when its replaced command is evicted before the --clear option is applied. The test verifies that the original DOM is restored correctly, enhancing the reliability of the tap command's behavior in managing DOM snapshots.

* refactor: simplify comments and enhance clarity in tap binding and snapshot handling

This commit refines comments across various files related to tap binding and snapshot management. It clarifies the behavior of pinned snapshots, particularly those created manually in the reporter, and improves the readability of the code by removing redundant explanations. Additionally, it updates the handling of command arguments in the tap manager to use named flags consistently, enhancing usability and clarity.

* feat: add tests for tap pin command behavior and enhance snapshot handling

This commit introduces new tests to verify the behavior of the tap pin command when the selected snapshot changes in the UI and when a reporter pin replaces a tap pin. It ensures that the correct snapshot state is reported and that tap's captured DOM is preserved during command eviction. Additionally, it refines the logic for distinguishing between tap and reporter pins, improving the overall reliability of the tap command's snapshot management.

* feat: enhance attempt selection in tap commands by including attempt number

This update modifies the attempt selection logic to return the attempt number alongside the selected attempt. The changes ensure that the pin command accurately reflects the current attempt context, improving the reliability of snapshot management and command behavior in the tap interface.

* Apply suggestions from code review

Co-authored-by: David Rowe <95636404+davidr-cy@users.noreply.github.com>

---------

Co-authored-by: David Rowe <95636404+davidr-cy@users.noreply.github.com>
* fix: capture the live page for a pin that follows no hover

`_pinSnapshot` never stored the original state, so the only thing that ever
captured the live page was the reporter's hover path. Every pin that arrives
without a hover in front of it — the tap CLI, or a click landing faster than the
50ms hover debounce — therefore left the app with nothing to restore, and
unpinning stranded the AUT on the snapshot. Pinning over a hover preview was
worse: the tap pin command detached the previewed snapshot as if it were the
live page, ready to restore it as one.

Capture in `_pinSnapshot` when the app holds no original state, which is also
the invariant the tap pin command hand-rolled by carrying a detached DOM of its
own. `detachedId` now moves with the pin as well, so the deferred restore of a
preview the pin replaced cannot put the live page back over it and drop the
capture with it.

Adds the first coverage for `IframeModel`.

* refactor: derive the tap pin from the app's snapshot store

The pin command tracked its own pin beside the app's snapshot store, so pin
identity had two sources of truth to reconcile on every command: `currentPin`
merging the two, `reconcilePin` dropping a stale record, an unpin listener, and
`current === pinned` ownership checks guarding every mutation. The only thing
the record held that the store could not answer was the live DOM to restore,
which the app now captures for itself.

Read the pin off the store on every command instead. A pin (or an unpin) made in
the reporter between two tap commands is then accounted for by construction, a
release is the app's own unpin, and a stale pin needs no reconciling because the
store resets when a run starts. Comparing resolved log ids replaces the command
id and attempt equality checks, which also makes `--at` move a pin made by hand
in the reporter rather than replacing it.

`pin` no longer reaches for the AUT iframe, so its NO_AUT guard goes with the
seam: unreachable behind the runner guard, and the code stays reachable — and
covered — through resolve-selector.

* refactor: drop the tap-pin comment from the app's pin snapshot

* fix: key the tap pin move on the test as well as the log id
The tap render specs import strip-ansi but cli/package.json never listed it,
so it resolved off the hoisted root copy and knip failed the health check with
15 unlisted-dependency errors. Pinned to 6.0.1 to match @packages/errors;
yarn.lock already carries that exact key.
* fix: read tap test state without serializing the run (AW-97)

`tap status` and `tap reporter` built their payloads from
`runner.getAllTestsState()`, which serializes every test and every retry attempt
— and `serializeTest` runs `LogUtils.toSerializedJSON` over every command, hook,
route and agent log, DOM-stringifying element references, invoking consoleProps
builders and deep-cloning to strip circular refs.

Measured on cypress-realworld-app's transaction-feeds spec (20 tests, 982 log
entries) that call takes ~155s, synchronously, on the renderer main thread. Every
later CDP call queues behind it, so the whole tap surface hangs and the app UI and
AUT freeze with it. `tap reporter` paid it twice, once directly and once through
aggregateResults.

Neither payload reads the logs. Status needs states and a count; the spec
overview needs each test's own properties. Two accessors serve exactly that:
getAllTestStates for the counts and getAllTestsSummary for the props-only
serialization. Both measure 0ms on that spec, with identical counts, and
`getAllTestsSummary` carries no log entries at all.

`reporter --testId` is unchanged: it goes through getTestState, serializes the one
test it renders, and its logs are the thing being displayed.

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* refactor: withhold the serializing test accessor from the tap runner seam

Nothing in tap reads `getAllTestsState` now that status counts states and the
spec view takes the props-only summary, so the seam stops exposing it. A command
that reaches for it fails to compile rather than quietly costing a spec's worth of
log serialization, which makes the two "did not call it" assertions redundant —
the seam's own test asserts it is withheld instead.

The driver keeps the method: it is public API, covered by its own e2e spec, and
read directly by the cypress-in-cypress rerun spec.

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* refactor: drop the tap state-cost rationale comments

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: stub the split test-state accessors in the reporter-pin runners

The reporter-pin stubs landed on trunk while this branch split
getAllTestsState into getAllTestStates and getAllTestsSummary, so they
still stub the accessor the runner seam no longer exposes and run-state
reads undefined off them.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3
…ctor (#34505)

* feat: offer a unique selector for each match of an ambiguous tap selector

When dom, aria, or inspect answer an ambiguous selector, the count alone leaves
the reader to work out how to name the element they meant. The answer now
carries a unique selector for each match as well, so the numbered table it
prints re-runs the read either way: --at <index>, or the selector itself.

The selectors come from the instance's own element-selectors binding command
rather than a generator ported into the CLI, so they honor whatever
selectorPriority the project configured and read as selectors the project's own
tests would use. Best effort: an instance that cannot reach its app under test —
a secondary origin inside cy.origin — or that fails outright leaves the match
count to speak for itself, and the answer still comes back.

Each entry carries its own index rather than relying on its position in the
table, since a match no unique selector could be derived for is omitted and the
rows would otherwise number a different element than --at would read.

Claude-Session: https://claude.ai/code/session_01LpSubVAQugpnrBCWySAHsJ

* fix: keep every ambiguous tap match in the numbered list

Review feedback. A match no unique selector could be derived for was dropped
from the table, so the list read as if only some of the matches existed even
though --at reads any of them. Every match now takes a row, with a faded `-`
standing in for the selector it lacks — which also retires the special case for
no selectors at all, since that is now just a table of dashes.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* refactor: move the tap option parsers into lib/tap/utils

Review feedback. parseIndex and parsePositiveInt parse raw commander option
strings — nothing about them is the AUT frame, and only the commands import
them. They move to lib/tap/utils alongside their test, so aut/frame is left
with the frame resolution and the lifecycle gate it is named for.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: point the tap ambiguity guard at the resolve-selector contract

The command it asks for a selector per match is `resolve-selector`, whose
matches keep their entry with a `null` selector where none could be
derived — a null reached `quoted`, which threw on it.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* test: give the withResolvedAutFrame ambiguity fixtures their selectors

The ambiguity result grew a `selectors` list here, so the fixtures the
parent's withResolvedAutFrame specs build need one too — the human
renderer reads it.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: bound the tap ambiguity list at the cap the instance derives to

The numbered list was built from the total match count, so a broad
selector on a real page — `tap dom --selector '*'` — allocated and
printed a row per match. Every row past the derivation cap could only
ever be a bare index anyway, so stop numbering there and say what the
list leaves out, since --at still reads any of the matches.

MAX_DERIVED_SELECTORS moves to the shared contract so the app derives
and the CLI numbers to the same number rather than two copies of 10.
--json is untouched: it carries the true count and does not build a row
per match.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: match the tap index and limit flags in lib/tap/utils too

The parsers moved out of aut/frame with their Number() coercion intact, so
'', '  7  ', '0x10' and a single-element array were accepted again. Carry
the match-don't-coerce fix and its malformed-input list across with them.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: reject the moved tap parsers' unsafe integers too

Carries the safe-integer guard onto lib/tap/utils, where the parsers now
live, so both sides of the move reject the same class of value.

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* refactor: name the tap index flag as --at in the moved parsers

Claude-Session: https://claude.ai/code/session_017pwk3j9LmPkmZyVhL288a3

* fix: say why an ambiguous tap match has no unique selector

A dash in the selector column told the caller nothing about what to do
next. Name the two things that put it there: a selector priority the
ElementSelector config can change, or an element no document-scoped CSS
selector reaches.

* fix: only blame the selector config when the instance said it derived nothing

An empty selector list means the lookup never answered — the instance was
unreachable, or could not reach its app under test. The dashes it leaves
behind say nothing about why any one match has no selector, so pointing
the reader at their ElementSelector config was advice for a cause that
had not been established. Gate the note on a null the instance actually
reported.
* feat: give tap CLI options short aliases

The `alias` field on `TapCommandOptionSchema` was already rendered by
`declareOptions`, but no command set one, so every tap option was long-form
only. Claim a letter on the options that get typed by hand, following the
`cypress open --help` convention:

  -t --testId      -c --commandId    -a --attempt
  -s --selector    -m --max-chars/--max-nodes
  -d --depth       -i --instance

`--timeout`, `--json`, `--at` and `pin --clear` stay long-form: every single
letter left for them collides within a command that already uses it, and
`-t` is worth more to `--testId`.

Commander silently lets the last declaration of a repeated short flag win,
so a spec asserts each command claims a letter at most once.

* refactor: spell the tap id options --test-id/--command-id

`--testId` and `--commandId` were the only camelCased flags in the CLI;
every other multi-word option (`--config-file`, `--reporter-options`,
`--max-chars`) is kebab-cased. Rename them in the shared contract, which is
also their wire key, so the app's handlers read them under the same spelling.

`forwardedOptions` already resolved a dashed schema name to commander's
camelCased attribute, so nothing new was needed to parse them.

* test: finish the id option rename in the command --json test
…ration

# Conflicts:
#	packages/server/lib/server-base.ts
#	packages/server/test/unit/open_project_spec.ts
Runs the main platform workflows on feat/tap-cli-integration and lets it
persist build artifacts, so the branch publishes an installable pre-release
binary while the tap CLI is being reviewed. Revert before merging.
CircleCI halts the setup job for a draft PR, so the branch would build no
pre-release binaries until the PR leaves draft. Revert with the allowlist
commit before merging.
Comment thread cli/lib/tap/build-program.ts Outdated
const commandIdField = { name: 'command-id', alias: 'c', type: 'string', description: 'command id, as listed by the reporter command — a row number (test body first when duplicated), an e-prefixed event id, or hook-qualified like "h1:3"' } as const
const attemptField = { name: 'attempt', alias: 'a', type: 'number', required: false, description: '1-based attempt (attempt 1 = first run); defaults to the latest' } as const
const selectorField = { name: 'selector', alias: 's', type: 'string', required: false, description: 'a CSS selector; omit to read the whole document' } as const
const selectorField = { name: 'selector', alias: 's', type: 'string', required: false, description: 'a CSS selector; omit to read the entire document' } as const

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const selectorField = { name: 'selector', alias: 's', type: 'string', required: false, description: 'a CSS selector; omit to read the entire document' } as const
const selectorField = { name: 'selector', alias: 's', type: 'string', required: false, description: 'a CSS selector; omit to read the entire document from root' } as const

what is root? body?

// also changes what the command returns: nothing is withheld from a payload
// that is not being rendered for reading room.
{ name: 'json', type: 'boolean', required: false, description: 'print the raw JSON result instead of the human-readable renderingevery console property in full, however long, rather than the long ones named by their length' },
{ name: 'json', type: 'boolean', required: false, description: 'print the raw JSON result instead of the human-readable rendering. This will output every console property in full regardless of length.' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we had limits with props outputs. I have noted 1000 string chars and 10000 dom characters?

const commandMeta = {
name: 'command',
description: 'detail one command log entry of a test — its reporter row, the DOM snapshots pinnable on it, and its console properties',
description: 'detail one command log entry of a test — outputs the reporter row, the DOM snapshots pinnable on it, and any associated console properties',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this reads really odd, especially detail one command log entry of a test

your terminal. Pass --test-id <id> (test ids come from the spec overview this
same command prints with no --test-id) to see one
test's full story: its network routes, the hooks that ran, the complete
description: 'render a view of the test runner reporter, optionally limited to a specific test',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: 'render a view of the test runner reporter, optionally limited to a specific test',
description: 'retrieve the test runner reporter for the spec or a specific test',

stats and every suite's tests including their IDs.

Provide a --test-id <id> to see one
test's full story: network requests, the hooks that ran, the complete

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

specifying network requests, is misleading because it's cypress events.

description: 'inspect the element a selector matches: its tag, attributes, computed styles, box model, and accessibility node',
details: `Inspects the element the selector matches: its tag, attributes, curated
computed styles, box model, and accessibility node.
description: 'inspect a single element matching a CSS Selector: outputs the tag, attributes, computed styles, box model, and accessibility node',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: 'inspect a single element matching a CSS Selector: outputs the tag, attributes, computed styles, box model, and accessibility node',
description: 'retrieve the tag, attributes, computed styles, box model, and accessibility node of a specific element',

what is an accessibility node in this description? like the node id? Thought aria was specifically for pulling the accessiblity node?

details: `Inspects the element the selector matches: its tag, attributes, curated
computed styles, box model, and accessibility node.
description: 'inspect a single element matching a CSS Selector: outputs the tag, attributes, computed styles, box model, and accessibility node',
details: `Inspects a single element matching a CSS Selector: outputs the tag, attributes, computed styles, box model, and accessibility node.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidr-cy why is the selector required for inspect but not for aria or dom? why wouldn't this fallback to the same "root"?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess inspect didn't seem very useful if you're looking at the body.

Co-authored-by: Emily Wisniewski (Rohrbough) <emilyrohrbough@yahoo.com>
Base automatically changed from feat/tap-cli-integration to develop August 14, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants