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.

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.
// 72 so far, publishing 106 harness classes: every form control,
// every overlay, the three data views, the whole navigation / disclosure set,
// every chart, eighteen of the twenty-one animations, <wr-markdown>, and the
// standalone widgets — calendar, event-calendar, window, image-cropper, tour,
// lightbox, speed-dial, splitter.
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';
import { WrEditorHarness } from 'ngwr/editor/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';
import { WrGraphHarness, WrGraphNodeHarness } from 'ngwr/graph/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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
getText()The option's text as drawn, trimmed, without its wrOptionLeading visual. Not the label input: what the select reports for the option is getValueText() or getChipLabels() on WrSelectHarness.Promise<string>—
hasLeading()Whether the row is drawing a wrOptionLeading visual right now, so it answers false while the panel is closed.Promise<boolean>—
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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. null when there is none, or when it is not a primitive: an object identity has no attribute form and a harness reads the DOM.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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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 gridThe 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 for a tooltip, click for a popover
  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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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>—

Action sheet

A name you cannot see, and a group you can.

it('offers a choice, and names itself either way', async () => {
  const sheet = await rootLoader.getHarness(WrActionSheetHarness);

  // No visible title, so the name is the screen-reader-only fallback.
  expect(await sheet.getAccessibleName()).toBe('Actions');
  expect(await sheet.isNamed()).toBe(true);

  expect(await sheet.getActionGroups()).toEqual([
    ['Take Photo', 'Choose from Library', 'Delete'],
    ['Cancel'],
  ]);

  // Picking emits and closes in one call, so the harness goes stale after it.
  await sheet.select({ role: 'destructive' });
  expect(await sheet.isOpen()).toBe(false);
});

The sheet renders through <wr-drawer>, so it comes from the document root like everything else in an overlay — but each sheet anchors on its own .wr-action-sheet root, so two open at once never answer for each other's rows. Dismissal by backdrop or ✕ belongs to the drawer underneath; WrDrawerHarness answers those from the same loader.

Two things are worth asserting because nothing on screen shows them. A sheet with no title is still an aria-modal dialog, and it names itself with a screen-reader-only string — getAccessibleName() reads it, isNamed() checks the panel is actually wired to it. And getActionGroups() keeps the cancel row's separate group visible, which the flat label list cannot: a cancel row that stopped being pinned to the bottom would still come last.

NameDescriptionTypeDefault
isOpen() / getTitle() / getMessage()A sheet you are HOLDING; a closed one has no harness to get. Title and message are null when the sheet was opened without them.Promise<…>—
getAccessibleName() / isTitleVisible() / isNamed()An untitled sheet still names its dialog, with a string only a screen reader gets. isNamed() resolves the panel’s aria-labelledby rather than trusting that the attribute is there.Promise<…>—
getActions(filters?) / getActionLabels() / getActionGroups() / hasCancelGroup()The rows, flat and grouped. The grouping is what pins the cancel row to its own block. Filters: label, role, disabled.Promise<…>—
select(filters) / sendEscape()The two ways out. Picking emits action and closes; Escape closes and emits nothing. A disabled or unmatched row throws instead of doing nothing quietly.Promise<void>—
Row: getLabel() / getRole() / isDisabled() / hasIcon() / getIconName() / click() / focus() / isFocused()WrActionSheetActionHarness. The role is default / destructive / cancel as the component painted it; the icon name comes from wr-icon’s reflected data-icon, the only place it reaches the DOM.Promise<…>—

Colour picker

No drag, and no reading the preview.

it('picks a colour through the fields, not a drag', async () => {
  const trigger = await loader.getHarness(WrColorPickerTriggerHarness);
  const picker = await trigger.open();

  await picker.setHex('#3969e2');
  expect(await picker.getHex()).toBe('#3969e2ff');

  await picker.setTab('rgb');
  expect(await picker.getRgb()).toEqual({ r: 57, g: 105, b: 226 });

  await picker.setAlphaPercent(50);
  // The surfaces followed, and the thumbs say so without any layout.
  expect((await picker.getThumbs()).alpha).toBe(50);
});

Two absences are the design. There is no setHue(): the SV canvas and the two sliders are pointer surfaces that divide by a measured box, and a unit test has no layout, so a synthetic drag writes NaN and reports success. The numeric fields reach the same state and are the component's only keyboard path anyway. getThumbs() then reads where the thumbs ended up, from the percentages the component writes inline — that is how you check a surface followed a colour set through a field.

And there is no getColor() reading the preview swatch. The preview paints the colour as a CSS background, which jsdom normalises to rgb(…) and strips the alpha from, so #ff880080 and #ff8800ff come back identical — a method that answers one thing in a spec and another in a browser. getHex() is the colour; it is also not the same as value, which format may write as rgb() or hsl().

NameDescriptionTypeDefault
getHex() / setHex(text) / blurHex() / isHexFocused()The canonical colour — 8 digits with alpha, 6 without. Typed rather than assigned, because the field commits on every keystroke; text that never parses leaves the colour alone until the blur snaps the field back.Promise<…>—
getTab() / setTab(tab) / getTabs()Which numeric fields are showing. The switcher is a <wr-segmented>, so getTabs() hands back WrSegmentedHarness rather than re-querying its buttons.Promise<…>—
getRgb() / setRgbChannel(ch, n) / getHsl() / setHslChannel(ch, n)The channels of the ACTIVE tab; reading the other tab’s throws, naming setTab. A write lands once rather than per keystroke — typing 128 would commit 1, then 12, and clearing first would commit 0, since Number("") is 0.Promise<…>—
getAlphaPercent() / setAlphaPercent(n) / hasAlpha()Alpha as the whole percent the numeric tabs show. null means the picker has no alpha; the HEX tab throws instead, because there the alpha is the last two hex digits.Promise<…>—
getSwatches() / pickSwatch(color) / getThumbs() / isDisabled()Presets are matched on the string the consumer passed — the button’s accessible name — because the painted background comes back normalised. getThumbs() is the inline percentages, the only position a spec without layout can read.Promise<…>—
Trigger: isOpen() / open() / close() / toggle() / sendEscape() / getPicker() / isDisabled()WrColorPickerTriggerHarness. open() hands back the picker, scoped by the panel id the trigger publishes through aria-controls — two triggers open at once answer with their own.Promise<…>—

