# @leaflink/dom-testing-utils

> Leaflink repository to manage test utilities to be shared across front-end applications.

[![version](https://img.shields.io/npm/v/@leaflink/dom-testing-utils.svg)](http://npm.im/@leaflink/dom-testing-utils)
[![downloads](https://img.shields.io/npm/dm/@leaflink/dom-testing-utils.svg)](http://npm-stat.com/charts.html?package=@leaflink/dom-testing-utils&from=2015-08-01)
[![MIT License](https://img.shields.io/npm/l/@leaflink/dom-testing-utils.svg)](http://opensource.org/licenses/MIT)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

- [@leaflink/dom-testing-utils](#leaflinkdom-testing-utils)
  - [Installation](#installation)
  - [Releases](#releases)
  - [Usage](#usage)
    - [Setup file](#setup-file)
    - [Global setup](#global-setup)
  - [Utilities](#utilities)
    - [`cleanupNoty`](#cleanupnoty)
    - [`waitForLoading`](#waitforloading)
    - [`cleanupDropdowns`](#cleanupdropdowns)
    - [`assertAndDismissNoty`](#assertanddismissnoty)
    - [`getByDescriptionTerm`](#getbydescriptionterm)
    - [`getAllByDescriptionTerm`](#getallbydescriptionterm)
    - [`getSelectedOption`](#getselectedoption)
    - [`getSelectedOptions`](#getselectedoptions)
    - [`createFixtureGenerator`](#createfixturegenerator)
    - [Mocking API Endpoints](#mocking-api-endpoints)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

## Installation

**Requirements:** Node.js >= 24

```sh
pnpm add -D @leaflink/dom-testing-utils
```

## Releases

Releases are fully automated using [semantic-release](https://github.com/semantic-release/semantic-release). When changes are pushed to a release branch (`main`, `alpha`, `beta`, `canary`, `next`, `next-major`, or `[0-9]+.x`), the CI workflow:

1. Builds the package with `pnpm build`
2. Analyzes commits to determine the next version (using [Conventional Commits](https://www.conventionalcommits.org/))
3. Publishes to npm and creates a GitHub release with changelog

**Commit types that trigger releases:** `feat`, `fix`, `perf`, and `revert`. Other types (e.g. `docs`, `chore`) appear in changelogs but do not bump the version on their own.

For detailed commit conventions and manual release instructions, see [CONTRIBUTING.md](.github/CONTRIBUTING.md#semantic-release).

## Dependency updates

Dependency updates are handled by self-hosted [Renovate](https://docs.renovatebot.com/), invoked from `.github/workflows/renovate.yml` using a PAT (`RENOVATE_PAT`) issued from the `leaflink-automation` account. Configuration lives in [`.github/renovate.json5`](.github/renovate.json5). Tracking ticket: [MKPL-1055](https://leaflink.atlassian.net/browse/MKPL-1055).

### Schedule

- Workflow cron wakes Renovate hourly.
- Renovate only opens PRs between **1am–5am America/New_York**, every day.
- A new release must be **≥ 10 days old** (`minimumReleaseAge`) before Renovate will propose it — buffer against compromised/withdrawn publishes.
- Merging is handled by a separate workflow (see [Auto-merge](#auto-merge)) that runs whenever required CI completes — independent of Renovate's schedule.

### Update groups and auto-merge policy

| Source | Update type | PR shape | Auto-merge |
|---|---|---|---|
| `devDependencies` | any | grouped as one PR (`chore(deps-dev): update dev dependencies`) | ✅ |
| `dependencies` / `peerDependencies` | **patch** | individual PR (`chore(deps): ...`) | ✅ |
| `dependencies` / `peerDependencies` | **minor / major** | individual PR (`chore(deps): ...`) | ❌ awaits review |
| `github-actions` | patch / minor / digest / pin | individual PR | ✅ |
| `github-actions` | major | individual PR | ❌ awaits review |
| Lockfile maintenance (transitive refresh of `pnpm-lock.yaml`) | weekly | `Refresh ... (chore(deps-dev))` | ✅ |

Renovate PR bodies all link back to MKPL-1055 via `prBodyNotes`.

### Auto-merge

`main` requires `quality-checks` + `release` status checks and a CODEOWNERS review. The `leaflink-automation` account (whose PAT Renovate uses) is in `main`'s `bypass_pull_request_allowances`, but Renovate itself won't call the merge API while the PR shows as `BLOCKED` in GitHub's API — even though the bypass would let it through.

To work around this, `.github/workflows/renovate-merge.yml` performs the merge on Renovate's behalf:

1. Renovate creates the PR. Any rule with `automerge: true` adds `addLabels: ['automerge']`, so the label is the single source of truth for "Renovate intended to auto-merge this."
2. `PR Quality Checks` and `Release` run on the PR.
3. When either completes, the `Renovate Merge` workflow is triggered via `workflow_run`. It:
   - filters to PRs authored by `leaflink-automation` with the `automerge` label,
   - asserts both required checks are present and successful,
   - calls the merge API authenticated as `leaflink-automation`.
4. Because the user is on the bypass list, the merge call succeeds despite `BLOCKED` status.

Human PRs are unaffected — they still require CODEOWNERS review.

The required-checks list in `renovate-merge.yml` (`REQUIRED_CHECKS`) is hard-coded and must stay in sync with branch protection on `main`.

### Where to look

- **Dependency Dashboard issue** (auto-created by Renovate, label `dependencies`) — single source of truth for what Renovate sees, what it skipped, and why.
- **Workflow runs** for `Renovate` in the Actions tab — `workflow_dispatch` with `logLevel: debug` to debug Renovate itself.
- **Workflow runs** for `Renovate Merge` in the Actions tab — shows which PRs were merged or skipped (with reason) when CI completed. `workflow_dispatch` for manual recovery.
- **`config:recommended`** is the base preset; any unspecified behavior comes from there.

## Usage

In your test files you can import utility functions.

```ts
import {
  waitForLoading,
  cleanupDropdowns,
  assertAndDismissNoty,
  cleanupNoty,
  createFixtureGenerator,
} from '@leaflink/dom-testing-utils';

it('...', () => {
  cleanupNoty();
});
```

### Setup file

Import `@leaflink/dom-testing-utils/setup-env` once (for instance in your tests setup file) and you're good to go:

> **Note:** `@testing-library/jest-dom` is auto-imported from `@leaflink/dom-testing-utils` so you don't have to.

```ts
// In your own setup-env.ts (or any other name)
import '@leaflink/dom-testing-utils/setup-env'
// DON'T import `@testing-library/jest-dom` is auto imported from dom-testing-utils

// In vite.config.ts add (if you haven't already)
setupFiles: ['tests/setup-env.js'],

// In jest.config.js add (if you haven't already)
setupFilesAfterEnv: ['<rootDir>/tests/setup-env.js']
```

This will be run once before _each_ test file. See <https://vitest.dev/config/#setupfiles>.

### Global setup

Add the following import to your test config:

```js
// In vite.config.ts add
globalSetup: ['node_modules/@leaflink/dom-testing-utils/dist/global-setup.js'],

// In jest.config.js add
globalSetup: ['<rootDir>/node_modules/@leaflink/dom-testing-utils/dist/global-setup.js']
```

This will run once _before everything_. See <https://vitest.dev/config/#globalsetup>.

## Utilities

### `cleanupNoty`

Helper method to remove all noty alerts from the DOM.

**Parameters**: None

**Returns**: `void`

### `waitForLoading`

Utility that waits for all loading elements to be removed from the DOM. The `data-test` argument defaults to `ll-loading` **or** `loading-spinner` if `testId` is not specified.

| **Parameters** | **Type** | **Default**                       | **Summary**                                         |
| -------------- | -------- | --------------------------------- | --------------------------------------------------- |
| testId         | `string` | `ll-loading` && `loading-spinner` | The data test ID to target.                         |
| timeout        | number   | 2000                              | How long to wait for loading elements to be removed |
| failIfNull     | boolean  | false                             | Throws an error if no loading elements are found    |

**Returns**: `Promise<void>`

Will resolve if the loaders get removed before the timeout. Otherwise, will throw an error if the loaders are still in the DOM by the end of the timeout.

Setting `failIfNull` to `true` will cause an error to be thrown if no loading spinners are initially found in the DOM.

### `cleanupDropdowns`

Helper method to remove all floating Stash Dropdown elements from the DOM.

**Parameters**: None

**Returns**: `void`

### `assertAndDismissNoty`

Helper to assert and manually dismiss a notification. This is useful in scenarios where cleanupNoty() does not work as expected, such as when validating error messages in test suites.

| **Parameters** | **Type** | **Default** | **Summary**                 |
| -------------- | -------- | ----------- | --------------------------- |
| text           | `string` | _Required_  | Expected notification text. |

**Returns**: `void`

### `getByDescriptionTerm`

Finds the first HTML element with the role "definition" (DD) that matches the specified text for the description term.

| **Parameters** | **Type**           | **Default** | **Summary**                             |
| -------------- | ------------------ | ----------- | --------------------------------------- |
| text           | `string \| RegExp` | _Required_  | Expected description term text or regex |

**Returns**: `HTMLElement | undefined` - The first matching description detail element or undefined if no match is found.

### `getAllByDescriptionTerm`

Queries and returns an array of HTML elements with the role "definition" (DD) that matches the specified text of a description term.

| **Parameters** | **Type**           | **Default** | **Summary**                                                                             |
| -------------- | ------------------ | ----------- | --------------------------------------------------------------------------------------- |
| textMatch      | `string \| RegExp` | _Required_  | The text to match within the HTML elements. It can be a string or a regular expression. |

**Returns**: `HTMLElement[]` - An array of HTML description detail elements that match the given text.

### `getSelectedOption`

Finds the first selected HTML element with the role "definition" (LI) "listitem" inside the specified select element.

| **Parameters** | **Type**          | **Default**   | **Summary**                                                |
| -------------- | ----------------- | ------------- | ---------------------------------------------------------- |
| element        | HTMLSelectElement | _Required_    | Stash Select element to be checked.                        |
| selectedClass  | string            | 'is-selected' | Selected class added on selected items                     |
| options        | ByRoleOptions     | null          | `getAllByRole()` options values using `ByRoleOptions` type |

**Returns**: `HTMLElement | undefined` - The first selected HTML listitem element or undefined if no match is found.

### `getSelectedOptions`

Finds all the selected HTML elements with the role "definition" (LI) "listitem" inside the specified select element.

| **Parameters** | **Type**          | **Default**   | **Summary**                                                |
| -------------- | ----------------- | ------------- | ---------------------------------------------------------- |
| element        | HTMLSelectElement | _Required_    | Stash Select element to be checked.                        |
| selectedClass  | string            | 'is-selected' | Selected class added on selected items                     |
| options        | ByRoleOptions     | null          | `getAllByRole()` options values using `ByRoleOptions` type |

**Returns**: `HTMLElement[]` - An array of selected HTML listitem elements.

### `createFixtureGenerator`

Higher order function that takes a method whose responsibility is to create a **single** data fixture object and returns a new generator function that allows you to create 1 or more of those fixtures. Fixture generator function that's returned supports passing optional `num` and `overrides` params.

| **Parameters** | **Type**   | **Default** | **Summary**                                             |
| -------------- | ---------- | ----------- | ------------------------------------------------------- |
| `fixtureFn`    | `function` | _Required_  | Method that generates and returns a single data object. |

**Returns**

```ts
(num?, overrides?) => Array<{[key: string]: any}> | {[key: string]: any}
// OR
(overrides?) => {[key: string]: any}
```

A new generator function that accepts a number & overrides where:

- `num` = The number of fake data objects to generate. Defaults to 1
- `overrides` = Specific attributes you want to override in each data fixture object.

When calling the returned function, you'll get an array OR object of fixture data (It will be a ** single object** if `num = 1`).

**Examples**

Quick example:

```ts
const generateInvoice = (overrides) => ({
  id: uuid(),
  balance: 15799,
  classification: 'Adult Use',
  ...overrides,
});
const generateInvoices = createFixtureGenerator(generateInvoice);

generateInvoices();
// => Single invoice object

generateInvoices(1);
// => Single invoice object

generateInvoices(1, { foo: 'bar' });
// => Single invoice object, override `foo` to equal `'bar'`

generateInvoices({ foo: 'bar' });
// => Single invoice object, override `foo` to equal `'bar'`

generateInvoices(10);
// => Array of 10 invoice objects

generateInvoices(10, { foo: 'bar' });
// => Array of 10 invoice objects, override `foo` to equal `'bar'` in each
```

Full example:

```ts
// tests/fixtures/products.ts
import { faker } from '@faker-js/faker';
import { createFixtureGenerator } from '@leaflink/dom-testing-utils';

export const generateProduct = (overrides = {}) => ({
  sku: git.commitSha(),
  name: faker.commerce.productName(),
  quantity: faker.random.number(100),
  cases: faker.random.number(10),
  ...overrides,
});

export default createFixtureGenerator(generateProduct);

// services/api/products.ts
import generateProducts from '@/tests/fixtures/products';

// ...
const mockProducts = generateProducts(10, { cases: 25 });
// ...
```

### Mocking API Endpoints

In order to mock API endpoints that your tests interact with, you can get a set of mocking functions from `createMockApiUtils`.

```ts
import { createMockApiUtils } from '@leaflink/dom-testing-utils';
import yourServer from './server.ts';

const {
  mockGetData,
  mockGetEndpoint,
  mockPatchData,
  // etc.
} = createMockApiUtils(yourServer);
```

There are two flavors of mocking utility functions:

1. mock{VERB}Data - mocks response with a singular data object
2. mock{VERB}Endpoint - mocks a response endpoint with a function like msw's [Response Resolver](https://mswjs.io/docs/getting-started/mocks/rest-api#response-resolver)

To mock an endpoint with simple return data

```ts
mockGetData('/relative-url', myMockObj);
```

or you can customize the response

```ts
mockGetEndpoint('/relative-url', (req, res, ctx) => {
  if (someConditional()) {
    HttpResponse.json({ foo: 'bar' });
  } else {
    HttpResponse.json({ foo: 'baz' });
  }
});
```
