import { Optional } from '../dataTypes'; import { MissingPartError } from '../errors/MissingPartError'; import { PostconditionNotMetError } from '../errors/PostconditionNotMetError'; import { BoundingRect, Point } from '../geometry'; import { ClickOption, FocusOption, HoverOption, Interactor, MouseDownOption, MouseEnterOption, MouseLeaveOption, MouseMoveOption, MouseOutOption, MouseUpOption, PressKeyOption, } from '../interactor'; import type { LocatorRelativePosition, PartLocator } from '../locators'; import { CommutableComponentDriverOption, IComponentDriver, IComponentDriverOption, PartName, ScenePart, ScenePartDriver, } from '../partTypes'; import * as locatorUtil from '../utils/locatorUtil'; import { WaitUntilOption } from '../utils/timingUtil'; import { getPartFromDefinition } from './driverUtil'; import { defaultWaitForOption, WaitForOption } from './WaitForOption'; /** * Base class for all component drivers. It provides the basic functionality to interact with the component */ export abstract class ComponentDriver implements IComponentDriver { private _locator: PartLocator; private readonly _parts: ScenePartDriver; /** * The component-agnostic slice of the constructor option that is safe to share * across the whole driver tree — everything the constructor received EXCEPT the * component-specific `parts`, which each driver owns for itself. Parent drivers * pass this straight to the constructors of children they create dynamically * (see the list helpers). See {@link CommutableComponentDriverOption}. */ public readonly commutableOption: CommutableComponentDriverOption; /** * @param locator Locator for the root of this component. * @param interactor Environment adapter used for all interactions. * @param option Driver option carrying the shared driver-tree context. * * Composite-driver authoring rule: a driver that declares non-empty `parts` * must type this parameter as `Partial` (i.e. the empty * `<{}>` default) and hardcode its own `parts` in the body — * `super(locator, interactor, { ...option, parts })`. The "natural" * `Partial>` signature does NOT satisfy * `ScenePartDefinition['driver']` (constructor parameters are checked * contravariantly), so a driver written that way could not be placed in a * parent `ScenePart`. Lock a composite driver against this rule in one line with * {@link AssertScenePlaceableDriver}; the rule itself is regression-tested * centrally in `core/src/drivers/__type-tests__` and demonstrated in * `@atomic-testing/component-driver-html`. */ constructor( locator: PartLocator, public readonly interactor: Interactor, option?: Partial> ) { this._locator = locator; this._parts = getPartFromDefinition(option?.parts ?? ({} as T), this._locator, interactor, option ?? {}); // Strip the component-specific `parts` so the shared slice never leaks a // parent's parts to its children — honestly, without the old `parts: {} as T` // cast lie. const { parts: _parts, ...commutable } = option ?? {}; this.commutableOption = commutable; } /** * Portal hook: where to re-root this driver's locator when its component renders * outside the parent's DOM (a modal, popup, drawer). Return the {@link PartLocator} * that locates the component from the document root, or `undefined` (the default) * for normal in-tree components whose locator chains from the parent. * * This is **static** because it is per-class metadata read off the constructor * before any instance exists — which makes the "no instance state" constraint * structural rather than a documented caution. Override with `static override`. * * `option` is the fully-merged constructor option the driver is about to receive * (the same value passed to the driver's own constructor) — a purely static, * per-invocation input, not instance state — so a driver whose portalling is * conditional on how its scene configures it (e.g. an overlay that can render * teleported OR in-tree, such as PrimeVue's `appendTo="self"`) can branch on a * flag there instead of always re-rooting. Ignore it to keep unconditional * portal behavior. */ static overriddenParentLocator(_option?: Partial>): Optional { return undefined; } /** * Portal hook: the locator relative position to apply when the component's real * DOM is a sibling/elsewhere rather than a descendant (e.g. a MUI dialog rendered * at the document root, located by a "Same"-level selector). Return `undefined` * (the default) to keep the natural position declared by the ScenePart. * * Static for the same reason as {@link ComponentDriver.overriddenParentLocator}: * it is class-level metadata read before construction. Override with `static override`. * * See {@link ComponentDriver.overriddenParentLocator} for what `option` carries * and why accepting it does not reintroduce instance state. */ static overrideLocatorRelativePosition( _option?: Partial> ): Optional { return undefined; } /** * Return driver instance of all the named parts */ get parts(): ScenePartDriver { return this._parts; } /** * Return the locator of the component */ get locator(): PartLocator { return this._locator; } /** * The element {@link ComponentDriver.within} resolves an interior scene against — * "inside this component", as *this driver* defines inside. Defaults to * {@link ComponentDriver.locator}, which is already correct wherever a driver's own * locator resolves to the surface holding caller content (Radix/Reka anchor at * `Dialog.Content`, Fluent at `DialogSurface`). * * Override it when the driver's locator resolves to a **wrapper** instead. MUI's * Dialog, Drawer and Menu are the shipped cases: their locator is the portal-rendered * Modal root, whose children are the backdrop, two focus-trap sentinels and a * positioning container. Un-narrowed, an interior there reaches MUI's own chrome, * and a `'Child'`-relative interior part resolves to `.MuiBackdrop-root` rather * than to anything the scene wrote — silently, since a locator that matches the * wrong element raises nothing. * * An override only helps where that chrome is **ancestral to** the caller's content. * Where a design system interleaves chrome *beside* it — Fluent's focus-trap * sentinels are siblings of the dialog body — no anchor separates the two, and the * default stands (ADR-019's rollout-width audit). * * An override MUST resolve to an element containing **everything the caller * supplied**. For a slotted component that means the surface, never one slot: MUI * spreads caller content across `DialogTitle`/`DialogContent`/`DialogActions` as * siblings, so narrowing to `.MuiDialogContent-root` would drop the action buttons * scenes click. Over-narrowing fails the same silent way it fixes — the part just * stops resolving (ADR-019). */ protected get interiorLocator(): PartLocator { return this._locator; } /** * Driver instances for a caller-supplied interior scene, resolved against this * component's {@link ComponentDriver.interiorLocator}. * * The call-time counterpart to {@link ComponentDriver.parts}: `parts` is the * chrome the driver author hardcodes, this is the interior the *scene* author * owns — a dialog's body, a popover's panel, a toast's action area. A * {@link PartLocator} resolves lazily and queries nothing here, so this is * synchronous and safe to call before the interior has mounted. * * This replaced an earlier `ContainerDriver` base whose `content` option * required the same scene to be named twice — once as a type argument, once in * the driver option — plus a laundering constructor in every subclass (ADR-019). * Named `within` rather than `getContent` because leaf drivers already own that * name for reading a component's own text (a badge's content, a tooltip's * content), and a base-class member cannot collide with them. * * Interior children are constructed with an empty option, exactly as `content` * parts always have been: an interior belongs to the scene, so it inherits no * driver-specific configuration from its host. This differs deliberately from * {@link ComponentDriver.parts}, whose children do inherit the host's option. * * @param parts The interior scene to resolve against this component's interior * @returns One driver instance per named part */ within(parts: ContentT): ScenePartDriver { return getPartFromDefinition(parts, this.interiorLocator, this.interactor, {}); } /** * Check the specified parts' existences, and throw MissingPartError if any of the part is found not existence. * Existence is defined by the part's existence in the DOM regardless of its visibility on the screen * @param partName Single or array of the names of the parts to be enforced */ protected async enforcePartExistence(partName: PartName | ReadonlyArray>): Promise { const missingPartNames = await this.getMissingPartNames(partName); if (missingPartNames.length > 0) { throw new MissingPartError(missingPartNames, this); } } /** * Get the names of parts not in the DOM * @param partName Single or array of the names of the parts to be examined * @returns */ protected async getMissingPartNames( partName: PartName | ReadonlyArray> ): Promise[]> { let partNames: ReadonlyArray; if (partName == null) { partNames = Object.keys(this._parts) as ReadonlyArray; } else { partNames = Array.isArray(partName) ? partName : [partName]; } const missingParts: PartName[] = []; const promises = partNames.map(x => { const fn = async () => { const partExists = await this.interactor.exists(this._parts[x]!.locator); if (!partExists) { missingParts.push(x); } }; return fn(); }); await Promise.all(promises); return missingParts; } /** * Get the combined text content of the component * @returns If the component exists and has content, it should return the text or otherwise undefined */ getText(): Promise> { return this.interactor.getText(this.locator); } getAttribute(attributeName: string): Promise> { return this.interactor.getAttribute(this.locator, attributeName); } /** * Whether the component exists/attached to the DOM * @returns true if the component is attached to the DOM, false otherwise */ exists(): Promise { return this.interactor.exists(this.locator); } async click(option?: Partial): Promise { return this.interactor.click(this.locator, option); } async hover(option?: Partial): Promise { return this.interactor.hover(this.locator, option); } // Low-level pointer/keyboard primitives below are `protected` for the 1.0 // freeze. They are inherited by every driver and by the engine root (where // most are meaningless — see #1048), so exposing them publicly would freeze a // large uniform surface that is breaking to narrow later but safe to widen // (ADR-015). Concrete drivers compose them internally to build semantic // actions; the raw gestures stay out of the public API. See #1045. protected async mouseMove(option?: Partial): Promise { return this.interactor.mouseMove(this.locator, option); } protected async mouseDown(option?: Partial): Promise { return this.interactor.mouseDown(this.locator, option); } protected async mouseUp(option?: Partial): Promise { return this.interactor.mouseUp(this.locator, option); } protected async mouseOver(option?: Partial): Promise { return this.interactor.mouseOver(this.locator, option); } protected async mouseOut(option?: Partial): Promise { return this.interactor.mouseOut(this.locator, option); } protected async mouseEnter(option?: Partial): Promise { return this.interactor.mouseEnter(this.locator, option); } protected async mouseLeave(option?: Partial): Promise { return this.interactor.mouseLeave(this.locator, option); } async focus(option?: Partial): Promise { return this.interactor.focus(this.locator, option); } /** * Dispatch a keyboard key press on the component. See {@link Interactor.pressKey} * for the full contract, including modifier-key delivery via {@link PressKeyOption}. * @param key A `KeyboardEvent.key` value, e.g. `'Escape'`, `'Backspace'`, `'Enter'` * @param option Modifier flags and other per-press options — see {@link PressKeyOption} */ async pressKey(key: string, option?: Partial): Promise { return this.interactor.pressKey(this.locator, key, option); } /** * Type text into the component as real per-character keystrokes, inserting at * the current caret without clearing. See {@link Interactor.typeText}. * @param text The literal text to type, one keystroke per character */ async typeText(text: string): Promise { return this.interactor.typeText(this.locator, text); } /** * Dispatch a right-click / `contextmenu` event on the component. See {@link Interactor.contextMenu}. */ protected async contextMenu(): Promise { return this.interactor.contextMenu(this.locator); } /** * Activate the component without relying on pointer geometry. See {@link Interactor.activate}. */ protected async activate(): Promise { return this.interactor.activate(this.locator); } /** * Scroll the component into the viewport. See {@link Interactor.scrollIntoView}. * * jsdom has no layout engine, so the scroll is a no-op there and behavioral * assertions (visibility, offset) are E2E-only. */ async scrollIntoView(): Promise { return this.interactor.scrollIntoView(this.locator); } /** * Scroll the component by the given pixel delta. See {@link Interactor.scrollBy}. * * jsdom has no layout engine, so the scroll is a no-op there and behavioral * assertions (resulting offset) are E2E-only. * * @param delta Pixel offset to scroll by */ protected async scrollBy(delta: Point): Promise { return this.interactor.scrollBy(this.locator, delta); } /** * Drag this component and drop it onto another component. See {@link Interactor.dragTo}. * * Prefer a keyboard-driven `setValue` over a true drag in real drivers — these * drag primitives exist only for cases keyboard cannot express (e.g. panning a * Lightbox, reordering a column). jsdom has no layout engine, so the positional * outcome of the drag is E2E-only there. * * @param target Another driver whose root element is the drop target */ protected async dragTo(target: ComponentDriver): Promise { return this.interactor.dragTo(this.locator, target.locator); } /** * Drag this component by the given pixel delta from its center. See {@link Interactor.drag}. * * Prefer a keyboard-driven `setValue` over a true drag in real drivers — these * drag primitives exist only for cases keyboard cannot express (e.g. panning a * Lightbox, reordering a column). jsdom has no layout engine, so the positional * outcome of the drag is E2E-only there. * * @param delta Pixel offset to drag by */ protected async drag(delta: Point): Promise { return this.interactor.drag(this.locator, delta); } /** * Get this component's bounding rectangle. See {@link Interactor.getBoundingRect}. * * jsdom has no layout engine, so every coordinate and dimension is `0` there; * real geometry is E2E-only. */ protected getBoundingRect(): Promise { return this.interactor.getBoundingRect(this.locator); } /** * Whether the component is visible. Visibility is defined * that the component does not have the CSS property `display: none`, * `visibility: hidden`, or `opacity: 0`. However this does not * check whether the component is within the viewport. * * @returns true if the component is visible, false otherwise */ isVisible(): Promise { return this.interactor.isVisible(this.locator); } /** * Wait until the component is attached and becomes visible to the DOM. * @param timeoutMs The number of milliseconds to wait before timing out. Defaults * to {@link defaultWaitForOption}.timeoutMs so this wait shares a single * flake-tolerance source with {@link waitUntilComponentState} (#1057). */ async waitUntilVisible(timeoutMs: number = defaultWaitForOption.timeoutMs): Promise { return this.waitUntilComponentState({ condition: 'visible', timeoutMs, }); } /** * Wait until the component is in the expected state such as * the component's visibility or existence. If the component has * not reached the expected state within the timeout, it will throw * an error. * * By default it waits until the component is attached to the DOM * within 30 seconds. * * @param option The option to configure the wait behavior */ async waitUntilComponentState(option: Partial> = defaultWaitForOption): Promise { return this.interactor.waitUntilComponentState(this.locator, option); } waitUntil(option: WaitUntilOption): Promise { return this.interactor.waitUntil(option); } /** * Hold an action open until its own postcondition holds, so the action does * not resolve while the DOM it promised is still arriving. * * **Why actions, not reads or assertions.** An interactor settles the * framework's scheduler after a write (React `act()`, Vue `nextTick()`, * Angular `whenStable()`) and then treats the DOM as final. A component that * defers its own DOM work onto a host timer — a `setTimeout` to re-register a * select's options, to restore a picker's section spans — lands *after* that * settle, so the next single-shot read observes a transient state that is * neither the old value nor the new one. Making reads retry cannot fix this * (a read does not know what it is waiting for, and negative reads must stay * fast); making every mutation drain a fixed extra macrotask is a sleep at * framework scale. The action is the only layer that knows what it promised, * so the action is where the wait belongs. * * Probing uses {@link waitUntil}'s escalating intervals, so a postcondition * that already holds costs one probe and no delay. * * @param postcondition Human-readable description of the awaited state, used * verbatim in {@link PostconditionNotMetError}. Phrase it as the state that * must arrive, not the action taken. * @param probeFn Returns true once the postcondition holds. Keep it cheap — * it runs repeatedly. * @param option.timeoutMs Defaults to {@link defaultWaitForOption}.timeoutMs so * every wait in the library shares one flake-tolerance source (#1057). * @throws {PostconditionNotMetError} If the postcondition never holds. Failing * here is deliberate: an action that cannot keep its promise is a real * defect, and reporting it at the action gives a far better diagnostic than * the downstream assertion mismatch it would otherwise become. */ protected async awaitPostcondition( postcondition: string, probeFn: () => Promise | boolean, option?: { readonly timeoutMs?: number } ): Promise { const timeoutMs = option?.timeoutMs ?? defaultWaitForOption.timeoutMs; const met = await this.interactor.waitUntil({ probeFn, terminateCondition: true, timeoutMs, }); if (!met) { throw new PostconditionNotMetError(this, postcondition, timeoutMs); } } /** * Get the inner HTML of the component * @returns The inner HTML of the component */ protected innerHTML(): Promise { return this.interactor.innerHTML(this.locator); } /** * Get the runtime CSS selector of the component. This is useful for debugging and testing purposes. * * @returns The runtime CSS selector of the component */ runtimeCssSelector(): Promise { return locatorUtil.toCssSelector(this.locator, this.interactor); } abstract get driverName(): string; }