Splitter

Two words for the axis, and they are opposites.

it('resizes from the keyboard', async () => {
  const splitter = await loader.getHarness(WrSplitterHarness);

  // Panes side by side; the divider drawn between them is a vertical line.
  expect(await splitter.getOrientation()).toBe('horizontal');
  expect(await splitter.getDividerOrientation()).toBe('vertical');

  await splitter.setPosition(70);
  expect(await splitter.getPaneSizes()).toEqual({ start: 70, end: 30 });

  // Home and End are semantic — they never mirror under RTL.
  await splitter.pressHome();
  expect(await splitter.getPosition()).toBe(await splitter.getMinPosition());
});

orientation="horizontal" means the panes sit side by side, which needs a VERTICAL divider — so that is what aria-orientation says. The harness answers both, because a spec that conflated them would look right and assert nothing. getPaneSizes() reads the flex-basis the component writes inline, which is the only evidence a test without layout has that the panes followed the divider.

setPosition() WALKS with the arrow keys rather than assigning — ten-unit hops, then single steps — because assigning would prove the harness can write a signal and nothing about the clamp, the bounds, or the RTL mirroring, which all live on that path. It works out which key grows the number by pressing one and putting it back, so it is right in both reading directions; a target it cannot land on exactly (the position is a float, and a drag can leave it mid-step) throws with where it stopped.

NameDescriptionTypeDefault
getPosition() / getMinPosition() / getMaxPosition()The divider, from the aria-value* trio it publishes as a role="separator".Promise<number>—
getOrientation() / getDividerOrientation()The component’s axis and the divider’s, which are opposites: panes side by side need a vertical line between them.Promise<…>—
pressArrow(arrow, { shift }) / pressHome() / pressEnd() / setPosition(n)The key a user presses, not a semantic direction — under RTL the start pane is on the right, so ArrowRight shrinks. setPosition walks and asserts its landing.Promise<void>—
getPaneSizes() / getStartText() / getEndText()The share each pane asks for, from the inline flex-basis — a measured width is zero for both in a unit test.Promise<…>—
isDisabled() / isDividerFocusable() / focusDivider() / isDividerFocused() / getDividerLabel()Announced state and tab stop, asked separately: a divider announced as disabled that is still a tab stop is a control the keyboard can reach and not use.Promise<…>—

Animations

What a test can see of something that moves.

it('animates text without losing it', async () => {
  const headline = await loader.getHarness(WrBlurTextHarness);

  // Two questions, not one: what is announced, and what is drawn.
  expect(await headline.getAccessibleText()).toBe('Welcome to ngwr');
  expect(await headline.getPieces()).toEqual(['Welcome', 'to', 'ngwr']);
  expect(await headline.isTextHidden()).toBe(true);

  // And the split has to be lossless — this is what catches a dropped space.
  expect(await headline.getRenderedText()).toBe(await headline.getAccessibleText());
});

Eighteen of the animation components have a harness, and they all obey one rule: nothing reports on the MOTION. A tween's progress is a compositor frame, a @keyframes rule lives in a stylesheet a unit test never loads, and jsdom implements neither Web Animations nor a canvas — so there is no isAnimating() anywhere in the set, and each class says in its own docs which method you might have reached for and why it is absent. What is left is everything an animation writes down: the split it made of your text, the ARIA around it, and the custom properties its inputs turn into.

The pattern worth copying is the pair. A component that animates text per character carries the whole string once in a visually-hidden span and marks the pieces aria-hidden, so getAccessibleText() is what a screen reader hears and getPieces() is what is drawn — and a spec that asserts only the second passes on a component that announces W. e. l. c. o. m. e. Comparing the two is also how you catch a split that lost a space.

Three components deliberately have NO harness: aurora, click-spark and confetti draw into a canvas whose context is null in a unit test, so every honest method would answer the same for a working component and a dead one. Where a harness does exist for a canvas-backed effect — fuzzy-text, waves, splash-cursor — it reports the DOM half only: the accessible copy of the text, the aria-hidden on the canvas, and the handover flag that says the drawing took over from the CSS stand-in.

