# Testing

> CDK test harnesses for ngwr components — drive a button, a field or a checkbox from your own specs without reaching into ngwr's markup.

Source: https://ngwr.dev/guides/testing

## What a harness is for

A spec that queries `.wr-btn__label` by hand is a spec that breaks when the library moves a `<span>`. A harness is the supported way to drive a component: it exposes what the control DOES — click it, read its label, ask whether it is disabled — and absorbs the markup underneath. These are [Angular CDK component harnesses](https://material.angular.dev/cdk/testing/overview), so if you have used Material's, these behave identically.

They ship beside the components they drive, one entry point each, and they are not part of the runtime bundle — importing `ngwr/button/testing` from a spec pulls nothing into your app.

```typescript
// The harnesses live beside the components they drive, one entry point each.
// 34 so far: every form control, every overlay, both data views, the whole
// navigation / disclosure set, and <wr-markdown>.
import { WrButtonHarness } from 'ngwr/button/testing';
import { WrInputHarness } from 'ngwr/input/testing';
import { WrCheckboxHarness } from 'ngwr/checkbox/testing';
import { WrRadioGroupHarness } from 'ngwr/radio/testing';
import { WrFormFieldHarness } from 'ngwr/form/testing';

// The overlay ones — panels that render outside your fixture.
import { WrSelectHarness } from 'ngwr/select/testing';
import { WrDialogHarness } from 'ngwr/dialog/testing';
import { WrContextMenuHarness } from 'ngwr/context-menu/testing';
import { WrCommandPaletteHarness } from 'ngwr/command-palette/testing';

// The data views come as families.
import { WrTableHarness, WrTableRowHarness } from 'ngwr/table/testing';
import { WrTreeHarness, WrTreeNodeHarness } from 'ngwr/tree/testing';

// The environment comes from the CDK, which is already a peer dependency.
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
```

## Your first harness test

Load a harness from the fixture, then talk to it.

```typescript
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TestBed } from '@angular/core/testing';
import { WrButtonHarness } from 'ngwr/button/testing';

it('saves the form', async () => {
  const fixture = TestBed.createComponent(CheckoutPage);
  fixture.detectChanges();

  const loader = TestbedHarnessEnvironment.loader(fixture);
  const save = await loader.getHarness(WrButtonHarness.with({ text: 'Save' }));

  expect(await save.isDisabled()).toBe(true);   // nothing filled in yet
});
```

Every harness method is `async`. That is the CDK's contract, not a quirk of ngwr: the same harness code has to work in a unit test and in a real browser over WebDriver, where nothing can be read synchronously. Await each call and you never need `detectChanges()` between them — the environment flushes for you.

## Finding the right one

`with()` narrows the query.

```typescript
// Every harness ships a `with()` predicate. A string is an exact match, a
// RegExp is tested, and several options AND together.
await loader.getHarness(WrButtonHarness.with({ text: 'Save' }));
await loader.getAllHarnesses(WrButtonHarness.with({ text: /^S/ }));
await loader.getAllHarnesses(WrButtonHarness.with({ disabled: true }));

await loader.getHarness(WrCheckboxHarness.with({ label: 'I agree' }));
await loader.getHarness(WrInputHarness.with({ placeholder: 'Email' }));
await loader.getHarness(WrSwitchHarness.with({ label: 'Dark mode', on: false }));
```

`getHarness` throws when nothing matches, which is usually what you want in a spec — a silent `null` turns into a confusing failure three lines later. Use `getAllHarnesses` when zero is a legitimate answer, and `getHarnessOrNull` when you mean to check.

## A form, end to end

```typescript
it('enables Save once the form is valid', async () => {
  const loader = TestbedHarnessEnvironment.loader(fixture);

  const email = await loader.getHarness(WrInputHarness.with({ placeholder: 'Email' }));
  const terms = await loader.getHarness(WrCheckboxHarness.with({ label: 'I agree' }));
  const save = await loader.getHarness(WrButtonHarness.with({ text: 'Save' }));

  await email.setValue('ada@example.test');
  await terms.check();

  expect(await save.isDisabled()).toBe(false);
});
```

`setValue` dispatches `input` and then `change`. The first is the one that carries the value: ngwr's value controls are Signal Forms native and listen to it, and so does Angular's `DefaultValueAccessor`, which is what `[(ngModel)]` and a reactive form bind through — its listeners are `input`, `blur` and the two composition events, and `change` is not one of them. `change` follows for your own `(change)` handler, which a browser would defer to the commit that a harness write never reaches.

## Components that render into an overlay

A dialog, a select panel or a toast is not inside your fixture — it lands in the overlay container, which is a sibling of it. Load those from the document root:

```typescript
// A component that renders into an overlay is NOT inside the fixture, so load
// it from the document root instead.
const rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture);

it('confirms before deleting', async () => {
  await (await loader.getHarness(WrButtonHarness.with({ text: 'Delete' }))).click();

  const dialog = await rootLoader.getHarness(WrDialogHarness);
  expect(await dialog.getTitleText()).toBe('Delete item');

  // A dialog is a content CONTAINER: harnesses resolve inside it, so a second
  // dialog's buttons can't be picked up by mistake.
  await (await dialog.getHarness(WrButtonHarness.with({ text: 'Confirm' }))).click();

  const toast = await rootLoader.getHarness(WrToastHarness.with({ type: 'success' }));
  expect(await toast.getMessage()).toBe('Item deleted');
});
```

Two loaders, one fixture: `loader()` for your own template, `documentRootLoader()` for anything the overlay owns. A harness looked up in the wrong one simply does not find its component, which is why the error you get is "failed to find element" rather than something about overlays.

Which is which follows the component, not the panel. A dialog, a toast and a service-opened drawer have no element in your template at all, so they come from `documentRootLoader()`. A select, a date picker, a dropdown, a popover, a context menu, a popconfirm, a cascader and a mention ARE elements in your template — the normal `loader()` finds them, and the harness reaches into the overlay for you, scoped to that one instance by the id its trigger publishes. Two of them open at once cannot answer for each other; a hand-written `document.querySelector('.wr-select-panel')` can, and quietly will.

## Driving a select

Options live in the panel, not in the select.

```typescript
it('picks a size', async () => {
  const select = await loader.getHarness(WrSelectHarness);

  await select.open();
  expect(await select.getOptionLabels()).toEqual(['Small', 'Medium', 'Large']);

  await select.selectOption({ text: 'Medium' });
  expect(await select.getValueText()).toBe('Medium');
});

it('filters as you type', async () => {
  const select = await loader.getHarness(WrSelectHarness);

  await select.open();
  await select.typeSearch('la');

  // Filtered-out options stay in the DOM and collapse via CSS — the harness
  // drops them, so this is the list a user can actually reach.
  expect(await select.getOptionLabels()).toEqual(['Large']);
});

it('builds up a multi selection', async () => {
  const select = await loader.getHarness(WrSelectHarness);

  await select.selectOption({ text: 'Small' });
  await select.selectOption({ text: 'Large' });
  expect(await select.getChipLabels()).toEqual(['Small', 'Large']);

  await select.removeChip('Small');
  expect(await select.getChipLabels()).toEqual(['Large']);
});
```

`WrSelectHarness` itself is found with the normal fixture loader — it is an element in your template. Only its options are in the overlay, and the harness reaches them for you, scoped by the id the trigger publishes as `aria-controls`. That scoping is not decoration: the overlay container is shared, so a query by class would happily answer with a different select's options.

## WrSelectHarness

`ngwr/select/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `open() / close()` | `open()` throws rather than resolving quietly when no panel appears — a tag-mode select has none, and a `minChars` select opens on the query. | `Promise<void>` | `—` |
| `isOpen()` | Whether the panel is showing. | `Promise<boolean>` | `—` |
| `getValueText()` | The trigger's current selection: the chip labels joined by `', '`, the single label, or the search input's text. | `Promise<string>` | `—` |
| `getPlaceholder()` | The placeholder, or `null` when a selection is hiding it. | `Promise<string \| null>` | `—` |
| `getOptions(filters?)` | The options a user can actually reach — filtered-out ones are dropped. Throws while the panel is closed. Filters: `text`, `selected`, `disabled`. | `Promise<WrOptionHarness[]>` | `—` |
| `getOptionLabels()` | Those options as plain strings, in DOM order. | `Promise<string[]>` | `—` |
| `selectOption(filters)` | Open if needed, then click the first matching option. | `Promise<void>` | `—` |
| `typeSearch(query)` | Replace a search or tag select's query. | `Promise<void>` | `—` |
| `getChipLabels() / removeChip(label)` | The visible chips in multi and tag modes. The `+N more` overflow chip is not one of them. | `Promise<string[]> \| Promise<void>` | `—` |
| `clear()` | Click the clear (×) control. | `Promise<void>` | `—` |
| `getNoResultsText() / isLoading()` | The panel's empty and async-loading rows. | `Promise<string \| null> \| Promise<boolean>` | `—` |
| `isMultiple() / isDisabled() / focus()` | Mode and state. | `Promise<boolean> \| Promise<void>` | `—` |

## WrOptionHarness

`ngwr/select/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getText()` | The option's label, trimmed. | `Promise<string>` | `—` |
| `isSelected() / isDisabled() / isActive()` | `isActive` is the keyboard cursor, not focus — a virtualized panel moves it with `aria-activedescendant`. | `Promise<boolean>` | `—` |
| `isHidden()` | Whether a search query filtered this option out. It stays in the DOM so registration order survives. | `Promise<boolean>` | `—` |
| `click()` | Click the option. | `Promise<void>` | `—` |

## WrDialogHarness

`ngwr/dialog/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getTitleText() / getContentText()` | The `[wrDialogTitle]` and `[wrDialogContent]` text, or `null` when the dialog projects neither. | `Promise<string \| null>` | `—` |
| `getHarness(…)` | Inherited from `ContentContainerComponentHarness` — resolves any harness INSIDE this dialog, so a stacked dialog cannot answer instead. | `Promise<T>` | `—` |
| `getRole() / isModal()` | Set on the OVERLAY element, not on your component — a consumer looking for them on their own host would not find them. | `Promise<string \| null> \| Promise<boolean>` | `—` |
| `isClosable() / getCloseLabel() / close()` | The built-in dismiss button. `close()` throws on a dialog opened `closable: false`. | `Promise<boolean> \| Promise<string \| null> \| Promise<void>` | `—` |
| `sendEscape()` | Press Escape. A dialog opened `closeOnEscape: false` ignores it — assert, do not assume. | `Promise<void>` | `—` |
| `isFocusTrapped()` | Whether focus is inside the dialog, where the trap should hold it. | `Promise<boolean>` | `—` |

## WrToastHarness

`ngwr/toast/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getMessage() / getTitle()` | The two text lines. `getTitle()` is `null` for a toast shown without one. | `Promise<string> \| Promise<string \| null>` | `—` |
| `getType()` | The intent, from the `wr-toast--*` modifier. | `Promise<WrToastType \| null>` | `—` |
| `getRole() / getLiveLevel()` | How urgently the toast announces itself: `alert` / `assertive` for danger, `status` / `assertive` for warning, `status` / `polite` otherwise. | `Promise<string \| null>` | `—` |
| `isDismissible() / dismiss()` | The close button. `dismiss()` throws on a toast shown `dismissible: false`. | `Promise<boolean> \| Promise<void>` | `—` |
| `hasCopyAction() / copy()` | The copy button, present only with `showCopy: true`. | `Promise<boolean> \| Promise<void>` | `—` |
| `hasProgressBar()` | Whether the auto-dismiss bar is showing — it needs both `showProgress` and a non-zero duration. | `Promise<boolean>` | `—` |
| `hover() / mouseAway()` | Hovering is what pauses the auto-dismiss timer. | `Promise<void>` | `—` |

## WrButtonHarness

`ngwr/button/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getText()` | The button's visible label, trimmed. | `Promise<string>` | `—` |
| `isDisabled()` | Whether the button refuses interaction. Reads both `disabled` and `aria-disabled`, and answers `true` for a loading button. | `Promise<boolean>` | `—` |
| `isLoading()` | Whether the spinner is showing. | `Promise<boolean>` | `—` |
| `getColor()` | The intent modifier, matched against `WR_COLORS`. `null` when the button carries none. | `Promise<WrColor \| null>` | `—` |
| `click()` | Click the button. | `Promise<void>` | `—` |
| `focus()` | Move keyboard focus to it. | `Promise<void>` | `—` |
| `isFocused()` | Whether it currently has focus. | `Promise<boolean>` | `—` |

## WrInputHarness

`ngwr/input/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getValue()` | The current value. | `Promise<string>` | `—` |
| `setValue(value)` | Type a value in. `input` carries the value — signal forms AND `[(ngModel)]` both listen to that one. `change` follows it for a consumer's own `(change)` handler, which a browser would only fire on commit. | `Promise<void>` | `—` |
| `clear()` | Empty the field, same events. | `Promise<void>` | `—` |
| `getPlaceholder()` | The placeholder text. | `Promise<string>` | `—` |
| `isDisabled()` | Whether the field is disabled. | `Promise<boolean>` | `—` |
| `isReadonly()` | Whether the field is read-only. | `Promise<boolean>` | `—` |
| `isInvalid()` | Whether `aria-invalid` is set — what a screen reader is told, not what the model thinks. | `Promise<boolean>` | `—` |
| `getTagName()` | `'input'` or `'textarea'`. | `Promise<string>` | `—` |
| `focus() / blur() / isFocused()` | Focus management. | `Promise<void> \| Promise<boolean>` | `—` |

## WrCheckboxHarness

`ngwr/checkbox/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getLabel()` | The projected label, trimmed. | `Promise<string>` | `—` |
| `isChecked()` | Whether the box is ticked. | `Promise<boolean>` | `—` |
| `isIndeterminate()` | Whether it is in the third state. Read from the DOM property, which is where that state lives. | `Promise<boolean>` | `—` |
| `isDisabled()` | Whether the box is disabled. | `Promise<boolean>` | `—` |
| `getCheckboxValue()` | The group identity — `checkboxValue`, not the form value. | `Promise<string \| null>` | `—` |
| `toggle() / check() / uncheck()` | `check` and `uncheck` are no-ops when the box is already in that state. | `Promise<void>` | `—` |
| `focus() / isFocused()` | Focus lands on the real control inside the label. | `Promise<void> \| Promise<boolean>` | `—` |

## WrSwitchHarness

`ngwr/switch/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getLabel()` | The projected label, trimmed. | `Promise<string>` | `—` |
| `isOn()` | Whether the switch is on. | `Promise<boolean>` | `—` |
| `isDisabled()` | Whether the switch is disabled. | `Promise<boolean>` | `—` |
| `getRole()` | `'switch'` — the difference between this control and a checkbox. | `Promise<string \| null>` | `—` |
| `toggle() / turnOn() / turnOff()` | `turnOn` and `turnOff` are no-ops when the switch is already there. | `Promise<void>` | `—` |
| `focus() / isFocused()` | Focus management. | `Promise<void> \| Promise<boolean>` | `—` |

## Driving a dropdown

Three ways in, and only one of them is a click.

```typescript
it('renames from the menu', async () => {
  const menu = await loader.getHarness(WrDropdownHarness.with({ text: 'Actions' }));

  await menu.open();
  expect(await menu.getItemTexts()).toEqual(['Rename', 'Duplicate', 'Delete']);

  // Focus starts on the first ENABLED item and the arrows skip the rest.
  expect(await menu.getFocusedItemText()).toBe('Rename');

  await menu.clickItem({ text: 'Rename' });
  // Picking does not close the menu — there is no close-on-select.
  expect(await menu.isOpen()).toBe(true);
});
```

`open()` hovers and then clicks, so it works whatever `trigger` is set to — which is what you want most of the time. When the point of the test IS the mode, drive the single gestures (`hoverTrigger()`, `clickTrigger()`) and assert that the other one does nothing.

## WrDropdownHarness

`ngwr/dropdown/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `open() / openByKeyboard() / close()` | `open()` hovers then clicks, so it works whatever the `trigger` mode is. `openByKeyboard()` is the third route — the component takes ArrowDown in every mode. | `Promise<void>` | `—` |
| `isOpen() / getTriggerText()` | The trigger publishes `aria-controls` only while its menu is up, which is what `isOpen()` reads. | `Promise<boolean> \| Promise<string>` | `—` |
| `getItems(filters?) / getItemTexts()` | The menu is in the overlay, scoped to this dropdown by its menu id. Filters: `text`, `disabled`. | `Promise<WrDropdownItemHarness[]> \| Promise<string[]>` | `—` |
| `clickItem(filters)` | Open if needed, then click the first match. Picking does NOT close the menu — there is no close-on-select. | `Promise<void>` | `—` |
| `getFocusedItemText()` | Where the roving focus is now. It starts on the first ENABLED item and the arrows step over disabled ones. | `Promise<string \| null>` | `—` |
| `getMenuRole() / isMenuLabelledByTrigger()` | The menu announces `menu` and names itself from the trigger. | `Promise<string \| null> \| Promise<boolean>` | `—` |
| `clickTrigger() / hoverTrigger() / mouseAwayFromTrigger()` | One gesture at a time, when you want to assert that a mode ignores the other. | `Promise<void>` | `—` |

## WrDropdownItemHarness

`ngwr/dropdown/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getText()` | The label, without a leading icon. | `Promise<string>` | `—` |
| `getRole() / isDisabled() / isFocused()` | Role, state and whether the roving focus is on it. | `Promise<string \| null> \| Promise<boolean>` | `—` |
| `hasIcon() / getIconName()` | The leading icon, if the item has one. | `Promise<boolean> \| Promise<string \| null>` | `—` |
| `click()` | Click the item. | `Promise<void>` | `—` |

## Driving a date picker

A field, a calendar grid and a time stepper.

```typescript
// A picker needs a date adapter, the same one your app provides.
TestBed.configureTestingModule({
  providers: [provideWrOverlay(), provideWrDateAdapter({ locale: 'en-US' })],
});

it('picks a departure date', async () => {
  const picker = await loader.getHarness(WrDatePickerHarness);

  await picker.open();
  expect(await picker.getPanelHeader()).toBe('January 2025');
  expect(await picker.getWeekdayLabels()).toEqual(['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']);

  await picker.selectDay(20);
  expect(await picker.getValueText()).toBe('20.01.2025');
});

it('reads the state of a day cell', async () => {
  const picker = await loader.getHarness(WrDatePickerHarness);
  await picker.open();

  const day = await picker.getDay(15);
  expect(await day.isSelected()).toBe(true);
  expect(await day.isToday()).toBe(false);

  // selectDay() refuses a disabled cell instead of clicking into the void.
  await expect(picker.selectDay(5)).rejects.toThrow(/disabled/);
});

it('sets a time and steps it', async () => {
  const picker = await loader.getHarness(WrDatePickerHarness);   // mode="datetime"
  await picker.open();

  await picker.setTime({ hours: 9, minutes: 30 });
  await picker.stepTime('minutes', 1);
  expect(await picker.getTime()).toBe('09:31');
});
```

Day cells are addressed by their number, and the harness answers with a cell harness rather than a boolean soup: selected, disabled, today, in-range and the roving tab stop are separate questions, and conflating the last two is the mistake worth guarding against — a range picker has two selected ends and only one tab stop.

## WrDatePickerHarness

`ngwr/date-picker/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `open() / close() / isOpen()` | `close()` toggles this picker's own trigger rather than sending Escape, which would go to whichever overlay opened last. | `Promise<void> \| Promise<boolean>` | `—` |
| `getValueText() / setValueText(text) / clear()` | The text in the field. Typing is how a user enters a date, so `setValueText` goes through the input. | `Promise<string> \| Promise<void>` | `—` |
| `getPanelHeader() / getView() / getWeekdayLabels()` | `getView()` is `day` / `month` / `year`; the header reads `January 2025`, `2025` or `2016 – 2027` to match. | `Promise<string> \| Promise<string[]>` | `—` |
| `next() / previous() / zoomOut()` | One step means what the nav button says it means: a month in the day view, a year in the month view, twelve years in the year view. | `Promise<void>` | `—` |
| `selectMonth(month) / selectYear(year)` | Pick from the zoomed-out views. A month takes a 0-based index or the label the locale renders. | `Promise<void>` | `—` |
| `getDays(filters?) / getDay(n) / selectDay(n)` | `selectDay` refuses a disabled cell instead of clicking into the void. Filters: `text`, `selected`, `disabled`, `inRange` — today and the out-of-month padding are questions you ask a cell harness, not the query. | `Promise<WrDatePickerDayHarness[]> \| Promise<void>` | `—` |
| `getTime() / setTime(fields) / stepTime(unit, ±1) / toggleMeridiem()` | The `time` and `datetime` modes only. Ordering settles on blur, not per keystroke. | `Promise<string> \| Promise<void>` | `—` |
| `getMode() / isDisabled() / isReadonly() / focus() / blur() / isFocused()` | Mode and field state. A readonly picker still opens; a readonly RANGE picker does not. | `Promise<…>` | `—` |

## WrDatePickerDayHarness

`ngwr/date-picker/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getDayOfMonth() / getText()` | The number the cell shows. | `Promise<number> \| Promise<string>` | `—` |
| `isSelected() / isDisabled() / isToday() / isInRange()` | Cell state, read from ARIA and the `wr-calendar__day--*` modifiers. | `Promise<boolean>` | `—` |
| `isOutOfMonth()` | Whether this is a neighbouring month's padding cell — those are real, clickable days. | `Promise<boolean>` | `—` |
| `isActive()` | The roving tab stop, which is NOT the same as selected. At most one cell answers true, and after a month step none does. | `Promise<boolean>` | `—` |

## WrDateRangePickerHarness

`ngwr/date-picker/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getStartText() / getEndText() / getSeparator()` | The two fields and the separator between them. | `Promise<string>` | `—` |
| `setStartText(text) / setEndText(text) / clear()` | Type into one end without disturbing the other. | `Promise<void>` | `—` |
| `focus(end) / blur(end) / isFocused(end) / getPlaceholder(end)` | Every per-end method takes `'start'` or `'end'`. | `Promise<…>` | `—` |
| `getTime(end) / setTime(end, fields) / stepTime(end, unit, ±1) / toggleMeridiem(end)` | A datetime range renders one stepper per end — stepping one must not drag the other with it. | `Promise<…>` | `—` |
| `Everything on the day grid` | The panel methods above are shared: both pickers extend the same base. | `—` | `—` |

## Popovers and tooltips

Same directive, two shapes.

```typescript
it('explains itself on hover', async () => {
  const tip = await loader.getHarness(WrPopoverHarness.with({ mode: 'tooltip' }));

  await tip.open();                       // hover, focus or click — whichever this one takes
  expect(await tip.getContentText()).toBe('Save changes');
  expect(await tip.getRole()).toBe('tooltip');

  await tip.close();
});
```

Both shapes have show and hide delays, so `open()` and `close()` wait for the panel to actually arrive or go instead of handing you the frame before. That is also why the harness reads the mode from ARIA rather than from the `mode` input: a bound `[mode]` never reaches the DOM, and the mode decides which gesture opens it and which attribute names the panel.

## WrPopoverHarness

`ngwr/popover/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getMode()` | Read from ARIA, not from the `mode` input: a bound `[mode]` never reaches the DOM. It gates what `open()` does and which attribute names the panel. | `Promise<'popover' \| 'tooltip'>` | `—` |
| `open(timeout?) / close(timeout?)` | Performs the gesture this instance takes, then WAITS: both modes have show / hide delays, so an immediate assertion would read the frame before. | `Promise<void>` | `—` |
| `waitUntilOpen(timeout?) / waitUntilClosed(timeout?)` | The wait on its own, for when you drove the gesture yourself. | `Promise<void>` | `—` |
| `click() / hover() / mouseAway() / focus() / blur() / sendEscape()` | One gesture, no waiting — assert that a click-mode popover ignores a hover. | `Promise<void>` | `—` |
| `getContentText() / getDescriptionText()` | The panel text. A tooltip also names its trigger through `aria-describedby`, which is what a screen reader reads — `getDescriptionText()` follows that reference. | `Promise<string> \| Promise<string \| null>` | `—` |
| `getRole() / getLabel() / isModal()` | `tooltip` in tooltip mode, `dialog` in popover mode. | `Promise<…>` | `—` |
| `getPosition() / isSheet()` | The placement modifier, and whether it collapsed to a bottom sheet on a small viewport. | `Promise<WrPopoverPosition \| null> \| Promise<boolean>` | `—` |

## Drawers and sheets

Element or service — one harness.

```typescript
it('closes the drawer', async () => {
  const drawer = await rootLoader.getHarness(WrDrawerHarness);

  expect(await drawer.getTitleText()).toBe('Filters');
  expect(await drawer.getPosition()).toBe('end');
  expect(await drawer.isLabelledByTitle()).toBe(true);

  await drawer.close();
});
```

A drawer can be an element in your template or opened from `WrDrawerManager`; either way the pane lands in the overlay, so the harness comes from `documentRootLoader()`. The role, the modality and the `aria-labelledby` link are written onto that pane rather than onto your markup — looking for them on your own host is why they seem to be missing.

## WrDrawerHarness

`ngwr/drawer/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `isOpen()` | Whether this drawer's pane is attached to the document — a disposed pane is not open. | `Promise<boolean>` | `—` |
| `getTitleText() / getContentText()` | The `[wrDrawerTitle]` and `[wrDrawerContent]` text. | `Promise<string \| null>` | `—` |
| `getPosition() / isSheet() / isRounded() / hasSafeArea() / hasHandle()` | Which edge it came from and how it is presented. | `Promise<…>` | `—` |
| `getRole() / isModal() / isLabelledByTitle()` | Written onto the OVERLAY element by both flavours, not onto your markup. `isLabelledByTitle()` resolves the reference rather than trusting that one is present. | `Promise<…>` | `—` |
| `isClosable() / getCloseLabel() / close() / sendEscape()` | `close()` throws on a drawer opened `closable: false`; Escape is a separate opt-out. | `Promise<…>` | `—` |
| `hasBackdrop() / clickBackdrop()` | The backdrop belonging to THIS drawer, not whichever one is on top. | `Promise<boolean> \| Promise<void>` | `—` |
| `isFocusTrapped()` | Whether focus is inside the drawer, where the trap should hold it. | `Promise<boolean>` | `—` |

## Driving a table

A family of harnesses, because a table is a tree.

```typescript
it('sorts, selects and expands', async () => {
  const table = await loader.getHarness(WrTableHarness);

  expect(await table.getHeaderTexts()).toEqual(['Name', 'Role']);
  expect(await table.getCellTexts()).toEqual([
    ['Ada', 'admin'],
    ['Grace', 'user'],
  ]);

  await table.sortByColumn('Name');
  expect(await table.getSortDirection('Name')).toBe('ascending');

  const [first] = await table.getRows();
  await first.select();
  expect(await first.isSelected()).toBe(true);
  expect(await table.isPartiallySelected()).toBe(true);

  await first.toggleExpand();
  expect(await table.getDetailTexts()).toEqual(['Joined 2024']);
});

it('announces a tree', async () => {
  const table = await loader.getHarness(WrTableHarness);   // childrenKey set

  expect(await table.getRole()).toBe('treegrid');

  const [root] = await table.getRows();
  expect(await root.getLevel()).toBe(1);
  expect(await root.isExpandable()).toBe(true);
});
```

Columns are addressed by their header title — the `columns` key never reaches the DOM, so it would be a name only your spec could see. Two caveats worth knowing before you assert: sorting publishes an intent and does not reorder `items` (your app does that), and a virtualized body renders a WINDOW, so `getRows()` is the window while `getAriaRowCount()` is the total.

## WrTableHarness

`ngwr/table/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getRole() / isTree()` | A flat table sets no role and announces the native `table`. `childrenKey` makes it a `treegrid` — and that role is the only place the hierarchy exists for a screen reader; the indent is decoration. | `Promise<'treegrid' \| 'table'>` | `—` |
| `getHeaderCells(filters?) / getHeaderTexts()` | Columns are addressed by their header TITLE — the `columns` key never reaches the DOM. The selection and expand headers are not columns. | `Promise<WrTableHeaderCellHarness[]> \| Promise<string[]>` | `—` |
| `sortByColumn(title) / getSortDirection(title)` | One step of none → ascending → descending. `<wr-table>` publishes the intent and never reorders `items` itself, so a spec expecting rows to move has to sort the data too. | `Promise<void> \| Promise<…>` | `—` |
| `getRows(filters?) / getCellTexts()` | The rows RENDERED, in order — which for a virtualized body is the window, not the dataset. Group bands, subtotals, detail rows and the empty row are not rows. | `Promise<WrTableRowHarness[]> \| Promise<string[][]>` | `—` |
| `hasSelectAll() / isAllSelected() / isPartiallySelected() / toggleSelectAll()` | Its scope is the render list: a collapsed group's rows are out, a virtualized table's off-window rows are IN. While the selection is partial the box reads unchecked, so one click selects everything. | `Promise<…>` | `—` |
| `getGroupLabels() / isGroupCollapsed(label) / toggleGroup(label)` | The built-in bands. A `[wrTableGroupHeader]` template replaces the label with your own markup, and then this list is empty. | `Promise<…>` | `—` |
| `getDetailTexts()` | Each open detail row. A detail row sits NEXT TO its row, so it is neither a row nor one of its cells. | `Promise<string[]>` | `—` |
| `getFooterTexts() / getEmptyText() / isLoading() / isVirtual() / getAriaRowCount()` | `isVirtual()` answers what actually happened: `virtualScroll` is a request the table drops whenever the layout stops being uniform. | `Promise<…>` | `—` |

## WrTableRowHarness

`ngwr/table/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getCells(filters?) / getCellTexts()` | The row's cells, lead cells excluded so they line up with the header list. | `Promise<WrTableCellHarness[]> \| Promise<string[]>` | `—` |
| `isSelectable() / isSelected() / toggleSelection() / select() / deselect()` | `select` and `deselect` are no-ops when the row is already there. | `Promise<…>` | `—` |
| `isExpandable() / isExpanded() / toggleExpand()` | Both the `[wrTableExpand]` detail row and a tree row's children. | `Promise<…>` | `—` |
| `getLevel() / getPosInSet() / getSetSize() / getRowIndex()` | What a tree row announces. `aria-setsize` counts the sibling set INCLUDING this row, and `aria-posinset` is per sibling group, not per flat list. | `Promise<number \| null>` | `—` |

## WrTableCellHarness / WrTableHeaderCellHarness

`ngwr/table/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getText()` | The cell text, whitespace collapsed. | `Promise<string>` | `—` |
| `getColumnTitle()` | Which column this cell belongs to — useful when a filter or a drag has moved the columns. | `Promise<string \| null>` | `—` |
| `getPin()` | Whether the column is pinned, and to which edge. | `Promise<'left' \| 'right' \| null>` | `—` |
| `Header cells add: isSortable() / sort() / getSortDirection() / isFilterable()` | `isFilterable()` needs a NON-EMPTY `filterItems` — an empty array renders no control. | `Promise<…>` | `—` |

## Radio groups

The value lives on the group, not the radio.

```typescript
it('answers the size question', async () => {
  const group = await loader.getHarness(WrRadioGroupHarness);

  expect(await group.getRadioLabels()).toEqual(['Small', 'Medium', 'Large']);
  expect(await group.getSelectedLabel()).toBeNull();

  // Nothing is picked yet, so the tab stop is option one — NOT the selection.
  expect(await group.getTabStopLabel()).toBe('Small');

  await group.select({ label: 'Large' });
  expect(await group.getSelectedLabel()).toBe('Large');
  expect(await group.getTabStopLabel()).toBe('Large');
});
```

The one thing worth pinning in a radio spec is that the tab stop and the selection are different questions. A group with nothing picked yet is still reachable by keyboard — its first enabled option carries the tab stop — and once an option is picked the tab stop moves onto it. A harness that answered "the checked one" for both would look right in every test and be wrong for every keyboard user.

## WrRadioGroupHarness / WrRadioHarness

`ngwr/radio/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getRadios(filters?) / getRadioLabels()` | The options in DOM order, scoped to THIS group. Filters: `label`, `value`, `checked`, `disabled`. | `Promise<WrRadioHarness[]> \| Promise<string[]>` | `—` |
| `getSelectedRadio() / getSelectedLabel() / select(filters)` | `select()` throws if the option is still unchecked after the click — which is what a disabled option does, silently. | `Promise<…>` | `—` |
| `getTabStopLabel() / focusTabStop() / getFocusedLabel()` | The roving tab stop is the CHECKED option, or the first enabled one while the question is unanswered — not the same thing, and the difference is what a keyboard user feels. | `Promise<…>` | `—` |
| `getName()` | Read off the radios, because that is where a bound `[name]` lands. A literal `name="size"` also survives on the group element — do not "simplify" a lookup into that trap. | `Promise<string \| null>` | `—` |
| `getAccessibleName()` | `aria-labelledby` first, then `aria-label` — the order the name computation uses. They are not interchangeable. | `Promise<string \| null>` | `—` |
| `getRole() / isDisabled()` | `radiogroup`. The group counts as disabled only when every option is. | `Promise<…>` | `—` |
| `One radio: getLabel() / getValue() / isChecked() / isDisabled() / getSize() / hasIcon() / isLabelBound() / check() / focus() / blur() / isFocused()` | `getLabel()` is the text only — an icon lives in the dot, and a consumer icon carrying a `<title>` would otherwise join the label. | `Promise<…>` | `—` |

## WrTextareaHarness

`ngwr/textarea/testing`

For the `<wr-textarea>` component. A native `<textarea wrInput>` is the `[wrInput]` directive instead, and `WrInputHarness` covers that one.

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getValue() / setValue(text) / clear()` | The `<textarea>` value never reaches its text content, so this is the property. A write refuses on a disabled or readonly field instead of pretending. | `Promise<string> \| Promise<void>` | `—` |
| `getLabel() / getPlaceholder()` | `getLabel()` answers `null`, not `''`, for a field with neither an `ariaLabel` nor a placeholder. | `Promise<string \| null> \| Promise<string>` | `—` |
| `isDisabled() / isReadonly() / isInvalid()` | Read off the native element, where the form and a screen reader read them. A native `aria-invalid="false"` wins over one on the wrapper rather than falling through to it. | `Promise<boolean>` | `—` |
| `isAutosizing() / hasFittedHeight() / getRows() / getSize() / getResizeDirection()` | Autosize is reported as the handover it is: whether the component has written a height at all. jsdom has no layout, so a harness that claimed to measure one would be lying. | `Promise<…>` | `—` |
| `focus() / blur() / isFocused()` | Focus lands on the native element inside the wrapper. | `Promise<…>` | `—` |

## Numbers, and the gap between text and value

```typescript
it('clamps at the maximum', async () => {
  const qty = await loader.getHarness(WrInputNumberHarness);

  await qty.setValue(3);
  expect(await qty.getValue()).toBe(3);

  await qty.increment();                    // step is 1, max is 4
  await qty.increment();
  expect(await qty.getValue()).toBe(4);
  expect(await qty.isIncrementDisabled()).toBe(true);
});

it('separates the field text from the value', async () => {
  const price = await loader.getHarness(WrInputNumberHarness);

  await price.setValueText('1 234,5');      // mid-type, not committed
  expect(await price.getValueText()).toBe('1 234,5');
  expect(await price.getValue()).toBe(1234.5);
});
```

A number field shows a string and holds a number, and mid-type the two disagree — a separator, a prefix, a lone minus sign. So the harness asks them separately: `getValueText()` is what a user sees and `getValue()` is the number, which THROWS rather than guessing when the field holds something the control has not accepted. If your app sets its own `LOCALE_ID`, pass it: `getValue('de-DE')`.

## WrInputNumberHarness

`ngwr/input-number/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getValue(locale?) / getValueText()` | Two different questions. The text is what the field shows — a separator, a prefix, a half-typed number; `getValue()` parses it and THROWS when the field holds something the control has not accepted yet. | `Promise<number \| null> \| Promise<string>` | `—` |
| `setValue(n) / setValueText(text) / clear()` | `setValueText` is the mid-type state; `setValue` is the committed one. | `Promise<void>` | `—` |
| `increment() / decrement() / isIncrementDisabled() / isDecrementDisabled() / hasSteppers()` | The buttons, including how they disable at a bound. | `Promise<…>` | `—` |
| `stepUp() / stepDown()` | The keyboard path — ArrowUp / ArrowDown on the field, which works with no steppers rendered. | `Promise<void>` | `—` |
| `getPrefix() / getSuffix() / getPlaceholder() / getAriaLabel()` | The decorations around the field, which are part of why the text is not the value. | `Promise<…>` | `—` |
| `isDisabled() / isReadonly() / focus() / blur() / isFocused()` | State and focus. | `Promise<…>` | `—` |

## One-time codes

Typed, pasted, or backspaced away.

```typescript
it('takes a pasted code', async () => {
  const otp = await loader.getHarness(WrInputOtpHarness);

  await otp.paste('123456');
  expect(await otp.getValue()).toBe('123456');
  expect(await otp.isComplete()).toBe(true);

  await otp.backspace();
  expect(await otp.getBoxValues()).toEqual(['1', '2', '3', '4', '5', '']);
  expect(await otp.getFocusedIndex()).toBe(5);
});
```

Pasting is how most people deliver a code, so `paste()` drives the real `paste` event rather than typing character by character. One caveat the harness cannot paper over: the assembled code is what the BOXES hold, and a value the boxes cannot accept — a letter in numeric mode — leaves the bound model holding something the harness cannot see. Assert the model as well when a write comes from outside the control.

## WrInputOtpHarness / WrInputOtpBoxHarness

`ngwr/input-otp/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getValue() / getBoxValues() / isComplete() / getLength()` | The code assembled from the boxes. It can differ from the bound model: a value the boxes cannot hold (a letter in numeric mode) leaves the model holding what the harness cannot see. | `Promise<…>` | `—` |
| `setValue(code) / type(text) / paste(code) / backspace() / clear()` | `paste` drives the real `paste` event, which is how a user delivers a code from an SMS. `clear()` skips boxes that are already empty, so it does not drag focus through the control or re-fire `touch`. | `Promise<void>` | `—` |
| `getBoxes(filters?) / getBox(i) / getFocusedIndex() / moveFocus(i)` | Per-box access. Filters: `value`, `empty`, `label` (the box's `aria-label`, e.g. `Digit 3`). | `Promise<…>` | `—` |
| `isMasked() / getInputMode() / getSize() / getPlaceholder() / getLabel() / isDisabled()` | How the control presents itself, including the keyboard a phone will show. | `Promise<…>` | `—` |
| `focus() / blur()` | `blur()` blurs the box that HAS focus, not box zero. | `Promise<void>` | `—` |

## Sliders

Driven by the keyboard, and that is not a shortcut.

```typescript
it('moves the slider', async () => {
  const slider = await loader.getHarness(WrSliderHarness);

  // Keyboard-driven on purpose: a unit test has no layout, so a drag would write
  // the wrong number or NaN. This is the accessible path anyway.
  await slider.setValue(70);
  expect(await slider.getValue()).toBe(70);

  await slider.stepUp();
  await slider.toMax();
  expect(await slider.getValue()).toBe(await slider.getMax());
});

it('moves the far end of a range first', async () => {
  const slider = await loader.getHarness(WrSliderHarness);   // range

  await slider.setRange(90, 95);
  expect(await slider.getValue()).toEqual([90, 95]);
});
```

A unit test has no layout: every element measures 0×0, so a drag computed from an offset resolves to a bound — or to `NaN` when the offset is zero. A coordinate-driven `setValue` would therefore write the wrong number and report success. The harness walks the value with the keys instead (the accessible path a real user with no mouse takes), takes the ten-step stride before single steps, and throws if it settles anywhere but the value you asked for — a `max` that is not a whole number of steps from `min` is not reachable, and you should hear about it rather than get a silent 99.

## WrSliderHarness / WrSliderThumbHarness

`ngwr/slider/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `setValue(n) / setRange(low, high)` | Keyboard-driven, and not a shortcut: a unit test has no layout, so a coordinate write produces a bound or `NaN`. The walk takes the ten-step stride first, then single steps, and THROWS if it settles anywhere but the value asked for — including a `max` that is not a whole number of steps from `min`. | `Promise<void>` | `—` |
| `getValue()` | A number, or a `[low, high]` tuple on a range slider. | `Promise<number \| [number, number]>` | `—` |
| `stepUp() / stepDown() / toMin() / toMax()` | One press each — arrows, Home and End. | `Promise<void>` | `—` |
| `getThumbs() / getLowThumb() / getHighThumb()` | A thumb harness carries the ARIA range trio, its label, its role and `largeStepUp/Down`. On a range slider each thumb bounds the other, which is why `setRange` moves the far end first. | `Promise<WrSliderThumbHarness[]>` | `—` |
| `isRange() / isDisabled() / getMin() / getMax() / getLabelText() / focus() / isFocused()` | Shape and state. | `Promise<…>` | `—` |

## Ratings

```typescript
it('takes a rating', async () => {
  const rating = await loader.getHarness(WrRatingHarness);

  await rating.setValue(4);
  expect(await rating.getValue()).toBe(4);

  const items = await rating.getItems();
  expect(await items[3].isFilled()).toBe(true);
  expect(await items[4].isFilled()).toBe(false);

  await rating.clear();
  expect(await rating.getValue()).toBe(0);
});
```

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `setValue(n) / clear() / stepUp() / stepDown()` | Picking by click (clicks need no coordinates) and the keyboard path. | `Promise<void>` | `—` |
| `getValue() / getMax() / getCount() / getFills()` | `getFills()` is the per-item fill fraction — how a half rating actually renders. | `Promise<…>` | `—` |
| `getItems()` | An item harness answers `getFill()`, `isFilled()`, `isPartiallyFilled()`, `isInteractive()`, and drives `click()`, `clickHalf()` and `hover()`. | `Promise<WrRatingItemHarness[]>` | `—` |
| `unhover()` | Ends a hover preview, which otherwise leaves the control showing a value it does not hold. | `Promise<void>` | `—` |
| `getRole() / getLabel() / getSize() / isReadonly() / isDisabled() / isFocusable() / focus() / blur() / isFocused()` | Shape, state and focus. | `Promise<…>` | `—` |

## File uploads

Real drag events, and a real FileList.

```typescript
it('takes a dropped file and drops a rejected one', async () => {
  const upload = await loader.getHarness(WrFileUploadHarness);

  await upload.dropFiles([new File(['hello'], 'notes.txt', { type: 'text/plain' })]);
  expect(await upload.getFileNames()).toEqual(['notes.txt']);

  await upload.removeFileNamed('notes.txt');
  expect(await upload.getFileCount()).toBe(0);
});
```

`FileList` has no constructor, so files are delivered through a `DataTransfer` — which is also what a real drop carries. `dropFiles()` plays the whole gesture (`dragenter`, `dragover`, `drop`) rather than the last event of it, because a component that stopped disarming its drop zone would pass a drop-only test. Focus goes to the zone, not to the hidden picker: the picker is `aria-hidden` and out of the tab order.

## WrFileUploadHarness

`ngwr/file-upload/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `selectFiles(files)` | Goes through the hidden `<input type="file">` — the one method that reaches past the zone, because `FileList` cannot be constructed by hand and the CDK has no file API. | `Promise<void>` | `—` |
| `dropFiles(files) / dragOver() / dragLeave() / isDragging()` | A real drag: `dragenter`, `dragover`, then `drop`, each carrying a `DataTransfer`. The zone is reachable by class even though coordinates are not. | `Promise<…>` | `—` |
| `getFileNames() / getFileSizes() / getFileCount()` | The rendered list. Sizes are as SHOWN (`4.9 KB`) — the byte count never reaches the DOM. | `Promise<…>` | `—` |
| `removeFile(i) / removeFileNamed(name)` | By index or by an EXACT name — a substring match would take `backup-a.png` when asked for `a.png`. | `Promise<void>` | `—` |
| `getLabel() / getPickText() / getDropText() / getHelperText() / getAccept() / isMultiple() / isDisabled()` | `accept` and `multiple` are read off the picker; everything else off the zone. | `Promise<…>` | `—` |
| `focus() / isFocused()` | Focus goes to the ZONE, which is the tab stop — the picker is `aria-hidden` and `tabindex="-1"`. | `Promise<…>` | `—` |

## Context menus

Opened by a right-click, closed on a timer.

```typescript
it('copies from the context menu', async () => {
  const menu = await loader.getHarness(WrContextMenuHarness);

  await menu.open();                        // a real `contextmenu` event, not a click
  expect(await menu.getItemTexts()).toEqual(['Copy', 'Cut', 'Paste']);

  await menu.clickItem({ text: 'Copy' });
  expect(await menu.isOpen()).toBe(false);
});

it('walks into a submenu', async () => {
  const menu = await loader.getHarness(WrContextMenuHarness);
  await menu.open();

  const [more] = await menu.getItems({ hasSubmenu: true });
  await more.openSubmenu();
  expect(await more.isSubmenuOpen()).toBe(true);

  await more.clickSubmenuItem({ text: 'As PNG' });
});
```

Two things about this component would trip a hand-written spec. It opens on a real `contextmenu` event, not a click — and its pane stays in the DOM through an exit animation, so "is a pane present" is not the same question as "is the menu open". The harness reads the target's `aria-controls` for the second one, and its close methods wait for the pane to actually be disposed. Submenus each get their own overlay pane, scoped by the id their parent item publishes, so one harness walks any depth.

## WrContextMenuHarness

`ngwr/context-menu/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `open() / rightClick() / openByLongPress() / close() / closeByOutsidePress()` | A context menu opens on a real `contextmenu` event, so `open()` sends one rather than clicking. The close paths WAIT for the pane to be disposed — it lingers for its exit animation, and a harness that returned early would report a menu that is on its way out as gone. | `Promise<void>` | `—` |
| `isOpen()` | Read from the target's `aria-controls`, not from whether a pane exists: during the exit animation both are true of the DOM and only one is true of the component. | `Promise<boolean>` | `—` |
| `getItems(filters?) / getItemTexts() / clickItem(filters)` | Filters: `text`, `disabled`, `hasSubmenu`. Dividers are not items and never appear here. | `Promise<…>` | `—` |
| `getDividerCount()` | Counted by `role="separator"`, so a separator you wrote yourself counts too — the role is what tells a screen reader where one group ends. | `Promise<number>` | `—` |
| `getMenuRole() / getMenuText() / getTargetText()` | The menu announces `menu`. | `Promise<…>` | `—` |
| `An item adds: hasSubmenu() / isSubmenuOpen() / openSubmenu() / openSubmenuByHover() / closeSubmenu() / getSubmenuItems() / clickSubmenuItem()` | Each submenu is its own overlay pane, scoped by the id its parent item publishes — so the same harness walks any depth without the panes reading each other. | `Promise<…>` | `—` |

## Popconfirm

Four ways out, and they mean different things.

```typescript
it('asks before deleting', async () => {
  const ask = await loader.getHarness(WrPopconfirmHarness);

  await ask.open();
  expect(await ask.getMessage()).toBe('Delete this item?');
  expect(await ask.getActionLabels()).toEqual(['Cancel', 'Delete']);

  await ask.confirm();
  expect(await ask.isOpen()).toBe(false);
  expect(host.deleted()).toBe(true);
});
```

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `open() / close() / confirm() / cancel() / sendEscape() / clickOutside()` | Four ways out, and they are not equivalent: confirm emits `confirmed`, the other three emit `cancelled`. | `Promise<void>` | `—` |
| `getMessage() / getConfirmText() / getCancelText() / getActionLabels()` | The copy, including whatever the i18n catalog resolved the button labels to. | `Promise<…>` | `—` |
| `getRole() / isModal() / getLabel() / getDescriptionText()` | A popconfirm is a NON-modal dialog that names itself and has the question as its description — it deliberately does not trap focus, so the description is how a screen-reader user hears the question. | `Promise<…>` | `—` |
| `getConfirmColor() / getPosition() / getFocusedActionLabel() / isTriggerFocused()` | Intent, placement and where focus sits. | `Promise<…>` | `—` |

## Command palette

```typescript
it('runs a command', async () => {
  const palette = await loader.getHarness(WrCommandPaletteHarness);

  await palette.open();
  await palette.setQuery('set');
  expect(await palette.getItemLabels()).toEqual(['Settings', 'Set theme']);

  await palette.moveToNextItem();
  expect(await palette.getActiveItemLabel()).toBe('Set theme');

  // The query field owns the list through aria-activedescendant, so the
  // highlighted row is announced without focus ever leaving the input.
  expect(await palette.isActiveItemAnnounced()).toBe(true);

  await palette.runActiveItem();
});
```

Focus never leaves the query field: the highlighted row is published with `aria-activedescendant`, which is what a screen reader announces as the arrows move. `isActiveItemAnnounced()` checks that link holds — the arrows can look right in a test while a screen-reader user hears nothing at all.

## WrCommandPaletteHarness

`ngwr/command-palette/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `open() / close() / pressTrigger() / pressHotkey() / clickBackdrop()` | Every route in and out, including the hotkey the palette owns. | `Promise<void>` | `—` |
| `setQuery(text) / getQuery() / getPlaceholder()` | The search field drives the list. | `Promise<…>` | `—` |
| `getItems(filters?) / getItemLabels() / runItem(filters) / getGroups() / getGroupTitles()` | The results as filtered, grouped the way the palette groups them. | `Promise<…>` | `—` |
| `getActiveItem() / getActiveItemLabel() / getActiveItemIndex() / moveToNextItem() / moveToPreviousItem() / moveToFirstItem() / moveToLastItem() / runActiveItem()` | The highlighted row and the arrow-key walk. | `Promise<…>` | `—` |
| `isActiveItemAnnounced() / isSearchWiredToList()` | The palette keeps focus in the input and points at the highlighted row with `aria-activedescendant`. These two check that link holds — without it a screen-reader user hears nothing as the arrows move. | `Promise<boolean>` | `—` |
| `getRole() / isModal() / getLabel() / isPresentedAsSheet() / getEmptyText() / focus() / blur() / isSearchInputFocused()` | Shape, state and focus. | `Promise<…>` | `—` |

## Cascader

One column per level.

```typescript
it('picks a path', async () => {
  const cascader = await loader.getHarness(WrCascaderHarness);

  await cascader.open();
  expect(await cascader.getColumnCount()).toBe(1);

  // Each column is one level, and opening a parent is what creates the next.
  await cascader.selectPath(['Europe', 'Portugal', 'Lisbon']);

  expect(await cascader.getValueText()).toBe('Europe / Portugal / Lisbon');
  expect(await cascader.isOpen()).toBe(false);
});
```

Which columns exist depends on what is open, so the columns are the component rather than a detail of it. `selectPath()` walks level by level the way a user does, opening each parent before reaching for the next.

## WrCascaderHarness

`ngwr/cascader/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getColumns() / getColumnCount() / getColumn(i) / getColumnLabels()` | One column per level, and which columns exist depends on what is open — that relationship IS the component. A column harness answers its options and its active one. | `Promise<…>` | `—` |
| `selectPath(labels) / getOption(path) / getActiveTrail()` | `selectPath` walks level by level, opening each parent before reaching for the next — the same order a user does it in. | `Promise<…>` | `—` |
| `open() / close() / clickTrigger() / clear() / focus() / blur() / isFocused()` | The trigger. | `Promise<…>` | `—` |
| `getValueText() / getPlaceholder() / getAccessibleName() / isDisabled() / isOpen()` | What the trigger shows and announces. | `Promise<…>` | `—` |
| `getPopupRole() / getPanelRole() / getPanelId() / isPanelWiredToTrigger()` | The panel reference the harness itself relies on: one cascader cannot answer with another's options while both are open. | `Promise<…>` | `—` |

## Trees

Levels, sibling groups, and a window.

```typescript
it('expands and selects', async () => {
  const tree = await loader.getHarness(WrTreeHarness);

  const root = await tree.getNode({ label: 'src' });
  expect(await root.isExpandable()).toBe(true);
  await root.expand();

  expect(await tree.getNodeLabels()).toEqual(['src', 'app', 'main.ts', 'README.md']);

  await tree.selectNode({ label: 'main.ts' });
  expect(await tree.getSelectedLabels()).toEqual(['main.ts']);
});

it('announces the hierarchy', async () => {
  const tree = await loader.getHarness(WrTreeHarness);
  const app = await tree.getNode({ label: 'app' });

  expect(await app.getLevel()).toBe(2);
  // Per SIBLING GROUP, not per flat list — 'app' and 'main.ts' are the two
  // children of 'src'.
  expect(await app.getSetSize()).toBe(2);
  expect(await app.getPosInSet()).toBe(1);
});
```

`aria-setsize` counts a node's sibling group including itself, and `aria-posinset` is its place in that group — neither is a position in the flat list of visible rows, and reading them that way is the classic tree bug. While the tree is virtualized `getNodes()` is the WINDOW, not the dataset, and the keyboard methods are named for the ACTIVE node because a virtual tree moves a cursor with `aria-activedescendant` rather than real focus.

## WrTreeHarness / WrTreeNodeHarness

`ngwr/tree/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getNodes(filters?) / getNodeLabels() / getNode(filters)` | The VISIBLE nodes in order. While the tree is virtualized this is the window, not the dataset — the spacer rows are not nodes. | `Promise<…>` | `—` |
| `selectNode(filters) / getSelectedLabels() / expandAll() / clear()` | Selection and bulk expansion. | `Promise<…>` | `—` |
| `focusNext() / focusPrevious() / focusFirst() / focusLast() / expandActive() / collapseActive() / selectActive() / getActiveNodeLabel()` | The keyboard walk. While virtual the tree moves a cursor with `aria-activedescendant` rather than real focus, which is why these are named for the ACTIVE node. | `Promise<…>` | `—` |
| `isOverlay() / isOpen() / open() / close() / getValueText() / getChipLabels() / getOverflowText() / removeChip(label)` | The select-like shape, for a tree used as a picker. | `Promise<…>` | `—` |
| `getRole() / isMultiple() / getSelectionMode() / isVirtual() / isDisabled()` | Shape and mode. | `Promise<…>` | `—` |
| `A node adds: getLabel() / getLevel() / getPosInSet() / getSetSize() / getIndex() / isExpandable() / isExpanded() / expand() / collapse() / isSelected() / isDisabled() / isActive() / click() / ctrlClick()` | `aria-setsize` counts the node's SIBLING GROUP including itself and `aria-posinset` is its place in that group — neither is a position in the flat list, and reading them that way is a classic tree bug. | `Promise<…>` | `—` |

## Mentions

The panel comes from what you typed.

```typescript
it('mentions a teammate', async () => {
  const mention = await loader.getHarness(WrMentionHarness);

  await mention.type('hey @ad');
  expect(await mention.isOpen()).toBe(true);
  expect(await mention.getOptionLabels()).toEqual(['Ada Lovelace']);

  await mention.commit();
  expect(await mention.getValue()).toBe('hey @ada ');
});
```

Typing is the only way in — the directive detects a trigger character at the caret, so a value written straight to the field opens nothing. The harness sends real keydown and keyup pairs, which is what a browser does; writing it that way is what surfaced a bug where Escape's keyup reopened the panel its keydown had just dismissed.

## WrMentionHarness

`ngwr/mention/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `type(text) / setValue(text) / getValue() / clear()` | Typing is the only way in: the panel opens off a trigger character the directive detects at the caret, so a value written straight to the field does not open anything. | `Promise<…>` | `—` |
| `getOptions(filters?) / getOptionLabels() / pick(filters)` | The suggestions, capped at `maxResults`. | `Promise<…>` | `—` |
| `nextOption() / previousOption() / getActiveOptionLabel() / getActiveOptionIndex() / getActiveOptionId() / commit() / commitWithTab()` | The arrow walk and the two commit keys, which insert the mention into the field. | `Promise<…>` | `—` |
| `dismiss() / blur() / isOpen()` | Escape dismisses without picking. Note the harness sends a real keydown AND keyup pair, which is what a browser does — and what exposed a bug where the keyup reopened the panel Escape had just closed. | `Promise<…>` | `—` |
| `getAutocomplete() / getPopupRole() / getListboxRole() / getListboxLabel() / getStatusMessage()` | The combobox wiring the field publishes while the panel is up. | `Promise<…>` | `—` |

## Form fields

Where the validation copy actually comes from.

```typescript
it('shows a message the app never wrote', async () => {
  const field = await loader.getHarness(WrFormFieldHarness);
  const email = await loader.getHarness(WrInputHarness);

  await email.setValue('nope');
  await email.blur();

  // No <wr-form-error> in the template: the copy comes from the i18n catalog
  // through provideWrFormErrors(), which is the whole point of the component.
  expect(await field.getErrorText('email')).toBeTruthy();
  expect(await field.isInvalid()).toBe(true);

  // And the control is actually wired to it — a message nothing points at is
  // decoration a screen reader never reads.
  expect(await field.getAnnouncedDescription()).toBe(await field.getErrorText('email'));
  expect(await field.isLabelLinkedToControl()).toBe(true);
});
```

`<wr-form-field>` resolves a message per error key through [provideWrFormErrors()](https://ngwr.dev/reference/components/form), then the `ngwr/i18n``validation.*` catalog, then a built-in fallback — so a field needs no `<wr-form-error>` markup to say something useful. The two questions worth asserting are whether a message is showing AND whether the control points at it: a message nothing references is decoration a screen reader never reads, which is what `getAnnouncedDescription()` and `hasEmptyErrorBlock()` are for.

## WrFormFieldHarness

`ngwr/form/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getErrors() / getErrorTexts() / getErrorText(key)` | The messages the field is SHOWING, by validator key. `getErrorText` throws rather than answering `null`, and its message says which of the two happened: the key is not in error, or its copy resolved to nothing. | `Promise<…>` | `—` |
| `getSuppressedErrorKeys() / hasEmptyErrorBlock()` | Keys in error with no copy to show. That combination is the failure this component exists to prevent — a field that knows it is invalid and cannot say why. | `Promise<…>` | `—` |
| `getLabel() / isRequired() / isOptional() / getHint()` | The surrounding copy. | `Promise<…>` | `—` |
| `isLabelLinkedToControl() / getLabelFor() / getControlId()` | A real `<label for>` link to the projected control. An `aria-label` on the wrapper does NOT reach the native control inside it, so this link is why the field is nameable at all. | `Promise<…>` | `—` |
| `getDescribedByIds() / getAnnouncedDescription()` | What the control points at, resolved to text: the hint and the error a screen reader will actually read. A message nothing references is decoration. | `Promise<…>` | `—` |
| `isInvalid() / isControlInvalid() / focusControl() / blurControl()` | Two different questions: what the field PAINTS, and what the control announces through `aria-invalid`. | `Promise<…>` | `—` |
| `WrFormItemHarness: getLabel() / isInvalid() / getErrorTexts()` | The same three questions for `<wr-form-item>`. | `Promise<…>` | `—` |

## Tabs

Focus and selection are different questions.

```typescript
it('switches tabs', async () => {
  const tabs = await loader.getHarness(WrTabsHarness);

  expect(await tabs.getTabLabels()).toEqual(['Overview', 'Details', 'Locked']);
  expect(await tabs.getSelectedLabel()).toBe('Overview');

  await tabs.select({ label: 'Details' });
  expect(await tabs.getSelectedLabel()).toBe('Details');

  // The panel has to be the one THIS tab names, or a screen reader lands nowhere.
  const details = (await tabs.getTabs({ label: 'Details' }))[0];
  expect(await details.isPanelBound()).toBe(true);
});

it('walks focus without moving the selection', async () => {
  const tabs = await loader.getHarness(WrTabsHarness);

  await tabs.focusTabStop();
  await tabs.pressArrowRight();

  // Two different questions. A strip that answered the selection for both would
  // look right in a test and be dead to a keyboard user.
  expect(await tabs.getFocusedLabel()).toBe('Details');
  expect(await tabs.getSelectedLabel()).toBe('Overview');
});
```

A tab strip roves focus, so "which tab is active" has two answers, and a harness that gave the selection for both would look right in every test while the strip was dead to a keyboard user. Both are exposed. The other thing worth asserting is the panel pairing: `isPanelBound()` walks the `aria-controls` / `aria-labelledby` round trip in both directions, because a panel wired to the wrong header looks fine and reads as nothing.

## WrTabsHarness / WrTabHarness

`ngwr/tabs/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getTabLabels() / getTabs(filters?) / getSelectedLabel() / select(filters) / selectByIndex(i)` | `select()` verifies the tab actually became selected and throws if it did not — a disabled content tab fires no click at all, and a router strip may navigate somewhere else entirely. Filters: `label`, `selected`, `disabled`. | `Promise<…>` | `—` |
| `getTabStopLabels() / isRoving() / getFocusedLabel() / focusTabStop()` | The roving tab stop, which is NOT the selection once focus has moved. A strip that answered one for the other would look correct in a test and be dead to a keyboard user. A disabled tab is never a tab stop, even when it is the active one. | `Promise<…>` | `—` |
| `pressArrowRight() / pressArrowLeft() / pressHome() / pressEnd()` | Sent at the `role="tablist"` strip, which owns the handler. The arrows mirror under `dir="rtl"`; Home and End do not. | `Promise<void>` | `—` |
| `isRouterMode() / getRole() / getOrientation() / getSize() / getFades()` | Shape and chrome. `getFades()` reads the edge fades from the strip's scroll metrics. | `Promise<…>` | `—` |
| `A tab adds: getLabel() / isSelected() / isDisabled() / isTabStop() / isLink() / getHref() / getPanelId() / isPanelBound() / getPanelText()` | `isPanelBound()` walks the `aria-controls` / `aria-labelledby` round trip both ways — a panel wired to the wrong header is invisible to a sighted user and fatal to a screen-reader one. | `Promise<…>` | `—` |

## Stepper

A linear flow refuses a jump.

```typescript
it('walks the steps', async () => {
  const stepper = await loader.getHarness(WrStepperHarness);

  expect(await stepper.getStepLabels()).toEqual(['Cart', 'Address', 'Payment']);
  expect(await stepper.getActiveLabel()).toBe('Cart');

  await stepper.next();
  expect(await stepper.getActiveLabel()).toBe('Address');
  expect(await stepper.getCompletedLabels()).toEqual(['Cart']);

  // A linear stepper refuses a jump, and says so rather than doing nothing.
  expect(await stepper.canGoTo(2)).toBe(false);
  await expect(stepper.goTo(2)).rejects.toThrow();
});
```

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getStepLabels() / getSteps(filters?) / getActiveLabel() / getActiveIndex() / getCompletedLabels()` | The steps and where the flow is. Filters: `label`, `active`, `completed`, `disabled`, `reachable`, `optional`. | `Promise<…>` | `—` |
| `next() / previous() / goTo(i) / goToLabel(label) / canGoTo(i)` | A LINEAR stepper refuses a jump, so `canGoTo()` answers first and `goTo()` throws rather than silently doing nothing. | `Promise<…>` | `—` |
| `getActiveStepText() / getStepTexts()` | The content of the active step, and of each. | `Promise<…>` | `—` |
| `isLinear() / getOrientation() / isResponsive() / getListRole() / getTabStopLabels() / getFocusedLabel()` | Shape, and the roving tab stop again. | `Promise<…>` | `—` |
| `A step adds: getLabel() / getDescription() / isOptional() / getAccessibleName() / isActive() / isCompleted() / isReachable() / select()` | `isReachable()` is the linear question per step; `select()` respects it. | `Promise<…>` | `—` |

## WrCarouselHarness

`ngwr/carousel/testing`

Next stays next in both reading directions — only the travel mirrors. There is deliberately no drag method: a drag is pointer-driven and a unit test has no layout, so one would write the wrong slide index and report success.

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getSlideCount() / getSlideTexts() / getActiveIndex() / getActiveSlideText()` | What is on show. | `Promise<…>` | `—` |
| `next() / previous() / goTo(i)` | Next stays next in both directions — only the travel mirrors under `dir="rtl"`. A drag is pointer-driven and jsdom has no layout, so there is no drag method: the buttons and the keyboard are the honest paths. | `Promise<void>` | `—` |
| `hasDots() / getDotCount() / getDotLabels() / hasArrows() / getPreviousLabel() / getNextLabel()` | The controls, including their accessible names. | `Promise<…>` | `—` |
| `hover() / mouseAway()` | What pauses and resumes autoplay. | `Promise<void>` | `—` |
| `getRole() / getRoleDescription() / getSlideRoleDescriptions() / getAccessibleName() / getTrackOffsetPercent()` | What the carousel announces. The track offset is the one geometric answer, read from the inline style rather than measured. | `Promise<…>` | `—` |

## Pagination

The gaps are not pages.

```typescript
it('pages through', async () => {
  const pager = await loader.getHarness(WrPaginationHarness);

  expect(await pager.getCurrentPage()).toBe(1);
  expect(await pager.isPreviousDisabled()).toBe(true);

  // The gaps are not pages. `getStrip()` shows them for what they are.
  expect(await pager.getPages()).toEqual([1, 2, 3, 4, 5, 10]);
  expect(await pager.getStrip()).toEqual([1, 2, 3, 4, 5, '…', 10]);

  await pager.goToPage(10);
  expect(await pager.isNextDisabled()).toBe(true);

  // The page-size control is a wr-select, so compose its harness rather than
  // querying a panel that already has one.
  await pager.setPageSize(50);
  expect(await pager.getPageSize()).toBe(50);
});
```

`getPages()` is the numbers a user can reach; `getStrip()` shows the ellipsis gaps for what they are. Counting a gap as a page is the classic pager bug, and it is why these are two methods rather than one. The page-size control is a `wr-select`, so the harness hands back `WrSelectHarness` instead of re-querying a panel that already has one.

## WrPaginationHarness

`ngwr/pagination/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getPages() / getStrip() / getCurrentPage() / getTotalPages()` | `getPages()` is the numbers only; `getStrip()` shows the ellipsis gaps for what they are. A gap is a `<span>`, not a page, and counting it as one is the classic pager bug. | `Promise<…>` | `—` |
| `goToPage(n) / goToFirst() / goToLast() / next() / previous()` | Moving. `aria-current` is what says where you landed. | `Promise<void>` | `—` |
| `isNextDisabled() / isPreviousDisabled() / isDisabled()` | The ends, and the whole control. | `Promise<boolean>` | `—` |
| `hasPageSizeChanger() / getPageSizeSelect() / getPageSize() / setPageSize(n)` | The size control is a `wr-select`, so `getPageSizeSelect()` hands back `WrSelectHarness` rather than re-querying a panel that already has a harness. | `Promise<…>` | `—` |
| `hasTotal() / getTotalText() / getLabel() / getSize() / isResponsive()` | The surrounding copy and chrome. | `Promise<…>` | `—` |

## WrSegmentedHarness

`ngwr/segmented/testing`

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getOptionLabels() / getOptions(filters?) / getSelectedLabel() / getSelectedIndex() / select(filters) / selectAt(i)` | The options and the choice. Filters: `label`, `selected`, `disabled`. | `Promise<…>` | `—` |
| `getTabStopLabels() / getFocusedLabel()` | The roving tab stop, separate from the selection as everywhere else. | `Promise<…>` | `—` |
| `getThumbIndex() / getThumbCount() / isThumbVisible() / isThumbTransitionEnabled()` | The sliding thumb is decoration, so these read what is actually there — its index comes from the inline custom property the component writes, not from a measured position jsdom does not have. | `Promise<…>` | `—` |
| `getRole() / getAccessibleName() / getSize() / isDisabled()` | Shape and state. | `Promise<…>` | `—` |

## Collapse

Open is an ARIA state, not a measured height.

```typescript
it('opens one panel at a time', async () => {
  const group = await loader.getHarness(WrCollapseGroupHarness);

  await group.openPanel({ title: 'Shipping' });
  expect(await group.getOpenTitles()).toEqual(['Shipping']);

  // Accordion mode: opening the next one closes the first.
  await group.openPanel({ title: 'Payment' });
  expect(await group.getOpenTitles()).toEqual(['Payment']);

  const shipping = await group.getPanel({ title: 'Shipping' });
  expect(await shipping.isOpen()).toBe(false);
  expect(await shipping.isRegionBound()).toBe(true);
});
```

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `Group: getPanelTitles() / getPanels(filters?) / getPanel(filters) / getPanelAt(i) / getOpenTitles()` | The accordion. Filters: `title`, `open`, `disabled`. | `Promise<…>` | `—` |
| `Group: openPanel(filters) / closePanel(filters) / closeAll() / getFocusedTitle()` | In accordion mode opening one closes the rest — assert that, it is the interesting behaviour. | `Promise<…>` | `—` |
| `Panel: isOpen() / open() / close() / toggle() / getContentText()` | Open and closed come from the header's `aria-expanded`, never from a measured height — the animation is invisible in jsdom, and a harness that measured it would answer about a frame. | `Promise<…>` | `—` |
| `Panel: getRegionId() / isRegionBound() / isContentHidden()` | The `aria-controls` pairing between header and region, checked in both directions. | `Promise<…>` | `—` |

## Transfer

Two symmetric panes, one side argument.

```typescript
it('moves a row across', async () => {
  const transfer = await loader.getHarness(WrTransferHarness);
  const source = await transfer.getPane('source');

  await (await source.getItem({ label: 'Write' })).check();
  expect(await transfer.canMoveTo('target')).toBe(true);

  await transfer.moveTo('target');
  expect(await (await transfer.getPane('target')).getItemLabels()).toEqual(['Write']);

  // Nothing staged, so the button is disabled — and the harness refuses rather
  // than pressing it and resolving as if something had happened.
  await expect(transfer.moveTo('target')).rejects.toThrow(/nothing staged/);
});
```

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getPane(side)` | The two panes are symmetric, so the API takes a side — `'source'` or `'target'` — rather than duplicating every method. | `Promise<WrTransferPaneHarness>` | `—` |
| `canMoveTo(side) / moveTo(side) / moveAllTo(side) / getMoveLabel(side)` | `moveTo()` refuses a disabled button instead of pressing it: a native disabled `<button>` swallows the click before Angular sees it, so the call would otherwise resolve having moved nothing. The error names the pane that is empty. | `Promise<…>` | `—` |
| `Pane: getItemLabels() / getItems(filters?) / getItem(filters) / getCheckedLabels() / getEmptyText()` | The rows a pane is showing. Filters: `label`, `checked`, `disabled`. | `Promise<…>` | `—` |
| `Pane: toggleSelectAll() / checkAll() / uncheckAll() / isAllChecked() / isPartiallyChecked() / isSelectAllDisabled()` | Select-all is scoped to what the pane is SHOWING — a search narrows it. | `Promise<…>` | `—` |
| `Pane: hasSearch() / search(query) / getSearchValue() / getCountText() / getTitle() / getListRole()` | The per-pane search and header. `search()` throws when `searchable` is off. | `Promise<…>` | `—` |
| `An item adds: getLabel() / isChecked() / isDisabled() / toggle() / check() / uncheck()` | `check` and `uncheck` are no-ops when the row is already there; a disabled row refuses. | `Promise<…>` | `—` |

## Markdown

A rendered document, read the way a reader sees it.

```typescript
import { WrMarkdownHarness } from 'ngwr/markdown/testing';

const md = await loader.getHarness(WrMarkdownHarness);

expect(await md.getHeadings()).toEqual([
  { level: 1, text: 'Release notes', id: 'user-content-release-notes' },
]);

// Links carry what a reviewer actually cares about. `rel` is only set when
// the host opts into a target — this one renders linkTarget="_blank".
const [link] = await md.getLinks();
expect(link.href).toBe('https://ngwr.dev');
expect(link.rel).toBe('noopener noreferrer');

// Code blocks are their own harness.
const block = await md.getCodeBlock({ language: 'ts' });
expect(await block.getCode()).toBe('const a = 1;');
expect(await block.canCopy()).toBe(true);
await block.copy();

// Task state is a field, not something to parse out of the text.
expect(await md.getTaskItems()).toEqual([
  { text: 'ship it', checked: true, stateLabel: 'Done:' },
]);

// Mid-stream, the host says so.
expect(await md.isStreaming()).toBe(true);
```

The harness answers in terms of the DOCUMENT rather than the markup that produced it — headings with their level and id, links with their `href` and `rel`, task items with their checked state. That is deliberate: the parser has its own spec, and a harness that re-asserted the tree would test the same thing twice while missing the half that only the DOM can answer.

Two details worth copying if you write something similar. Prose reads exclude the hidden task-state labels, so an assertion on an item's text does not have to know that `Done:` is spliced in front of it — the state is a field of its own instead. And a code block's text is read exactly, never trimmed: a snippet's leading indentation and blank lines are its content, and a harness that collapsed them would pass on a component that had destroyed them.

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `getText()` | The whole document as prose, whitespace collapsed, hidden task labels left out. | `Promise<string>` | `—` |
| `getHeadings()` | Level, text and id per heading. The level comes from the element, so it also asserts that a real `<h2>` was rendered rather than a styled div. | `Promise<WrMarkdownHarnessHeading[]>` | `—` |
| `getLinks()` | Text, `href`, `title`, `target` and `rel`. A `_blank` without `rel="noopener noreferrer"` is the assertion worth writing. | `Promise<WrMarkdownHarnessLink[]>` | `—` |
| `getCodeBlocks(filters?)` | Every fenced block, nested ones included. `getCodeBlock(filters?)` returns the first match and throws naming the languages present. | `Promise<WrMarkdownCodeBlockHarness[]>` | `—` |
| `getTaskItems()` | Text, checked state and the screen-reader label that carries it. | `Promise<WrMarkdownHarnessTaskItem[]>` | `—` |
| `getTables()` | Headers, rows and per-column alignment, read from the inline style the renderer wrote rather than from computed style — a `<th>` centres by default, which would make "no alignment" indistinguishable from `center`. | `Promise<WrMarkdownHarnessTable[]>` | `—` |
| `getParagraphs() / getListItems() / getQuotes() / getInlineCode() / getImages() / getRuleCount()` | The rest of the document, each in the terms a reader would use. | `Promise<string[]> etc.` | `—` |
| `isStreaming() / isEmpty()` | `isStreaming()` reads the host modifier that paints the caret. `isEmpty()` means nothing rendered at all, which is not the same as no text. | `Promise<boolean>` | `—` |
| `WrMarkdownCodeBlockHarness` | `getLanguage()`, `getCode()` (exact, never trimmed), `isHighlighted()`, `canCopy()`, `getCopyLabel()`, `copy()`. | `ComponentHarness` | `—` |

## Writing one for your own component

The same base class is available to you. If your app wraps an ngwr control in something of its own, a harness for the wrapper keeps your specs as stable as ours:

```typescript
import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';

export class MyWidgetHarness extends ComponentHarness {
  static hostSelector = 'my-widget';

  static with(options: { title?: string } = {}) {
    return new HarnessPredicate(MyWidgetHarness, options).addOption('title', options.title, (harness, title) =>
      HarnessPredicate.stringMatches(harness.getTitle(), title)
    );
  }

  async getTitle(): Promise<string> {
    return (await this.locatorFor('.my-widget__title')()).text();
  }
}
```

## See also

- [wr-btn](https://ngwr.dev/reference/components/button) — The component WrButtonHarness drives.
- [[wrInput]](https://ngwr.dev/reference/components/input) — The directive WrInputHarness drives.
- [wr-checkbox](https://ngwr.dev/reference/components/checkbox) — Including the `checkboxValue` identity the harness reads.
- [wr-switch](https://ngwr.dev/reference/components/switch) — The `role="switch"` control behind WrSwitchHarness.
- [wr-select](https://ngwr.dev/reference/components/select) — Single, multi, tag and search — one harness covers all four.
- [WrDialog](https://ngwr.dev/reference/components/dialog) — Opens the panel WrDialogHarness drives.
- [WrToast](https://ngwr.dev/reference/components/toast) — Shows the toasts WrToastHarness reads.
- [wr-table](https://ngwr.dev/reference/components/table) — Sorting, selection, tree rows — the harness family mirrors them.
- [wr-date-picker](https://ngwr.dev/reference/components/date-picker) — Date, time and datetime in one component.
- [[wrDropdown]](https://ngwr.dev/reference/components/dropdown) — The trigger WrDropdownHarness drives.
- [[wrPopover]](https://ngwr.dev/reference/components/popover) — Popover and tooltip modes behind one harness.
