Skip to content

Latest commit

 

History

History
171 lines (131 loc) · 6.32 KB

resolver-model.md

File metadata and controls

171 lines (131 loc) · 6.32 KB

Resolver model

The resolver model fixes the real cross-runner problem: eager HTMLElement storage in this._root / this._context. That pattern causes Cypress detached DOM, RTL partial-assignment staleness, and duplicate test object classes for E2E.

Problem: stored elements vs lazy scope

Symptom Vitest / Jest Cypress
Getter runs child query Works at access time Builds command queue before DOM touch
const cell = view.cells[3] then later cell.click() Stale after re-render Detached if _root captured in .then()
new PCO(element) constructor Freezes container cy.wrap(staleElement) breaks retry chain
// BAD — partial assignment captures HTMLElement (today)
const btn = view.submitBtn;
await btn.click(); // may target detached node after re-render

// GOOD — PCOTarget re-resolves on each interaction
await view.submitBtn.userClick();

Three layers

flowchart TB
  subgraph consumer [Consumer __pco__ TO]
    Intent["Intent: fillCheckout()"]
    Queries["Queries: byRole, byLabelText"]
    Actions["Actions: userClick, userType"]
  end
  subgraph core [PCO abstraction]
    Resolver["rootResolver: () => Scope"]
    Context["context: PCOContext"]
    Target["PCOTarget from each query"]
  end
  subgraph adapters [Adapters]
    RTL["RTL: within(freshEl)"]
    CY["Cypress: cy chain"]
    PW["Playwright: Locator"]
  end
  Intent --> Queries
  Queries --> Context
  Context --> Target
  Resolver --> Context
  adapters --> Resolver
Loading
Member Role
rootResolver Sync () => Scope — never stores DOM
context Full TL query API fixed to rootResolver → PCOTarget
root Optional materialization of rootResolver (RTL convenience; Cypress debug only)

Sync resolvers only: ContextResolver = () => Scope. No async resolvers in v1 — Cypress deferral lives inside the chain returned by the resolver.

Partitioned types

/** Base partition — shared TO code + RTL adapter */
interface PCOTargetBase<TNode = unknown> extends PromiseLike<TNode> {
  userClick(opts?: ClickOptions): PCOTargetBase<TNode>;
  userType(text: string, opts?: TypeOptions): PCOTargetBase<TNode>;
  userClear(): PCOTargetBase<TNode>;
}

/** Query partition — what this.context returns */
interface PCOContext extends PCOTargetBase {
  getByRole(...): PCOTarget;
  queryByRole(...): PCOTarget | null;
  findByRole(...): PCOTarget;
  getByLabelText(...): PCOTarget;
  // ... full TL surface
  within(resolver: ContextResolver): PCOContext;
}

/** Cypress runtime merge — not imported in shared *.to.ts */
type PCOTarget = PCOTargetBase & Cypress.Chainable<JQuery<HTMLElement>>;

Shared test objects type against PCOContext / PCOTargetBase. Cypress specs get chain methods at runtime without native().

Child test objects

Both patterns are supported:

// A) Explicit resolver
new RowTO(() => this.context.findByRole('row', { name: /x/i }).rootResolver);

// B) Parent wrapper
this.child(RowTO, (p) => p.context.findByRole('row', { name: /x/i }));

Adapter scope mapping

Adapter Document scope Nested scope
RTL () => screen () => within(parentResolve())
Cypress () => cy / document () => cy.get(...) chain from parent
Playwright (spike) page locator(root)

getBy / queryBy / findBy policy

The framework exposes the full Testing Library surface on shared test objects. Consumers choose per getter.

API RTL adapter (Vitest/Jest/Storybook) Cypress adapter
queryBy* Sync; null if absent Enqueues retry-aware TL command (same as findBy* today)
getBy* Sync throw if absent Enqueues retry-aware TL command (same as findBy* today)
findBy* Async RTL findBy* (waits for appearance) Enqueues retry-aware TL command

On Cypress, getBy*, queryBy*, and findBy* on context all route through @testing-library/cypress — there is no separate “sync snapshot only” path for getBy*. Getters return PCO targets (chainable), not materialized elements for cy.wrap().

On RTL, timing still differs: getBy* throws immediately in JSDOM; findBy* waits. Specs should pick the query variant that matches the behavior under test.

Anti-patterns

// BAD Cypress: captured element as resolver
cy.get('.sidebar').then(($el) => new PCO(() => cy.wrap($el[0])));

// GOOD Cypress: chain as resolver
new PCO(() => cy.get('[data-testid="sidebar"]'));

// BAD: materialize HTMLElement from getter, then wrap for Cypress
get heading() {
  return this.context.getByRole('heading', { name: /items/i }); // used as HTMLElement
}
cy.wrap(view.heading).click();

// GOOD: getter returns a PCO target — chainable, retry-aware on Cypress
get heading() {
  return this.context.getByRole('heading', { name: /items/i });
}
view.heading.should('contain.text', 'Items');
view.heading.userClick();

Runtime guards

Adapters throw PcoUnsupportedInRuntimeError for APIs that do not translate:

Pattern Cypress RTL
this.root.querySelector Throw — use this.context Warn — prefer TL queries
.should() on target N/A Throw — use expect()
cy.wrap(capturedEl) in resolver Throw N/A

Migration from 0.1.x

0.1.x 0.2.x
constructor(root?: HTMLElement) Internal rootResolver = () => root
bindToRoot(el) bindResolver(() => el)
this._root stored Dropped — rootResolver is source of truth
CypressComponentTestObject duplicate class Unified ComponentTestObject + Cypress adapter injection

Jest/Vitest/Storybook demos must pass unchanged behind the RTL adapter before Cypress switches default paths.

Benefits

  • Re-render: tables[1].cells[3].userClick() after intentional re-render
  • Storybook: bindResolver(() => within(canvasElement))
  • One TO class: queries defined once; adapters differ in resolver + action execution
  • Playwright: rootResolver: () => page.getByTestId('sidebar') — see Playwright spike in packages/adapters/playwright

Related