NameDescriptionTypeDefault
Text splitters: getAccessibleText() / getPieces() / getRenderedText() / isTextHidden() / hasStagedMotion()WrBlurTextHarness, WrSplitTextHarness. The announced copy and the drawn pieces are separate reads because they can disagree; comparing them is what catches a split that lost a space.Promise<…>—
Circular / rotating: getAccessibleText() / getCharacters() / getCharacterAngles() / getOrbitOffsets() / isBonkers() / getWordCount() / isSettled()WrCircularTextHarness, WrRotatingTextHarness. Angles and offsets are parsed from the inline transform each character carries — a computed read would answer none for a ring that placed them correctly.Promise<…>—
Typewriter / decrypt: getText() / hasCursor() / getCursorBlinkDuration() / getRevealedIndices() / getEncryptedCount() / isFullyRevealed() / hover() / click()WrTypewriterHarness, WrDecryptTextHarness. The typewriter’s accessible text IS the painted fragment — there is no second copy and none is wanted. Decrypt-text’s per-character reveal is a real state machine and reads exactly.Promise<…>—
CSS-driven text: getText() / getCloneText() / isCloneTextInSync() / getDurations() / getColors() / getGradient() / isPaused() / isYoyo() / pausesOnHover()WrGlitchTextHarness, WrGradientTextHarness, WrShinyTextHarness. Everything here is a custom property the component computed from an input — a seconds string, a gradient, a degree — so a lost unit is a visible break and these are what catch it.Promise<…>—
Surfaces: getWords() / getHighlightedWords() / release() / getCopyCount() / getAnnouncedCopyCount() / getItems() / isSingleRay() / getSpeedSeconds()WrFallingTextHarness, WrMarqueeHarness (+ item), WrStarBorderHarness. The marquee duplicates its sequence and hides every copy but the first: getAnnouncedCopyCount() is the assertion that catches a duplicate leaking into the accessibility tree.Promise<…>—
Pointer effects: movePointerTo(x, y) / leave() / isFlat() / getTilt() / getSpotlightPosition() / hasGlare() / getGlarePosition()WrTiltHarness, WrSpotlightHarness, WrSpotlightCardHarness, WrBorderGlowHarness. These divide by the host’s measured box, so stub getBoundingClientRect in the spec first — each JSDoc says so. isFlat() after a move is the reduced-motion assertion.Promise<…>—
Canvas-backed: getText() / isDecorative() / hasCanvas() / isTextVisuallyHidden() / isPainted() / getLineGapPx() / isFullscreen()WrFuzzyTextHarness, WrWavesHarness, WrSplashCursorHarness. The DOM half only. isPainted() is the handover flag that retires the CSS stand-in once the canvas has drawn — false in a unit test, which is the contract rather than a limitation.Promise<…>—

Charts

Read what is textual; a path is not an assertion.

it('reads the data, not the drawing', async () => {
  const bars = await loader.getHarness(WrBarChartHarness);

  // The label row is aria-hidden; the column carries the real name.
  expect(await bars.getLabels()).toEqual(['Mon', 'Tue', 'Wed']);
  expect(await bars.getAccessibleNames()).toEqual(['Mon: 12', 'Tue: 24', 'Wed: 6']);
  expect((await bars.getBars()).map(b => b.heightPercent)).toEqual([50, 100, 25]);

  const donut = await loader.getHarness(WrDonutChartHarness);
  expect(await donut.getLegend()).toEqual([{ label: 'Direct', value: '30' }]);
});

Every chart has a harness — bar-chart, line-chart, donut-chart, sparkline, gauge, meter-group and calendar-heatmap — and they share one rule: an SVG path's d is a rendering detail that moves with the viewBox, the padding and the value range, so none of them lets you assert one. What they read instead is everything with words or a count in it: legends, axis ticks, x labels, slice counts, and the bar heights, which the chart writes as a PERCENTAGE of its own maximum and are therefore readable with no layout.

Three ARIA shapes are worth pinning. A gauge is a role="meter" whose aria-valuetext carries the suffix, which is what keeps it readable with its number hidden; a heatmap is ONE named image with aria-hidden squares, because a year of announced days would be unusable. A bar chart names each column with its label AND its value, while the printed label row underneath is aria-hidden decoration — so asserting only the drawn labels would pass on a chart that announces nothing. And a sparkline is either named, in which case it is a role="img", or it is decoration and must be aria-hidden: getting that backwards leaves a screen reader stopping on a graphic with nothing to say.

NameDescriptionTypeDefault
Bar: getBars() / getLabels() / getAccessibleNames() / getBarCount() / hasValues() / getPlotHeight()WrBarChartHarness. Heights come back as a percentage of the chart’s maximum — the one number that survives a test with no layout, and the component’s actual job.Promise<…>—
Line: getSeriesLabels() / getLineCount() / getDotCount() / getYTicks() / getXLabels() / hasGrid() / hasLegend()WrLineChartHarness. Comparing the line count with the legend catches a series that got filtered out of one of them.Promise<…>—
Line: hasTooltip() / getTooltipLabel() / getTooltipRows()Readable, but there is no hoverAt(): the tooltip resolves the cursor’s x against a MEASURED plot, and jsdom reports 0×0, so a synthetic move lands nowhere.Promise<…>—
Donut: getSliceCount() / getLegend() / getCenterValue() / getCenterLabel() / getAccessibleName() / getSize()WrDonutChartHarness. The ring is aria-hidden and its slices are paths, so the legend is the only textual form the data has — and the chart’s own name is what survives showLegend: false.Promise<…>—
Sparkline: isDecorative() / getRole() / getAccessibleName() / hasLine() / hasArea() / hasTip() / getColor()WrSparklineHarness. Named means role="img"; unnamed means aria-hidden. The pair is the assertion — a nameless graphic a screen reader stops on is the failure.Promise<…>—
Gauge: getRole() / getValue() / getMin() / getMax() / getValueText() / getDisplayValue() / getSuffix()WrGaugeHarness. The arc is aria-hidden and the printed number is optional, so role="meter" plus aria-valuetext is the whole readable surface — a gauge with showValue off still announces 72%.Promise<…>—
Meter group: getValue() / getMax() / getSlices() / getLegendLabels() / getLegendValues() / hasLegend()WrMeterGroupHarness. The bar is ONE progressbar carrying the total; the bands announce nothing and are read by their title and their inline share.Promise<…>—
Heatmap: getCellCount() / getCells() / getValueFor(iso) / getWeekdayLabels() / getMonthLabels() / hasLabels()WrCalendarHeatmapHarness. Every square is aria-hidden and the grid is one named image — a year of announced days would be unusable — so a square’s title is the only text there is. Four weekday labels are blank by design.Promise<…>—

Event calendar

A chip lives in the cell its event starts in.

it('moves an event with the keyboard', async () => {
  const calendar = await loader.getHarness(WrEventCalendarHarness);

  expect(await calendar.getView()).toBe('month');
  expect(await calendar.getChipLabels()).toHaveLength(3);

  const [standup] = await calendar.getChips({ title: 'Standup' });
  await standup.move('right');

  // The calendar only EMITS — applying the change is the host's job.
  expect(changed()?.kind).toBe('move');
  expect(changed()?.start.getDate()).toBe(15);
});

