@atomic-testing/component-driver-mui-v9 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/index.cjs +2439 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1391 -0
  6. package/dist/index.d.mts +1391 -0
  7. package/dist/index.mjs +2386 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +48 -0
  10. package/src/components/AccordionDriver.ts +109 -0
  11. package/src/components/AlertDriver.ts +74 -0
  12. package/src/components/AutoCompleteDriver.ts +151 -0
  13. package/src/components/AvatarDriver.ts +51 -0
  14. package/src/components/AvatarGroupDriver.ts +79 -0
  15. package/src/components/BadgeDriver.ts +43 -0
  16. package/src/components/BottomNavigationActionDriver.ts +23 -0
  17. package/src/components/BottomNavigationDriver.ts +138 -0
  18. package/src/components/ButtonDriver.ts +16 -0
  19. package/src/components/CheckboxDriver.ts +98 -0
  20. package/src/components/ChipDriver.ts +53 -0
  21. package/src/components/DialogDriver.ts +122 -0
  22. package/src/components/DrawerDriver.ts +90 -0
  23. package/src/components/FabDriver.ts +11 -0
  24. package/src/components/InputDriver.ts +112 -0
  25. package/src/components/ListDriver.ts +42 -0
  26. package/src/components/ListItemDriver.ts +34 -0
  27. package/src/components/MenuDriver.ts +65 -0
  28. package/src/components/MenuItemDriver.ts +15 -0
  29. package/src/components/OverlayDriver.ts +98 -0
  30. package/src/components/PaginationDriver.ts +117 -0
  31. package/src/components/ProgressDriver.ts +78 -0
  32. package/src/components/RadioDriver.ts +63 -0
  33. package/src/components/RadioGroupDriver.ts +129 -0
  34. package/src/components/RatingDriver.ts +121 -0
  35. package/src/components/SelectDriver.ts +223 -0
  36. package/src/components/SliderDriver.ts +109 -0
  37. package/src/components/SnackbarDriver.ts +68 -0
  38. package/src/components/SpeedDialDriver.ts +83 -0
  39. package/src/components/StepperDriver.ts +109 -0
  40. package/src/components/SwitchDriver.ts +62 -0
  41. package/src/components/TabDriver.ts +39 -0
  42. package/src/components/TableCellDriver.ts +14 -0
  43. package/src/components/TableDriver.ts +148 -0
  44. package/src/components/TablePaginationDriver.ts +110 -0
  45. package/src/components/TableRowDriver.ts +79 -0
  46. package/src/components/TabsDriver.ts +133 -0
  47. package/src/components/TextFieldDriver.ts +155 -0
  48. package/src/components/ToggleButtonDriver.ts +21 -0
  49. package/src/components/ToggleButtonGroupDriver.ts +75 -0
  50. package/src/components/TooltipDriver.ts +82 -0
  51. package/src/errors/MenuItemDisabledError.ts +17 -0
  52. package/src/errors/MenuItemNotFoundError.ts +17 -0
  53. package/src/index.ts +52 -0
