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, 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.
// 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.
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.
// 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
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('[email protected]');
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:
// 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.
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.
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.
// 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.
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.
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.
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.
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
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.
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(), then the ngwr/i18nvalidation.* 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.
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.
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.
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.
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.
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.
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:
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
- Componentwr-btnThe component WrButtonHarness drives.
- Directive[wrInput]The directive WrInputHarness drives.
- Componentwr-checkboxIncluding the `checkboxValue` identity the harness reads.
- Componentwr-switchThe `role="switch"` control behind WrSwitchHarness.
- Componentwr-selectSingle, multi, tag and search — one harness covers all four.
- ServiceWrDialogOpens the panel WrDialogHarness drives.
- ServiceWrToastShows the toasts WrToastHarness reads.
- Componentwr-tableSorting, selection, tree rows — the harness family mirrors them.
- Componentwr-date-pickerDate, time and datetime in one component.
- Directive[wrDropdown]The trigger WrDropdownHarness drives.
- Directive[wrPopover]Popover and tooltip modes behind one harness.