Every chip sits inside the role="gridcell" where its event STARTS and reaches out from there — a calc() width for a band, a percentage height for a timed block — which is what lets getCellChips() scope to one cell at all. Cells are addressed by their DAY and, in a time view, the minutes from midnight of their row: those come off the data attributes the drag's own hit-testing publishes, and they are the only names that mean the same thing in all three views.

Moving and resizing go through the keyboard — Alt and an arrow to move, Alt + Shift to resize — which is the accessible path and the only one a test without layout can drive. Both only EMIT: events is an input the calendar never writes to, so a spec whose host ignores eventChange is asserting a cancelled gesture. One jsdom note: an editable chip's click needs document.elementFromPoint stubbed, because the CDK dispatches a real pointer sequence and the chip hit-tests for the cell under it.

NameDescriptionTypeDefault
getView() / getTitle() / previous() / next() / goToday() / setView(label) / getViewLabels() / getActiveViewLabel()The view is read off the GRID rather than the view model — month has its own shape, and week and day are told apart by their column count. The switcher is addressed by the label it prints, since the view value never reaches the DOM.Promise<…>—
getChips(filters?) / getChipLabels() / getCellChips(day, minutes?)A multi-day event has ONE chip, in the cell it starts in — so this counts events in view, not event-days. Filters: label, title, band.Promise<…>—
clickCell(day, minutes?) / getCellLabel(day, minutes?) / focusCell(day, minutes?) / getCursor() / pressArrow(arrow) / pressHome() / pressEnd()minutes is -1 for a month cell or the all-day band. The grid roves a single tab stop rather than making every cell tabbable, and getCursor() is where it is.Promise<…>—
getWeekdayNames() / getDayNumbers() / getSlotLabels() / hasAllDayRow() / getOverflowLabels() / openOverflow()The month grid is always six weeks. The all-day row appears only when something needs it, and the “+N more” button only when a cell holds more than maxLanes lets it show.Promise<…>—
Chip: getLabel() / getTitle() / getTime() / isBand() / continuesBefore() / continuesAfter() / click() / move(arrow) / resize(arrow)WrEventCalendarChipHarness. The drawn text is aria-hidden, so getLabel() is what is announced and getTitle() what is printed. move() is Alt + arrow, resize() adds Shift and moves the end alone.Promise<…>—

Image cropper

The smallest honest surface here.

it('opens a crop window once the image is measured', async () => {
  const cropper = await loader.getHarness(WrImageCropperHarness);

  // jsdom loads nothing, so hand the <img> the two numbers the component reads.
  const img = fixture.nativeElement.querySelector('.wr-image-cropper__image');
  img.getBoundingClientRect = () => ({ width: 400, height: 400, x: 0, y: 0 });
  Object.defineProperty(img, 'naturalWidth', { value: 800 });
  Object.defineProperty(img, 'naturalHeight', { value: 800 });

  await cropper.dispatchImageLoad();

  expect(await cropper.getCropBox()).toEqual({ x: 80, y: 80, width: 240, height: 240 });
});

Almost everything the cropper does is measured — the image's rendered box, its naturalWidth, the conversion between display and source pixels, the pointer deltas — and jsdom measures nothing. So there is deliberately no moveCrop() or resizeCrop(): either would divide by a zero-sized rect, write NaN, and report success. What a spec can do is give the image the two numbers onImageLoad reads and call dispatchImageLoad(); from there the crop window's geometry is written inline and is a real answer.

Note that isEmpty() and isReady() are different questions: a cropper with a perfectly good src shows nothing but the image until it has been measured. And the cropper renders no buttons at all — crop() and cropRect() are component API a consumer reaches through a viewChild, which is outside what any harness can see.

NameDescriptionTypeDefault
isEmpty() / getEmptyText() / getImageSrc() / isReady()Two different questions: a cropper with a src is not empty, but it is not READY until the image has been measured — the crop window is a fraction of the rendered box.Promise<…>—
dispatchImageLoad()Fires the event, not a real load. Stub getBoundingClientRect() and naturalWidth on the <img> first, or the crop UI stays shut — which is what a browser does with a zero-sized image too.Promise<void>—
getCropBox() / getHandles() / hasBackdrop()The crop window in DISPLAY pixels, from the inline styles. Not what a consumer receives: cropRect() converts to the image’s natural pixels, and that is component API rather than DOM.Promise<…>—

Window

Non-modal, several at once, and a rail for the rest.

it('minimizes to the taskbar and back', async () => {
  windows.open(EditorComponent, { title: 'Untitled.md' });
  await fixture.whenStable();

  const win = await rootLoader.getHarness(WrWindowHarness.with({ title: 'Untitled.md' }));
  await win.minimize();

  // Minimized is a state, not a dismissal.
  expect(await win.isOpen()).toBe(true);
  expect(await win.getState()).toBe('minimized');

  const taskbar = await loader.getHarness(WrWindowTaskbarHarness);
  expect(await taskbar.getTabTitles()).toEqual(['Untitled.md']);

  await taskbar.restore('Untitled.md');
  expect(await win.getState()).toBe('normal');
});

WrWindow is not exported — a window is always a WrWindowManager.open() call — so the harness comes from documentRootLoader(), while <wr-window-taskbar> is an element in your template and comes from the normal one. The window harness is a content container, like the dialog's: the component you opened is projected into the body, so a nested harness reads ITS content and cannot reach another window's — which matters here, because windows are non-modal and several are normally open.

Geometry comes from the inline styles the component writes, never from a measured box: getBox() is how you assert a move, a resize or a snap, and getZIndex() is how you assert that clicking a window brought it to the front. Two states to keep apart: MINIMIZED is not closed — isOpen() stays true — and the same button minimizes and restores, which is why getMinimizeLabel() changes with the state. On Linux the chrome renders the close button alone by DEFAULT — showMinimize: true puts the button back, so assert the configuration you opened rather than an invariant.