@@ -0,0 +1,117 @@
1
+ import { byAttribute, byCssSelector, ComponentDriver, locatorUtil, PartLocator } from '@atomic-testing/core';
2
+
3
+ // Numbered page controls are the only ones that carry the `MuiPaginationItem-page`
4
+ // state class; matching on it selects every page button regardless of selection.
5
+ const pageItemLocator = byCssSelector('.MuiPaginationItem-page');
6
+ // MUI marks exactly the selected page with an `aria-current` attribute (value
7
+ // "page"); matching on its presence is version-agnostic.
8
+ const selectedPageLocator = byCssSelector('[aria-current]');
9
+ const firstButtonLocator = byAttribute('aria-label', 'Go to first page');
10
+ const previousButtonLocator = byAttribute('aria-label', 'Go to previous page');
11
+ const nextButtonLocator = byAttribute('aria-label', 'Go to next page');
12
+ const lastButtonLocator = byAttribute('aria-label', 'Go to last page');
13
+
14
+ /**
15
+ * Driver for the Material UI v9 Pagination component.
16
+ *
17
+ * Pagination renders a `<nav>` whose `MuiPaginationItem` buttons are each wrapped
18
+ * in their own `<li>` (so they are not siblings — positional `:nth-of-type`
19
+ * iteration does not apply). Page controls are read by their accessible name
20
+ * instead: every page button's aria-label ends in its page number ("Go to page N",
21
+ * or "page N" once selected), and first/previous/next/last carry stable
22
+ * aria-labels and become `disabled` at the bounds.
23
+ * @see https://mui.com/material-ui/react-pagination/
24
+ */
25
+ export class PaginationDriver extends ComponentDriver {
26
+ private get pageItemsLocator(): PartLocator {
27
+ return locatorUtil.append(this.locator, pageItemLocator);
28
+ }
29
+
30
+ /**
31
+ * The selected page number, or `-1` when no page is marked current.
32
+ */
33
+ async getSelectedPage(): Promise<number> {
34
+ const locator = locatorUtil.append(this.locator, selectedPageLocator);
35
+ if (!(await this.interactor.exists(locator))) {
36
+ return -1;
37
+ }
38
+ const text = await this.interactor.getText(locator);
39
+ const page = Number.parseInt(text?.trim() ?? '', 10);
40
+ return Number.isNaN(page) ? -1 : page;
41
+ }
42
+
43
+ /**
44
+ * Total number of pages, taken from the highest numbered page control. MUI
45
+ * always renders the upper boundary page (boundaryCount >= 1), so this is exact
46
+ * even when middle pages collapse into an ellipsis.
47
+ */
48
+ async getPageCount(): Promise<number> {
49
+ const labels = await this.interactor.getAttribute(this.pageItemsLocator, 'aria-label', true);
50
+ let max = 0;
51
+ for (const label of labels) {
52
+ const match = label?.match(/(\d+)\s*$/);
53
+ if (match != null) {
54
+ const page = Number.parseInt(match[1], 10);
55
+ if (page > max) {
56
+ max = page;
57
+ }
58
+ }
59
+ }
60
+ return max;
61
+ }
62
+
63
+ /**
64
+ * Click the numbered control for `page`. A no-op (returns `true`) when `page` is
65
+ * already selected.
66
+ * @returns `false` when that page is not currently rendered (e.g. hidden behind
67
+ * an ellipsis) or is disabled.
68
+ */
69
+ async goToPage(page: number): Promise<boolean> {
70
+ if ((await this.getSelectedPage()) === page) {
71
+ return true;
72
+ }
73
+ const locator = locatorUtil.append(this.locator, byAttribute('aria-label', `Go to page ${page}`));
74
+ if (!(await this.interactor.exists(locator)) || (await this.interactor.isDisabled(locator))) {
75
+ return false;
76
+ }
77
+ await this.interactor.click(locator);
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * Click a navigation control unless it is absent or disabled (at a bound).
83
+ * @returns whether the click was performed.
84
+ */
85
+ private async clickNavButton(navLocator: PartLocator): Promise<boolean> {
86
+ const locator = locatorUtil.append(this.locator, navLocator);
87
+ if (!(await this.interactor.exists(locator)) || (await this.interactor.isDisabled(locator))) {
88
+ return false;
89
+ }
90
+ await this.interactor.click(locator);
91
+ return true;
92
+ }
93
+
94
+ /** Go to the next page. Returns `false` when on the last page or the control is hidden. */
95
+ async next(): Promise<boolean> {
96
+ return this.clickNavButton(nextButtonLocator);
97
+ }
98
+
99
+ /** Go to the previous page. Returns `false` when on the first page or the control is hidden. */
100
+ async previous(): Promise<boolean> {
101
+ return this.clickNavButton(previousButtonLocator);
102
+ }
103
+
104
+ /** Jump to the first page. Returns `false` when already there or the control is hidden (needs `showFirstButton`). */
105
+ async first(): Promise<boolean> {
106
+ return this.clickNavButton(firstButtonLocator);
107
+ }
108
+
109
+ /** Jump to the last page. Returns `false` when already there or the control is hidden (needs `showLastButton`). */
110
+ async last(): Promise<boolean> {
111
+ return this.clickNavButton(lastButtonLocator);
112
+ }
113
+
114
+ override get driverName(): string {
115
+ return 'MuiV9PaginationDriver';
116
+ }
117
+ }
@@ -0,0 +1,78 @@
1
+ import { HTMLRadioButtonGroupDriver } from '@atomic-testing/component-driver-html';
2
+ import {
3
+ byCssSelector,
4
+ byInputType,
5
+ byValue,
6
+ ComponentDriver,
7
+ IComponentDriverOption,
8
+ IInputDriver,
9
+ Interactor,
10
+ locatorUtil,
11
+ PartLocator,
12
+ ScenePart,
13
+ } from '@atomic-testing/core';
14
+
15
+ export const parts = {
16
+ choices: {
17
+ locator: byInputType('radio'),
18
+ driver: HTMLRadioButtonGroupDriver,
19
+ },
20
+ } satisfies ScenePart;
21
+
22
+ export class ProgressDriver extends ComponentDriver<typeof parts> implements IInputDriver<number | null> {
23
+ constructor(locator: PartLocator, interactor: Interactor, option?: Partial<IComponentDriverOption>) {
24
+ super(locator, interactor, {
25
+ ...option,
26
+ parts,
27
+ });
28
+ }
29
+
30
+ async getValue(): Promise<number | null> {
31
+ const rawValue = await this.getAttribute('aria-valuenow');
32
+ const numValue = Number(rawValue);
33
+ if (rawValue == null || isNaN(numValue)) {
34
+ return null;
35
+ }
36
+ return Number(rawValue);
37
+ }
38
+
39
+ async getType(): Promise<'linear' | 'circular'> {
40
+ const cssClasses = await this.getAttribute('class');
41
+ if (cssClasses?.includes('MuiCircularProgress-root')) {
42
+ return 'circular';
43
+ }
44
+ return 'linear';
45
+ }
46
+
47
+ async isDeterminate(): Promise<boolean> {
48
+ const val = await this.getValue();
49
+ return val != null;
50
+ }
51
+
52
+ //TODO: Buffer value can be extracted from style="transform: translateX(-15%);" actual value would be 100 - 15 = 85
53
+ // <span class="MuiLinearProgress-bar MuiLinearProgress-bar2 MuiLinearProgress-colorPrimary MuiLinearProgress-bar2Buffer css-1v1662g-MuiLinearProgress-bar2" style="transform: translateX(-15%);"></span>
54
+
55
+ async setValue(value: number | null): Promise<boolean> {
56
+ // TODO: Setting value to null is not supported. https://github.com/atomic-testing/atomic-testing/issues/68
57
+ const currentValue = await this.getValue();
58
+ if (value === currentValue) {
59
+ return true;
60
+ }
61
+
62
+ const valueToClick = (value == null ? currentValue : value) as number;
63
+ const targetLocator = locatorUtil.append(this.parts.choices.locator, byValue(valueToClick.toString(), 'Same'));
64
+
65
+ const targetExists = await this.interactor.exists(targetLocator);
66
+ if (targetExists) {
67
+ const id = await this.interactor.getAttribute(targetLocator, 'id');
68
+ const labelLocator = locatorUtil.append(this.locator, byCssSelector(`label[for="${id}"]`));
69
+ await this.interactor.click(labelLocator);
70
+ }
71
+ // TODO: throw error if the value does not exist
72
+ return targetExists;
73
+ }
74
+
75
+ get driverName(): string {
76
+ return 'MuiV9ProgressDriver';
77
+ }
78
+ }
@@ -0,0 +1,63 @@
1
+ import { byCssSelector, ComponentDriver, locatorUtil, Optional, PartLocator } from '@atomic-testing/core';
2
+
3
+ // The radio `<input>` carries checked/value/disabled; located as a descendant of
4
+ // this option's FormControlLabel root.
5
+ const inputLocator: PartLocator = byCssSelector('input');
6
+ const checkedInputLocator: PartLocator = byCssSelector('input:checked');
7
+
8
+ /**
9
+ * Driver for a single Material UI v9 Radio option (a `FormControlLabel` wrapping a
10
+ * `Radio`). Its label text is the `FormControlLabel`'s text; selected/value/disabled
11
+ * state is read from the underlying radio `<input>`.
12
+ *
13
+ * Used as the item driver of {@link RadioGroupDriver}, but also usable on its own
14
+ * when a single radio option is addressed directly. It declares no parts (so it
15
+ * composes as a list item) and reads the input via descendant locators.
16
+ * @see https://mui.com/material-ui/react-radio-button/
17
+ */
18
+ export class RadioDriver extends ComponentDriver {
19
+ private get input(): PartLocator {
20
+ return locatorUtil.append(this.locator, inputLocator);
21
+ }
22
+
23
+ /**
24
+ * The option's visible label, or `undefined` when it renders without text.
25
+ */
26
+ async getLabel(): Promise<Optional<string>> {
27
+ const text = await this.getText();
28
+ return text?.trim() || undefined;
29
+ }
30
+
31
+ /**
32
+ * The option's `value` attribute.
33
+ */
34
+ async getValue(): Promise<string | null> {
35
+ const value = await this.interactor.getAttribute(this.input, 'value');
36
+ return value ?? null;
37
+ }
38
+
39
+ /**
40
+ * Whether this option is the selected one in its group.
41
+ */
42
+ isSelected(): Promise<boolean> {
43
+ return this.interactor.exists(locatorUtil.append(this.locator, checkedInputLocator));
44
+ }
45
+
46
+ /**
47
+ * Whether this option is disabled.
48
+ */
49
+ isDisabled(): Promise<boolean> {
50
+ return this.interactor.isDisabled(this.input);
51
+ }
52
+
53
+ /**
54
+ * Select this option by clicking its radio input. No-op effect when already selected.
55
+ */
56
+ async select(): Promise<void> {
57
+ await this.interactor.click(this.input);
58
+ }
59
+
60
+ get driverName(): string {
61
+ return 'MuiV9RadioDriver';
62
+ }
63
+ }
@@ -0,0 +1,129 @@
1
+ import {
2
+ byCssSelector,
3
+ escapeUtil,
4
+ IComponentDriverOption,
5
+ IInputDriver,
6
+ Interactor,
7
+ ListComponentDriver,
8
+ ListComponentDriverSpecificOption,
9
+ locatorUtil,
10
+ Nullable,
11
+ PartLocator,
12
+ } from '@atomic-testing/core';
13
+
14
+ import { RadioDriver } from './RadioDriver';
15
+
16
+ /**
17
+ * Radio options are located by their `FormControlLabel` root, which wraps the
18
+ * radio `<input>` and renders the label text — the unit a {@link RadioDriver}
19
+ * drives.
20
+ */
21
+ export const defaultRadioGroupDriverOption: ListComponentDriverSpecificOption<RadioDriver> = {
22
+ itemClass: RadioDriver,
23
+ itemLocator: byCssSelector('.MuiFormControlLabel-root'),
24
+ };
25
+
26
+ type RadioGroupDriverOption<ItemT extends RadioDriver> = ListComponentDriverSpecificOption<ItemT> &
27
+ Partial<IComponentDriverOption<any>>;
28
+
29
+ /**
30
+ * Driver for the Material UI v9 RadioGroup component.
31
+ *
32
+ * `<RadioGroup>` renders a `role="radiogroup"` whose options are `FormControlLabel`s
33
+ * wrapping a radio `<input>`. This driver is a {@link ListComponentDriver} over those
34
+ * options, so per-option {@link RadioDriver} instances are available via
35
+ * `getItems`/`getItemByIndex`/`getItemByLabel`, on top of the group-level value and
36
+ * label helpers. The selected value is read from the checked `<input>` and selection
37
+ * is made by value or label.
38
+ * @see https://mui.com/material-ui/react-radio-button/
39
+ */
40
+ export class RadioGroupDriver<ItemT extends RadioDriver = RadioDriver>
41
+ extends ListComponentDriver<ItemT>
42
+ implements IInputDriver<string | null>
43
+ {
44
+ constructor(locator: PartLocator, interactor: Interactor, option: Partial<RadioGroupDriverOption<ItemT>> = {}) {
45
+ // The option shape is fixed (FormControlLabel options driven by RadioDriver),
46
+ // so defaults are merged in rather than relying on a default parameter, which a
47
+ // scene part's always-present option object would otherwise shadow.
48
+ super(locator, interactor, {
49
+ ...defaultRadioGroupDriverOption,
50
+ ...option,
51
+ } as RadioGroupDriverOption<ItemT>);
52
+ }
53
+
54
+ /**
55
+ * The `value` of the selected option, or `null` when none is selected.
56
+ */
57
+ async getValue(): Promise<string | null> {
58
+ const checked = locatorUtil.append(this.locator, byCssSelector('input:checked'));
59
+ const value = await this.interactor.getAttribute(checked, 'value');
60
+ return value ?? null;
61
+ }
62
+
63
+ /**
64
+ * Select the option whose radio `<input>` has the given `value`.
65
+ * @returns `false` when no option has that value, or when `value` is `null`.
66
+ */
67
+ async setValue(value: string | null): Promise<boolean> {
68
+ if (value == null) {
69
+ return false;
70
+ }
71
+ const input = locatorUtil.append(this.locator, byCssSelector(`input[value="${escapeUtil.escapeValue(value)}"]`));
72
+ if (!(await this.interactor.exists(input))) {
73
+ return false;
74
+ }
75
+ await this.interactor.click(input);
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * The visible label of every option, in DOM order.
81
+ */
82
+ async getOptions(): Promise<string[]> {
83
+ const items = await this.getItems();
84
+ const labels: string[] = [];
85
+ for (const item of items) {
86
+ labels.push((await item.getLabel()) ?? '');
87
+ }
88
+ return labels;
89
+ }
90
+
91
+ /**
92
+ * The label of the selected option, or `null` when none is selected.
93
+ */
94
+ async getSelectedLabel(): Promise<Nullable<string>> {
95
+ const items = await this.getItems();
96
+ for (const item of items) {
97
+ if (await item.isSelected()) {
98
+ return (await item.getLabel()) ?? null;
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Select the first option whose visible label equals `label`.
106
+ * @returns `false` when no option matches.
107
+ */
108
+ async selectByLabel(label: string): Promise<boolean> {
109
+ const option = await this.getItemByLabel(label);
110
+ if (option == null) {
111
+ return false;
112
+ }
113
+ await option.select();
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Whether the option with the given label is disabled.
119
+ * @returns `false` when no option matches.
120
+ */
121
+ async isOptionDisabled(label: string): Promise<boolean> {
122
+ const option = await this.getItemByLabel(label);
123
+ return option == null ? false : option.isDisabled();
124
+ }
125
+
126
+ override get driverName(): string {
127
+ return 'MuiV9RadioGroupDriver';
128
+ }
129
+ }
@@ -0,0 +1,121 @@
1
+ import { HTMLRadioButtonGroupDriver } from '@atomic-testing/component-driver-html';
2
+ import {
3
+ byCssClass,
4
+ byCssSelector,
5
+ byInputType,
6
+ byValue,
7
+ ComponentDriver,
8
+ IComponentDriverOption,
9
+ IInputDriver,
10
+ Interactor,
11
+ locatorUtil,
12
+ PartLocator,
13
+ ScenePart,
14
+ } from '@atomic-testing/core';
15
+
16
+ export const parts = {
17
+ choices: {
18
+ locator: byInputType('radio'),
19
+ driver: HTMLRadioButtonGroupDriver,
20
+ },
21
+ } satisfies ScenePart;
22
+
23
+ /**
24
+ * MUI marks the disabled/read-only state with a class on the Rating root span
25
+ * (`Mui-disabled` / `Mui-readOnly`), not on the visually-hidden radio inputs that
26
+ * the composed {@link HTMLRadioButtonGroupDriver} can see — so these states are
27
+ * only observable from the root element.
28
+ */
29
+ const disabledClassName = 'Mui-disabled';
30
+ const readOnlyClassName = 'Mui-readOnly';
31
+ const filledIconClassName = 'MuiRating-iconFilled';
32
+
33
+ export class RatingDriver extends ComponentDriver<typeof parts> implements IInputDriver<number | null> {
34
+ constructor(locator: PartLocator, interactor: Interactor, option?: Partial<IComponentDriverOption>) {
35
+ super(locator, interactor, {
36
+ ...option,
37
+ parts,
38
+ });
39
+ }
40
+
41
+ async getValue(): Promise<number | null> {
42
+ // A read-only Rating renders no radio inputs (root becomes `role="img"` with the
43
+ // value carried in `aria-label`), so the radio-based read below cannot be used.
44
+ const isReadOnly = await this.interactor.hasCssClass(this.locator, readOnlyClassName);
45
+ if (isReadOnly) {
46
+ return this.getReadOnlyValue();
47
+ }
48
+
49
+ await this.enforcePartExistence('choices');
50
+ const value = await this.parts.choices.getValue();
51
+ // The "no rating" state is the visually-hidden radio whose `value` attribute is the
52
+ // empty string; treat it the same as an absent selection.
53
+ if (value == null || value === '') {
54
+ return null;
55
+ }
56
+ return parseFloat(value);
57
+ }
58
+
59
+ /**
60
+ * Read the value of a read-only Rating.
61
+ *
62
+ * Primary source is the root's `aria-label`, which MUI populates with the accessible
63
+ * name (e.g. `"2.5 Stars"`) — accessibility-first and precision-accurate. When a
64
+ * caller supplies a custom, non-numeric `aria-label`, fall back to counting the
65
+ * filled star icons; that count is exact for whole-star ratings.
66
+ */
67
+ private async getReadOnlyValue(): Promise<number | null> {
68
+ const label = await this.interactor.getAttribute(this.locator, 'aria-label');
69
+ if (label != null) {
70
+ const parsed = parseFloat(label);
71
+ if (!Number.isNaN(parsed)) {
72
+ return parsed;
73
+ }
74
+ }
75
+
76
+ const filledLocator = locatorUtil.append(this.locator, byCssClass(filledIconClassName));
77
+ const filledIcons = await this.interactor.getAttribute(filledLocator, 'class', true);
78
+ return filledIcons.length > 0 ? filledIcons.length : null;
79
+ }
80
+
81
+ /**
82
+ * Whether the Rating is disabled. `disabled` is reflected as the `Mui-disabled`
83
+ * class on the root span (the composed radio-group driver exposes no `isDisabled`).
84
+ */
85
+ async isDisabled(): Promise<boolean> {
86
+ return this.interactor.hasCssClass(this.locator, disabledClassName);
87
+ }
88
+
89
+ async setValue(value: number | null): Promise<boolean> {
90
+ const currentValue = await this.getValue();
91
+ if (value === currentValue) {
92
+ return true;
93
+ }
94
+
95
+ // Clearing (value == null) reuses MUI's "click the selected star again to
96
+ // reset" behaviour by re-clicking the current value's label. This resets the
97
+ // rating in real browsers but not in jsdom, where MUI's clear path depends on
98
+ // pointer coordinates that jsdom does not provide; the empty radio (`value=""`)
99
+ // that jsdom could click has no `<label>` and is not reliably clickable in
100
+ // browsers. A fully portable clear needs a coordinate-free click primitive on
101
+ // the Interactor. TODO(#68): revisit once that primitive exists.
102
+ const valueToClick = value ?? currentValue;
103
+ if (valueToClick == null) {
104
+ return true;
105
+ }
106
+
107
+ const targetLocator = locatorUtil.append(this.parts.choices.locator, byValue(valueToClick.toString(), 'Same'));
108
+ const targetExists = await this.interactor.exists(targetLocator);
109
+ if (targetExists) {
110
+ const id = await this.interactor.getAttribute(targetLocator, 'id');
111
+ const labelLocator = locatorUtil.append(this.locator, byCssSelector(`label[for="${id}"]`));
112
+ await this.interactor.click(labelLocator);
113
+ }
114
+ // TODO: throw error if the value does not exist
115
+ return targetExists;
116
+ }
117
+
118
+ get driverName(): string {
119
+ return 'MuiV9RatingDriver';
120
+ }
121
+ }