NameDescriptionTypeDefault
getTitle() / getState() / isOpen() / getBodyText() / getStatusBarText() / getTitleExtraText()isOpen() is for a harness you are HOLDING, and stays true for a MINIMIZED window — that is a state, not a dismissal.Promise<…>—
close() / minimize() / maximize() / doubleClickChrome() / sendEscape() / focusWindow()The same button minimizes and restores, and the same one maximizes and restores down. A button the chrome does not render throws, naming why — showClose: false, or the Linux chrome.Promise<void>—
getBox() / getZIndex() / isResizable() / getResizeHandles()Geometry from the inline styles the component writes — the only readable answer without layout, and the same numbers a drag or a snap changes. getZIndex() is how you assert the stack order.Promise<…>—
getCloseLabel() / getMinimizeLabel() / getMaximizeLabel() / hasCloseButton() / hasMinimizeButton() / hasMaximizeButton() / getOs() / getChromeSize()The chrome is icon-only, so those labels are the buttons’ only names — and they change with the state and with the OS the chrome is dressed as.Promise<…>—
getRole() / isLabelledByTitle() / isHiddenFromAssistiveTech()A non-modal role="dialog" named by its own title element — resolved rather than trusted, since with several windows open the ids are what keeps them apart.Promise<…>—
Taskbar: isEmpty() / getTabTitles() / restore(title) / closeTab(title) / getRestoreLabel(title) / getRailLabel() / getPosition()WrWindowTaskbarHarness, from the FIXTURE loader. An untitled window still gets a named tab. closeTab() closes without restoring, which is the nesting worth asserting.Promise<…>—

Calendar

Three views behind one header.

it('picks a day and walks the views', async () => {
  const calendar = await loader.getHarness(WrCalendarHarness);

  expect(await calendar.getHeaderLabel()).toBe('March 2026');
  await (await calendar.getDay(20)).click();
  expect(await calendar.getSelectedDayNumbers()).toEqual([20]);

  await calendar.clickHeader();               // day -> month
  await calendar.selectChip('Jun');           // and back down
  expect(await calendar.getHeaderLabel()).toBe('June 2026');
});

Clicking the header walks day → month → year, and each view renders something else — a role="grid" of day cells, or a role="listbox" of chips. Every day method throws off the day view rather than answering with an empty list, which would read as a month with no days. getDay(20) addresses a day of the DISPLAYED month: the grid is always six weeks, so most numbers appear twice and the spill days are skipped.

The roving cell is not the selection, and it does not follow next() — paging is a view change, so a grid moved away from the roving month has no tab stop at all until a key or a click puts one back. getActiveDayNumber() answers null there, which is the behaviour rather than a failure. Keys go to the host, where the component listens, so a spec does not depend on jsdom having focused anything.

WrCalendarDayHarness is the same class ngwr/date-picker/testing exports as WrDatePickerDayHarness — a picker's popup IS a calendar, and there is one implementation of its cell. Either name works with either query.

NameDescriptionTypeDefault
getView() / getHeaderLabel() / clickHeader() / previous() / next()The header walks day → month → year and is disabled at the top, where clickHeader() throws rather than clicking a button the DOM ignores.Promise<…>—
getDays(filters?) / getDay(n) / getSelectedDayNumbers() / getInRangeDayNumbers() / getActiveDayNumber()getDay(n) skips the spill days of the neighbouring months. getActiveDayNumber() is the roving tab stop, and null is a real answer after paging. Filters: text, selected, disabled, inRange.Promise<…>—
getMonths() / getYears() / selectChip(label)The chips of the two picker views, each with its selected / current / disabled state. Throws on the day view, which has none.Promise<…>—
pressArrow(arrow) / pressHome() / pressEnd() / pressPageUp({ shift }) / pressPageDown({ shift }) / pressEnter()Sent to the host, where the component listens. Home and End are the ends of the WEEK, not of the month; shift turns a page into a year.Promise<void>—
getWeekdayNames() / getWeekCount() / getGridRole() / getPreviousLabel() / getNextLabel() / getMode() / isDisabled()Six rows always, so the grid never reflows. The grid role is on the body rather than the host, which also holds the nav. The arrow names change with the view.Promise<…>—
Day: getText() / getAccessibleName() / getDayOfMonth() / isSelected() / isDisabled() / isToday() / isOutOfMonth() / isInRange() / isActive() / click()WrCalendarDayHarness — the same class ngwr/date-picker/testing exports as WrDatePickerDayHarness. getText() is the drawn number and getAccessibleName() is what a screen reader says; they are two questions, and before v14 the second had no answer.Promise<…>—

Tour

One card at a time, and last is not a count.

it('walks the tour', async () => {
  tour.start(steps);
  await fixture.whenStable();

  const first = await rootLoader.getHarness(WrTourHarness);
  expect(await first.getTitle()).toBe('Search');
  expect(await first.hasBack()).toBe(false);

  await first.next();

  // The card is rebuilt per step, so fetch a fresh harness rather than reusing one.
  const second = await rootLoader.getHarness(WrTourHarness);
  expect(await second.getProgress()).toEqual({ current: 2, total: 2 });
  expect(await second.getPrimaryLabel()).toBe('Done');
});

WrTour is a service with no element of its own, so the card comes from documentRootLoader() and there is nothing to load while the tour is idle — which makes getHarnessOrNull() the way to ask whether one is running. Only one card exists at a time: the service tears a step down before opening the next, so a harness held across next() is pointed at a detached element.

The assertion worth writing is getPrimaryLabel(), not the count. A step whose target is not on the page is skipped, so a three-step tour can show two — and the service looks AHEAD for a reachable step to decide whether the button reads "Next" or "Done". current === total disagrees with it exactly when the last step is hidden, which is the case that used to end the tour from a card still saying "Next".

NameDescriptionTypeDefault
getTitle() / getContent() / getProgressText() / getProgress()The step as printed. getProgress() parses the two numbers out of the line and answers null rather than guessing if a catalog spells them differently; its total counts the steps the tour was STARTED with, skipped ones included.Promise<…>—
next() / back() / skipTour() / hasBack()The three buttons, driven through WrButtonHarness. back() throws on the first step, where the button is not rendered at all.Promise<…>—
getPrimaryLabel() / getBackLabel() / getSkipLabel()getPrimaryLabel() is how you tell the last step: the service looks ahead for a reachable target, so it reads "Done" one card early when the final step is hidden — which no count can show.Promise<string>—
isShowing() / isModal() / getAccessibleName()isShowing() is for a harness you are HOLDING — it goes false when the step is torn down. The card is an aria-modal dialog named by its title plus the progress line, or by the progress line alone.Promise<…>—

Lightbox

A viewer that does not exist until it does.

it('opens the full image', async () => {
  const lightbox = await loader.getHarness(WrLightboxHarness.with({ alt: 'Mountain' }));

  // Nothing to read yet — the viewer is an overlay that does not exist while shut.
  await expect(lightbox.getFullSrc()).rejects.toThrow();

  await lightbox.open();
  expect(await lightbox.isModal()).toBe(true);
  expect(await lightbox.getFullSrc()).toContain('/photo.jpg');

  await lightbox.sendEscape();
  expect(await lightbox.isOpen()).toBe(false);
});

The thumbnail is in your fixture and the viewer is an overlay, so the harness crosses between them — scoped by the id the trigger publishes as aria-controls, which means two lightboxes open at once still answer for their own image. Unlike a collapse or a speed dial, a closed lightbox has no viewer at ALL: the overlay is created on open and disposed on close, so every viewer read throws while it is shut rather than answering about a stale element.

Two things worth asserting that nothing else covers. clickImage() drives the full image, which is styled cursor: zoom-out and deliberately kept OUT of the tab order — a mouse affordance whose promise nothing else checks. And [disablePreview] removes the button rather than disabling it, so the thumbnail becomes a bare <img>: isInteractive() is how you tell, and open() explains itself instead of failing to find an element.

NameDescriptionTypeDefault
isOpen() / open() / close() / clickImage() / sendEscape()Open state from the host modifier — the viewer itself is gone when closed, so a query for it cannot tell "shut" from "never opened". clickImage() drives the zoom-out affordance, which is not a tab stop.Promise<…>—
getAlt() / getThumbSrc() / getFullSrc()The thumbnail shows preview when there is one and never swaps to src, so the two sources are different questions.Promise<string>—
getCaption() / getViewerLabel() / getCloseLabel() / isModal() / isFocusTrapped()Everything inside the viewer, and all of it throws while closed. isFocusTrapped() is what makes aria-modal true rather than a claim — hand jsdom a box first, or the trap finds nothing tabbable.Promise<…>—
isInteractive() / getOpenLabel() / isLoading() / isViewerBound()disablePreview removes the button entirely. isLoading() clears on a FAILED load too — the loading state hides the image, so a broken src would otherwise shimmer for ever.Promise<…>—

Speed dial

The actions are always in the DOM.

it('fans out and picks', async () => {
  const dial = await loader.getHarness(WrSpeedDialHarness);

  // Closed, the buttons are in the DOM but unreachable — so the harness refuses.
  await expect(dial.getActions()).rejects.toThrow();

  await dial.open();
  expect(await dial.getActionLabels()).toEqual(['Share', 'Copy link']);

  await dial.sendEscape();
  expect(await dial.isTriggerFocused()).toBe(true);
});

A collapsed dial still holds its buttons — the fan-out has to have something to animate — and what actually hides them is visibility, which is also what keeps them out of the tab order. A unit test applies no CSS, so those buttons look perfectly reachable to a query. getActions() and pick() therefore refuse while the dial is closed; getActionCount() answers either way, because counting is not reaching.

An action draws an ICON, or the first glyph of its label — so its visible text is "C" for "Copy link". getLabel() reads the accessible name and getInitial() what is drawn. The Escape assertion is worth writing: role="menu" promises a way out, and the actions are ordinary tab stops, so focus has to come back to the trigger.

NameDescriptionTypeDefault
isOpen() / open() / close() / toggle() / sendEscape()Open state from the trigger’s aria-expanded. Escape closes AND returns focus to the trigger, which is the half worth asserting.Promise<…>—
getActions(filters?) / getActionLabels() / pick(filters) / getActionCount()The first three refuse while the dial is closed — the buttons are still in the DOM and only visibility hides them. pick() opens first, then clicks. Filters: label, disabled.Promise<…>—
getTriggerLabel() / getTriggerIcon() / getDirection() / hasSafeArea() / isDisabled()The trigger is icon-only, so its aria-label is its ONLY name. Direction comes from the host modifier.Promise<…>—
getMenuRole() / isMenuBound()The aria-controls pairing, checked in both directions: the menu must be this dial’s own, and no other element may answer to the same id.Promise<…>—
Action: getLabel() / getInitial() / hasIcon() / getIconName() / getRole() / isDisabled() / click()WrSpeedDialActionHarness. getLabel() is the accessible name; getInitial() is the single glyph drawn when there is no icon — a whole emoji, not half a surrogate pair.Promise<…>—

Knob

The number it announces, and the string it prints.

it('turns the dial from the keyboard', async () => {
  const knob = await loader.getHarness(WrKnobHarness);

  await knob.setValue(60);

  // The announced number and the printed string are different questions.
  expect(await knob.getValue()).toBe(60);
  expect(await knob.getDisplayValue()).toBe('60%');

  await knob.pressEnd();
  expect(await knob.getValue()).toBe(await knob.getMax());
});

getValue() is aria-valuenow; getDisplayValue() is the text in the middle of the dial, which a suffix makes "50%" while the announced value stays 50. Two methods because they can disagree — and because [showValue]="false" takes the text away without touching the value.

setValue() walks with the arrows and measures the step as it goes: step is an input with no DOM presence, and a value written from outside is clamped but never snapped, so the first press can move by less than a step as it lands on the grid. A target BETWEEN two grid points is only discovered by overshooting it, and the error says where it stopped. There is no turnTo() — the drag measures an angle from the centre of a box a unit test never lays out, so it would write the same end of the arc for any coordinates and report success.

NameDescriptionTypeDefault
getValue() / getMin() / getMax()The dial as role="slider" reports it — the three aria-value* attributes.Promise<number>—
getDisplayValue() / getSuffix()The text in the middle of the dial, suffix included, or null when showValue is off. Not the same as the announced value, and deliberately not merged with it.Promise<string | null>—
pressArrow(arrow, { shift }) / pressHome() / pressEnd() / setValue(n)All four arrows are live, and shift is ten steps. setValue walks and measures the step as it goes, then asserts its landing; a target between grid points throws with where it stopped.Promise<void>—
getHandlePosition()The handle dot in viewBox units, from its cx / cy — the arc and the dot are the whole visual, and neither can be measured without layout.Promise<{ x: number; y: number }>—
isDisabled() / isReadonly() / isFocusable() / focus() / blur() / getLabel()Both off states leave the tab order, so isFocusable() is worth asking separately: a read-only dial a keyboard cannot reach is one nobody can read either. blur() is what emits touch.Promise<…>—

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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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.

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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);
});
NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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);
});
NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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<…>—

Graphs

Ids on the lines, words on the nodes.

it('reads the structure, not the drawing', async () => {
  const graph = await loader.getHarness(WrGraphHarness.with({ ariaLabel: 'Team structure' }));

  // Lines speak in ids — the only place the DOM writes one — in the order given.
  expect(await graph.getEdges()).toEqual([
    { from: 'lead', to: 'design' },
    { from: 'lead', to: 'build' },
  ]);

  // Two questions, not one: what the node draws, and what it announces.
  const design = await graph.getNode({ label: 'Design' });
  expect(await design.getText()).toBe('Design');
  expect(await design.getRelationText()).toBe('Parents: Lead');
});

The lines are aria-hidden, so a node's relation sentence is the only form the structure takes for a screen reader — which is why getText() and getRelationText() are two methods: read together, a spec would pass on a graph that named the wrong neighbours. A node's id is written nowhere in the DOM, so nodes are addressed by what they draw and ids are read off the edges.

Three things are deliberately not offered: a path's d, node positions, and "is scrollable". jsdom lays nothing out, and the coordinates belong to a layout that is internal in this release — the layout's own spec pins them. Every query is anchored to the graph's own parts, so a graph drawn inside a node template never answers for the one around it.

WrGraphHarness / WrGraphNodeHarness

ngwr/graph/testing

NameDescriptionTypeDefault
getAccessibleName() / getNodes(filters?) / getNode(filters)The viewport's name, and this graph's own nodes in READING order — layer, then inline position — which is DOM order. Node filter: label, matched against the drawn text. Graph filters: ariaLabel, nodeLabel.Promise<…>—
getEdges()One { from, to } per line, off data-from / data-to, in the order given with skipped edges left out. Throws on a graph that draws no nodes: [] would read the same for empty data and for a layout that drew nothing.Promise<{ from: string; to: string }[]>—
A node adds: getText() / getRelationText()What the node draws (the card or your template, hidden text left out) and the sentence a screen reader hears about its parents and children — null for a node with no edges.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

NameDescriptionTypeDefault
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<…>—

Editors

The drawn text here, the value in the model.

it('writes through the editor, and reads the value from the model', async () => {
  const body = await loader.getHarness(WrEditorHarness.with({ label: 'Body' }));

  await body.setText('Release notes');
  await body.selectAll();
  await (await body.getTool('Bold')).click();

  // Two questions: what the surface draws, and what the model holds.
  expect(await body.getText()).toBe('Release notes');
  expect(await body.isToolPressed('Bold')).toBe(true);
  expect(fixture.componentInstance.body()).toBe('<p><strong>Release notes</strong></p>');

  // A refused address leaves the panel open with its message.
  await body.setLink('javascript:alert(1)');
  expect(await body.getLinkError()).toBe('This address cannot be used as a link.');
});

The value is an HTML string, a markdown string or a JSON tree, written by the editor's codec and never by the DOM, so the harness does not pretend to read it — a string rebuilt from the surface would be a fourth format that agrees with none of the three. Typing is refused for the reason pressKey() gives: a browser inserts the character into a contenteditable and ProseMirror reads the change, and jsdom inserts nothing. setText() writes the surface the way the CDK defines writing a contenteditable, which ProseMirror reads as a real change, so the edit reaches the model through the editor's own path.

Your spec installs one stub: ProseMirror scrolls the selection into view after an edit made with focus, and jsdom's Range has no getClientRects(). Without it the edit throws from a DOM listener after the document changed, the model keeps its old value, and the error surfaces only as an unhandled one. The harness class docs have the three lines. There is no caret placement beyond selectAll(), no heights, and no "is the placeholder visible" — it is CSS content, so getPlaceholder() reads what is announced instead.

WrEditorHarness

ngwr/editor/testing

NameDescriptionTypeDefault
getText() / getLabel() / getPlaceholder() / isMounted()What the surface DRAWS, one textblock per line, the task-item words left out; the accessible name, with a field label resolved through aria-labelledby; the placeholder as announced, null once the document holds anything. The value is not here — read it from the model you bound. Filters: label, text, disabled, readonly.Promise<…>—
setText(text) / paste(text) / pasteHtml(html, text?) / selectAll() / pressKey(key, modifiers?)The writes. setText replaces the document through the DOM change ProseMirror reads; paste / pasteHtml insert at the selection, through the same schema a bound value goes through; pressKey refuses a printable character with no modifier, since a key event types nothing in jsdom.Promise<void>—
getTool(label) / getToolLabels() / isToolPressed(label) / getToolShortcuts(label) / getToolbarTabStop()A tool by its accessible name, as a WrButtonHarness; the drawn tools; aria-pressed (throws for a tool that is not a toggle); aria-keyshortcuts; the one tool in the tab order.Promise<…>—
openLinkPanel() / setLink(url) / removeLink() / getLinkError() / isLinkPanelOpen()The link panel, scoped by the id its button publishes — openLinkPanel() hands back the WrPopoverHarness. setLink resolves either way; a refused address leaves the panel open and getLinkError() reads its message.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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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();
});
NameDescriptionTypeDefault
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.

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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

NameDescriptionTypeDefault
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);
});
NameDescriptionTypeDefault
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/);
});
NameDescriptionTypeDefault
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.

NameDescriptionTypeDefault
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>—
WrMarkdownCodeBlockHarnessgetLanguage(), getCode() (exact, never trimmed), isHighlighted(), canCopy(), getCopyLabel(), copy().ComponentHarness—

Names, for the tests that are not harness tests

A harness queries by class and by role, so it never has to know what a control is CALLED. An end-to-end test does — the accessible name IS the locator, Go to page 2 and nothing else — and until you read the i18n catalog those strings are undiscoverable. These are the ones the library renders itself.

Built-in accessible names, and the two ways to change one.
ElementDefault nameOverride
Pagination landmarkPagination (on role="navigation")[label] · pagination.label
Pagination page cellGo to page 3pagination.goToPage
Pagination previous / nextPrevious page · Next page[prevLabel] / [nextLabel] · pagination.prev / .next
Pagination size changerItems per pagepagination.itemsPerPage
Table sort buttonSort column — the same on EVERY sortable column[sortLabel] · table.sort
Table row / all checkboxesSelect row · Select all rowstable.selectRow / .selectAll
Table loading overlayLoading… (on role="status")[loadingLabel] · table.loading
Dialog / drawer ✕Close dialogcloseLabel · dialog.close
Select clear ✕Clear selectionselect.clearSelection
Select chip removeRemove Berlinselect.removeItem

Two of those are traps. Every sort button carries the same name, so a query for a button named Sort column is ambiguous the moment a table has two sortable columns — scope the query to the header cell, or use WrTableHeaderCellHarness, which addresses a column by its visible title. And an aria-label written on a component's HOST names nothing: the host of <wr-select> carries no role, and the role="combobox" lives on the trigger inside it. That is why the components that need one expose an ariaLabel INPUT — bind that.

Naming a table

<wr-table> renders no <caption> and takes no name input, so a screen reader announces it as “table” and a page with two of them offers no way to tell which is which. That is the current contract, not an oversight you can configure around — the <table> element is inside the component and out of reach.

<!-- <wr-table> renders no <caption> and takes no name input, so name the
     REGION around it. The heading does double duty: visible on the page,
     and the landmark's accessible name. -->
<section role="region" aria-labelledby="open-orders-heading">
  <h2 id="open-orders-heading">Open orders</h2>
  <wr-table [columns]="cols" [items]="orders()" />
</section>

<!-- No visible heading to point at? Name the region directly. -->
<section role="region" aria-label="Archived orders">
  <wr-table [columns]="cols" [items]="archived()" />
</section>

Wrapping in a named region is the recipe, and it is worth doing even for one table: the name reaches the landmark list, so a screen-reader user can jump to “Open orders” directly, and an end-to-end test gets a stable scope — a region role with that accessible name — to run every other query inside. Use a real heading as the label where the page already has one — a <caption>-shaped aria-label that duplicates a visible <h2> is two names for one thing.

Running axe over your own app

One result you will see on every page that has a select, and it is a contract rather than a defect. <wr-select>, <wr-cascader> and an openOn="overlay" <wr-tree> each publish aria-controls on their trigger naming the listbox they own — and the listbox is created when the panel opens, so while the control is closed that attribute names an id no element carries. A combobox is supposed to describe what it owns before you open it, which is why the attribute is written unconditionally; the two date pickers take the other route and drop it while closed, so they give you nothing to triage.

axe cannot tell a deliberate forward reference from a typo, so it does the only honest thing and asks: the rule is aria-valid-attr-value, the result is incomplete — “needs review” — and it carries critical impact, which makes it look far louder than it is. It is not a violation, and a gate that asserts on results.violations never sees it. A gate that treats incomplete as failure sees one per closed combobox on every page, forever.

// Gate on violations. `incomplete` is axe asking a human, not a finding.
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

// If your gate DOES read `incomplete` — some do, and it is a defensible
// choice — triage the rule rather than excluding the page or the component.
// Every closed combobox trigger publishes aria-controls for a listbox that
// does not exist until the panel opens, and axe cannot tell that apart from
// a typo. Drop only those nodes; keep the rule on for every other node.
const forReview = results.incomplete.filter(
  r =>
    r.id !== 'aria-valid-attr-value' ||
    r.nodes.some(n => !/aria-controls/.test(n.html) || !/aria-expanded="false"/.test(n.html)),
);
expect(forReview).toEqual([]);

Triage the rule, never the page. Suppressing aria-valid-attr-value for a route, or excluding the component's subtree, also hides the real thing that rule is for — an aria-labelledby pointing at an id you renamed, which is silent everywhere else and is exactly the class of bug this sweep exists to catch. Filtering the nodes keeps the rule live for everything else on the page.

And run it on a page in the state you care about. This project's own structural sweep reads prerendered HTML, so nothing inside an overlay is in it — no open panel, no dialog, no focus ring, no hover. Whatever your own gate asserts about an ngwr overlay, open the overlay in the test first; Quality describes the three sweeps here and what each of them cannot see.

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