aria-ease 6.9.1 → 6.11.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 (38) hide show
  1. package/README.md +3 -3
  2. package/{bin/buildContracts-GBOY7UXG.js → dist/buildContracts-FT6KWUJN.js} +31 -3
  3. package/{bin/chunk-LMSKLN5O.js → dist/chunk-NI3MQCAS.js} +34 -0
  4. package/{bin → dist}/cli.cjs +239 -24
  5. package/{bin → dist}/cli.js +4 -4
  6. package/dist/{configLoader-WTGJAP4Z.js → configLoader-DWHOHXHL.js} +34 -0
  7. package/{bin/configLoader-Q6A4JLKW.js → dist/configLoader-UJZHQBYS.js} +1 -1
  8. package/{bin/contractTestRunnerPlaywright-ZZNWDUYP.js → dist/contractTestRunnerPlaywright-QDXSK3FE.js} +173 -20
  9. package/dist/{contractTestRunnerPlaywright-XBWJZMR3.js → contractTestRunnerPlaywright-WNWQYSXZ.js} +173 -20
  10. package/dist/index.cjs +568 -298
  11. package/dist/index.d.cts +53 -53
  12. package/dist/index.d.ts +53 -53
  13. package/dist/index.js +364 -281
  14. package/dist/src/combobox/index.cjs +21 -7
  15. package/dist/src/combobox/index.js +21 -7
  16. package/dist/src/utils/test/{configLoader-YE2CYGDG.js → configLoader-SHJSRG2A.js} +34 -0
  17. package/dist/src/utils/test/{contractTestRunnerPlaywright-LC5OAVXB.js → contractTestRunnerPlaywright-Z2AHXSNM.js} +173 -20
  18. package/dist/src/utils/test/dsl/index.cjs +338 -269
  19. package/dist/src/utils/test/dsl/index.d.cts +53 -53
  20. package/dist/src/utils/test/dsl/index.d.ts +53 -53
  21. package/dist/src/utils/test/dsl/index.js +338 -269
  22. package/dist/src/utils/test/index.cjs +207 -20
  23. package/dist/src/utils/test/index.js +2 -2
  24. package/{bin/test-OND56UUL.js → dist/test-O3J4ZPQR.js} +2 -2
  25. package/package.json +4 -5
  26. package/bin/AccordionComponentStrategy-4ZEIQ2V6.js +0 -42
  27. package/bin/ComboboxComponentStrategy-OGRVZXAF.js +0 -64
  28. package/bin/MenuComponentStrategy-JAMTCSNF.js +0 -81
  29. package/bin/TabsComponentStrategy-3SQURPMX.js +0 -29
  30. package/bin/chunk-I2KLQ2HA.js +0 -22
  31. package/bin/chunk-PK5L2SAF.js +0 -17
  32. package/bin/chunk-XERMSYEH.js +0 -363
  33. /package/{bin → dist}/audit-RM6TCZ5C.js +0 -0
  34. /package/{bin → dist}/badgeHelper-JOWO6RQG.js +0 -0
  35. /package/{bin → dist}/chunk-JJEPLK7L.js +0 -0
  36. /package/{bin → dist}/cli.d.cts +0 -0
  37. /package/{bin → dist}/cli.d.ts +0 -0
  38. /package/{bin → dist}/formatters-32KQIIYS.js +0 -0
@@ -34,16 +34,12 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
34
34
  }
35
35
  function setActiveDescendant(index) {
36
36
  const visibleItems = getVisibleItems();
37
- visibleItems.forEach((item) => {
38
- item.setAttribute("aria-selected", "false");
39
- });
40
37
  if (index >= 0 && index < visibleItems.length) {
41
38
  const activeItem = visibleItems[index];
42
39
  const itemId = activeItem.id || `${listBoxId}-option-${index}`;
43
40
  if (!activeItem.id) {
44
41
  activeItem.id = itemId;
45
42
  }
46
- activeItem.setAttribute("aria-selected", "true");
47
43
  comboboxInput.setAttribute("aria-activedescendant", itemId);
48
44
  if (typeof activeItem.scrollIntoView === "function") {
49
45
  activeItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -76,8 +72,6 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
76
72
  comboboxInput.setAttribute("aria-activedescendant", "");
77
73
  listBox.style.display = "none";
78
74
  activeIndex = -1;
79
- const visibleItems = getVisibleItems();
80
- visibleItems.forEach((item) => item.setAttribute("aria-selected", "false"));
81
75
  if (callback?.onOpenChange) {
82
76
  try {
83
77
  callback.onOpenChange(false);
@@ -89,6 +83,7 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
89
83
  function selectOption(item) {
90
84
  const value = item.textContent?.trim() || "";
91
85
  comboboxInput.value = value;
86
+ item.setAttribute("aria-selected", "true");
92
87
  closeListbox();
93
88
  if (callback?.onSelect) {
94
89
  try {
@@ -135,6 +130,10 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
135
130
  } else if (comboboxInput.value) {
136
131
  event.preventDefault();
137
132
  comboboxInput.value = "";
133
+ const visibleItems2 = getVisibleItems();
134
+ visibleItems2.forEach((item) => {
135
+ if (item.getAttribute("aria-selected") === "true") item.setAttribute("aria-selected", "false");
136
+ });
138
137
  if (callback?.onClear) {
139
138
  try {
140
139
  callback.onClear();
@@ -213,9 +212,24 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
213
212
  function initializeOptions() {
214
213
  const items = listBox.querySelectorAll(`.${listBoxItemsClass}`);
215
214
  if (items.length === 0) return;
215
+ let selectedValue = null;
216
+ for (const item of items) {
217
+ if (item.getAttribute("aria-selected") === "true") {
218
+ selectedValue = item.textContent?.trim() || null;
219
+ break;
220
+ }
221
+ }
222
+ if (!selectedValue && comboboxInput.value) {
223
+ selectedValue = comboboxInput.value.trim();
224
+ }
216
225
  items.forEach((item, index) => {
217
226
  item.setAttribute("role", "option");
218
- item.setAttribute("aria-selected", "false");
227
+ const itemValue = item.textContent?.trim() || "";
228
+ if (selectedValue && itemValue === selectedValue) {
229
+ item.setAttribute("aria-selected", "true");
230
+ } else {
231
+ item.setAttribute("aria-selected", "false");
232
+ }
219
233
  const currentId = item.getAttribute("id");
220
234
  if (!currentId || currentId === "") {
221
235
  const itemId = `${listBoxId}-option-${index}`;
@@ -32,16 +32,12 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
32
32
  }
33
33
  function setActiveDescendant(index) {
34
34
  const visibleItems = getVisibleItems();
35
- visibleItems.forEach((item) => {
36
- item.setAttribute("aria-selected", "false");
37
- });
38
35
  if (index >= 0 && index < visibleItems.length) {
39
36
  const activeItem = visibleItems[index];
40
37
  const itemId = activeItem.id || `${listBoxId}-option-${index}`;
41
38
  if (!activeItem.id) {
42
39
  activeItem.id = itemId;
43
40
  }
44
- activeItem.setAttribute("aria-selected", "true");
45
41
  comboboxInput.setAttribute("aria-activedescendant", itemId);
46
42
  if (typeof activeItem.scrollIntoView === "function") {
47
43
  activeItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
@@ -74,8 +70,6 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
74
70
  comboboxInput.setAttribute("aria-activedescendant", "");
75
71
  listBox.style.display = "none";
76
72
  activeIndex = -1;
77
- const visibleItems = getVisibleItems();
78
- visibleItems.forEach((item) => item.setAttribute("aria-selected", "false"));
79
73
  if (callback?.onOpenChange) {
80
74
  try {
81
75
  callback.onOpenChange(false);
@@ -87,6 +81,7 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
87
81
  function selectOption(item) {
88
82
  const value = item.textContent?.trim() || "";
89
83
  comboboxInput.value = value;
84
+ item.setAttribute("aria-selected", "true");
90
85
  closeListbox();
91
86
  if (callback?.onSelect) {
92
87
  try {
@@ -133,6 +128,10 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
133
128
  } else if (comboboxInput.value) {
134
129
  event.preventDefault();
135
130
  comboboxInput.value = "";
131
+ const visibleItems2 = getVisibleItems();
132
+ visibleItems2.forEach((item) => {
133
+ if (item.getAttribute("aria-selected") === "true") item.setAttribute("aria-selected", "false");
134
+ });
136
135
  if (callback?.onClear) {
137
136
  try {
138
137
  callback.onClear();
@@ -211,9 +210,24 @@ function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, listBoxId,
211
210
  function initializeOptions() {
212
211
  const items = listBox.querySelectorAll(`.${listBoxItemsClass}`);
213
212
  if (items.length === 0) return;
213
+ let selectedValue = null;
214
+ for (const item of items) {
215
+ if (item.getAttribute("aria-selected") === "true") {
216
+ selectedValue = item.textContent?.trim() || null;
217
+ break;
218
+ }
219
+ }
220
+ if (!selectedValue && comboboxInput.value) {
221
+ selectedValue = comboboxInput.value.trim();
222
+ }
214
223
  items.forEach((item, index) => {
215
224
  item.setAttribute("role", "option");
216
- item.setAttribute("aria-selected", "false");
225
+ const itemValue = item.textContent?.trim() || "";
226
+ if (selectedValue && itemValue === selectedValue) {
227
+ item.setAttribute("aria-selected", "true");
228
+ } else {
229
+ item.setAttribute("aria-selected", "false");
230
+ }
217
231
  const currentId = item.getAttribute("id");
218
232
  if (!currentId || currentId === "") {
219
233
  const itemId = `${listBoxId}-option-${index}`;
@@ -41,6 +41,23 @@ function validateConfig(config) {
41
41
  if (typeof cfg.test !== "object" || cfg.test === null) {
42
42
  errors.push("test must be an object");
43
43
  } else {
44
+ if (cfg.test.disableTimeouts !== void 0 && typeof cfg.test.disableTimeouts !== "boolean") {
45
+ errors.push("test.disableTimeouts must be a boolean when provided");
46
+ }
47
+ const testTimeoutFields = [
48
+ "actionTimeoutMs",
49
+ "assertionTimeoutMs",
50
+ "navigationTimeoutMs",
51
+ "componentReadyTimeoutMs"
52
+ ];
53
+ for (const field of testTimeoutFields) {
54
+ const value = cfg.test[field];
55
+ if (value !== void 0) {
56
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
57
+ errors.push(`test.${field} must be a non-negative number when provided`);
58
+ }
59
+ }
60
+ }
44
61
  if (cfg.test.components !== void 0) {
45
62
  if (!Array.isArray(cfg.test.components)) {
46
63
  errors.push("test.components must be an array");
@@ -61,6 +78,23 @@ function validateConfig(config) {
61
78
  if (comp.strictness !== void 0 && !["minimal", "balanced", "strict", "paranoid"].includes(comp.strictness)) {
62
79
  errors.push(`test.components[${idx}].strictness must be one of: minimal, balanced, strict, paranoid`);
63
80
  }
81
+ if (comp.disableTimeouts !== void 0 && typeof comp.disableTimeouts !== "boolean") {
82
+ errors.push(`test.components[${idx}].disableTimeouts must be a boolean when provided`);
83
+ }
84
+ const componentTimeoutFields = [
85
+ "actionTimeoutMs",
86
+ "assertionTimeoutMs",
87
+ "navigationTimeoutMs",
88
+ "componentReadyTimeoutMs"
89
+ ];
90
+ for (const field of componentTimeoutFields) {
91
+ const value = comp[field];
92
+ if (value !== void 0) {
93
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
94
+ errors.push(`test.components[${idx}].${field} must be a non-negative number when provided`);
95
+ }
96
+ }
97
+ }
64
98
  }
65
99
  });
66
100
  }
@@ -212,8 +212,41 @@ var ActionExecutor = class {
212
212
  /**
213
213
  * Execute focus action
214
214
  */
215
- async focus(target) {
215
+ /**
216
+ * Execute focus action (supports absolute, relative, and virtual focus)
217
+ * @param target - selector key (e.g. "input", "button", "relative", or "virtual")
218
+ * @param relativeTarget - for relative focus (e.g. "first", "last")
219
+ * @param virtualId - for virtual focus (aria-activedescendant value)
220
+ */
221
+ async focus(target, relativeTarget, virtualId) {
216
222
  try {
223
+ if (target === "virtual" && virtualId) {
224
+ const inputSelector = this.selectors.input;
225
+ if (!inputSelector) {
226
+ return { success: false, error: `Input selector not defined for virtual focus.` };
227
+ }
228
+ const input = this.page.locator(inputSelector).first();
229
+ const exists = await input.count();
230
+ if (!exists) {
231
+ return { success: false, error: `Input element not found for virtual focus.` };
232
+ }
233
+ await input.evaluate((el, id) => {
234
+ el.setAttribute("aria-activedescendant", id);
235
+ }, virtualId);
236
+ return { success: true };
237
+ }
238
+ if (target === "relative" && relativeTarget) {
239
+ const relativeSelector = this.selectors.relative;
240
+ if (!relativeSelector) {
241
+ return { success: false, error: `Relative selector not defined for focus action.` };
242
+ }
243
+ const element = await RelativeTargetResolver.resolve(this.page, relativeSelector, relativeTarget);
244
+ if (!element) {
245
+ return { success: false, error: `Could not resolve relative target ${relativeTarget} for focus.` };
246
+ }
247
+ await element.focus({ timeout: this.timeoutMs });
248
+ return { success: true };
249
+ }
217
250
  const selector = this.selectors[target];
218
251
  if (!selector) {
219
252
  return { success: false, error: `Selector for focus target ${target} not found.` };
@@ -404,10 +437,10 @@ var AssertionRunner = class {
404
437
  /**
405
438
  * Resolve the target element for an assertion
406
439
  */
407
- async resolveTarget(targetName, relativeTarget) {
440
+ async resolveTarget(targetName, relativeTarget, selectorKey) {
408
441
  try {
409
442
  if (targetName === "relative") {
410
- const relativeSelector = this.selectors.relative;
443
+ const relativeSelector = selectorKey ? this.selectors[selectorKey] : this.selectors.relative;
411
444
  if (!relativeSelector) {
412
445
  return { target: null, error: "Relative selector is not defined in the contract." };
413
446
  }
@@ -594,10 +627,30 @@ var AssertionRunner = class {
594
627
  failMessage: `CRITICAL: Browser/page closed before completing all tests. Increase test timeout or reduce test complexity.`
595
628
  };
596
629
  }
597
- const { target, error } = await this.resolveTarget(assertion.target, assertion.relativeTarget || assertion.expectedValue);
630
+ const { target, error } = await this.resolveTarget(
631
+ assertion.target,
632
+ assertion.relativeTarget || assertion.expectedValue,
633
+ assertion.selectorKey
634
+ );
598
635
  if (error || !target) {
599
636
  return { success: false, failMessage: error || `Target ${assertion.target} not found.`, target: null };
600
637
  }
638
+ if (assertion.target === "input" && assertion.attribute === "aria-activedescendant" && assertion.expectedValue === "!empty" && assertion.relativeTarget && assertion.selectorKey) {
639
+ const optionLocator = await RelativeTargetResolver.resolve(this.page, this.selectors[assertion.selectorKey], assertion.relativeTarget);
640
+ const optionId = optionLocator ? await optionLocator.getAttribute("id") : null;
641
+ const inputId = await target.getAttribute("aria-activedescendant");
642
+ if (optionId && inputId === optionId) {
643
+ return {
644
+ success: true,
645
+ passMessage: `input[aria-activedescendant] matches id of ${assertion.relativeTarget}(${assertion.selectorKey}). Test: "${testDescription}".`
646
+ };
647
+ } else {
648
+ return {
649
+ success: false,
650
+ failMessage: `input[aria-activedescendant] should match id of ${assertion.relativeTarget}(${assertion.selectorKey}), found "${inputId}".`
651
+ };
652
+ }
653
+ }
601
654
  switch (assertion.assertion) {
602
655
  case "toBeVisible":
603
656
  return this.validateVisibility(target, assertion.target, true, assertion.failureMessage || "", testDescription);
@@ -638,8 +691,43 @@ async function runContractTestsPlaywright(componentName, url, strictness, config
638
691
  const componentConfig = config?.test?.components?.find((c) => c.name === componentName);
639
692
  const isCustomContract = !!componentConfig?.path;
640
693
  const reporter = new ContractReporter(true, isCustomContract);
641
- const actionTimeoutMs = 400;
642
- const assertionTimeoutMs = 400;
694
+ const defaultTimeouts = {
695
+ actionTimeoutMs: 400,
696
+ assertionTimeoutMs: 400,
697
+ navigationTimeoutMs: 3e4,
698
+ componentReadyTimeoutMs: 5e3
699
+ };
700
+ const globalDisableTimeouts = config?.test?.disableTimeouts === true;
701
+ const componentDisableTimeouts = componentConfig?.disableTimeouts === true;
702
+ const disableTimeouts = componentDisableTimeouts || globalDisableTimeouts;
703
+ const resolveTimeout = (componentValue, globalValue, fallback) => {
704
+ if (disableTimeouts) return 0;
705
+ const value = componentValue ?? globalValue;
706
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
707
+ return fallback;
708
+ }
709
+ return value;
710
+ };
711
+ const actionTimeoutMs = resolveTimeout(
712
+ componentConfig?.actionTimeoutMs,
713
+ config?.test?.actionTimeoutMs,
714
+ defaultTimeouts.actionTimeoutMs
715
+ );
716
+ const assertionTimeoutMs = resolveTimeout(
717
+ componentConfig?.assertionTimeoutMs,
718
+ config?.test?.assertionTimeoutMs,
719
+ defaultTimeouts.assertionTimeoutMs
720
+ );
721
+ const navigationTimeoutMs = resolveTimeout(
722
+ componentConfig?.navigationTimeoutMs,
723
+ config?.test?.navigationTimeoutMs,
724
+ defaultTimeouts.navigationTimeoutMs
725
+ );
726
+ const componentReadyTimeoutMs = resolveTimeout(
727
+ componentConfig?.componentReadyTimeoutMs,
728
+ config?.test?.componentReadyTimeoutMs,
729
+ defaultTimeouts.componentReadyTimeoutMs
730
+ );
643
731
  const strictnessMode = normalizeStrictness(strictness);
644
732
  let contractPath = componentConfig?.path;
645
733
  if (!contractPath) {
@@ -697,7 +785,7 @@ async function runContractTestsPlaywright(componentName, url, strictness, config
697
785
  try {
698
786
  await page.goto(url, {
699
787
  waitUntil: "domcontentloaded",
700
- timeout: 3e4
788
+ timeout: navigationTimeoutMs
701
789
  });
702
790
  } catch (error) {
703
791
  throw new Error(
@@ -715,7 +803,7 @@ async function runContractTestsPlaywright(componentName, url, strictness, config
715
803
  throw new Error(`CRITICAL: No selector found in contract for ${componentName}`);
716
804
  }
717
805
  try {
718
- await page.locator(mainSelector).first().waitFor({ state: "attached", timeout: 3e4 });
806
+ await page.locator(mainSelector).first().waitFor({ state: "attached", timeout: componentReadyTimeoutMs });
719
807
  } catch (error) {
720
808
  throw new Error(
721
809
  `
@@ -729,18 +817,26 @@ This usually means:
729
817
  }
730
818
  reporter.start(componentName, totalTests, apgUrl);
731
819
  if (componentName === "menu" && componentContract.selectors.trigger) {
732
- await page.locator(componentContract.selectors.trigger).first().waitFor({
733
- state: "attached",
734
- timeout: 5e3
735
- }).catch(() => {
820
+ await page.locator(componentContract.selectors.trigger).first().waitFor({ state: "attached", timeout: componentReadyTimeoutMs }).catch(() => {
736
821
  });
737
822
  }
738
823
  const hasSubmenuCapability = componentName === "menu" && !!componentContract.selectors.submenuTrigger ? await page.locator(componentContract.selectors.submenuTrigger).count() > 0 : false;
824
+ const isSubmenuRelation = (rel) => rel.type === "aria-reference" && [rel.from, rel.to].some((name) => ["submenu", "submenuTrigger", "submenuItems"].includes(name || "")) || rel.type === "contains" && [rel.parent, rel.child].some((name) => ["submenu", "submenuTrigger", "submenuItems"].includes(name || ""));
739
825
  let staticPassed = 0;
740
826
  let staticFailed = 0;
741
827
  let staticWarnings = 0;
742
828
  for (const rel of componentContract.relationships || []) {
743
829
  const relationshipLevel = normalizeLevel(rel.level);
830
+ if (componentName === "menu" && !hasSubmenuCapability) {
831
+ const involvesSubmenu = isSubmenuRelation(rel);
832
+ if (involvesSubmenu) {
833
+ const relDescription = rel.type === "aria-reference" ? `${rel.from}.${rel.attribute} references ${rel.to}` : `${rel.parent} contains ${rel.child}`;
834
+ const skipMessage = `Skipping submenu relationship assertion: no submenu capability detected in rendered component.`;
835
+ skipped.push(skipMessage);
836
+ reporter.reportStaticTest(relDescription, "skip", skipMessage, relationshipLevel);
837
+ continue;
838
+ }
839
+ }
744
840
  if (rel.type === "aria-reference") {
745
841
  const relDescription = `${rel.from}.${rel.attribute} references ${rel.to}`;
746
842
  const fromSelector = componentContract.selectors[rel.from];
@@ -760,6 +856,12 @@ This usually means:
760
856
  const fromExists = await fromTarget.count() > 0;
761
857
  const toExists = await toTarget.count() > 0;
762
858
  if (!fromExists || !toExists) {
859
+ if (componentName === "menu" && isSubmenuRelation(rel)) {
860
+ const skipMessage = "Skipping submenu relationship assertion in static phase: submenu elements are not present until submenu is opened.";
861
+ skipped.push(skipMessage);
862
+ reporter.reportStaticTest(relDescription, "skip", skipMessage, relationshipLevel);
863
+ continue;
864
+ }
763
865
  const outcome = classifyFailure(
764
866
  `Relationship target not found: ${!fromExists ? rel.from : rel.to}.`,
765
867
  rel.level
@@ -815,6 +917,12 @@ This usually means:
815
917
  const parent = page.locator(parentSelector).first();
816
918
  const parentExists = await parent.count() > 0;
817
919
  if (!parentExists) {
920
+ if (componentName === "menu" && isSubmenuRelation(rel)) {
921
+ const skipMessage = "Skipping submenu relationship assertion in static phase: submenu container is not present until submenu is opened.";
922
+ skipped.push(skipMessage);
923
+ reporter.reportStaticTest(relDescription, "skip", skipMessage, relationshipLevel);
924
+ continue;
925
+ }
818
926
  const outcome = classifyFailure(`Relationship parent target not found: ${rel.parent}.`, rel.level);
819
927
  if (outcome.status === "fail") staticFailed += 1;
820
928
  if (outcome.status === "warn") staticWarnings += 1;
@@ -824,6 +932,12 @@ This usually means:
824
932
  const descendants = parent.locator(childSelector);
825
933
  const descendantCount = await descendants.count();
826
934
  if (descendantCount < 1) {
935
+ if (componentName === "menu" && isSubmenuRelation(rel)) {
936
+ const skipMessage = "Skipping submenu relationship assertion in static phase: submenu descendants are not present until submenu is opened.";
937
+ skipped.push(skipMessage);
938
+ reporter.reportStaticTest(relDescription, "skip", skipMessage, relationshipLevel);
939
+ continue;
940
+ }
827
941
  const outcome = classifyFailure(
828
942
  `Expected ${rel.parent} to contain descendant matching selector for ${rel.child}.`,
829
943
  rel.level
@@ -947,11 +1061,6 @@ This usually means:
947
1061
  failures.push(`CRITICAL: Browser/page closed before completing all tests. ${componentContract.dynamic.length - componentContract.dynamic.indexOf(dynamicTest)} tests skipped.`);
948
1062
  break;
949
1063
  }
950
- const { action, assertions } = dynamicTest;
951
- const failuresBeforeTest = failures.length;
952
- const warningsBeforeTest = warnings.length;
953
- const skippedBeforeTest = skipped.length;
954
- const dynamicLevel = normalizeLevel(dynamicTest.level);
955
1064
  try {
956
1065
  await strategy.resetState(page);
957
1066
  } catch (error) {
@@ -959,6 +1068,40 @@ This usually means:
959
1068
  reporter.error(errorMessage);
960
1069
  throw error;
961
1070
  }
1071
+ const { setup = [], action, assertions } = dynamicTest;
1072
+ const dynamicLevel = normalizeLevel(dynamicTest.level);
1073
+ const actionExecutor = new ActionExecutor(page, componentContract.selectors, actionTimeoutMs);
1074
+ if (Array.isArray(setup) && setup.length > 0) {
1075
+ for (const setupAct of setup) {
1076
+ let setupResult;
1077
+ if (setupAct.type === "focus") {
1078
+ if (setupAct.target === "relative" && setupAct.relativeTarget) {
1079
+ setupResult = await actionExecutor.focus("relative", setupAct.relativeTarget);
1080
+ } else {
1081
+ setupResult = await actionExecutor.focus(setupAct.target);
1082
+ }
1083
+ } else if (setupAct.type === "type" && setupAct.value) {
1084
+ setupResult = await actionExecutor.type(setupAct.target, setupAct.value);
1085
+ } else if (setupAct.type === "click") {
1086
+ setupResult = await actionExecutor.click(setupAct.target, setupAct.relativeTarget);
1087
+ } else if (setupAct.type === "keypress" && setupAct.key) {
1088
+ setupResult = await actionExecutor.keypress(setupAct.target, setupAct.key);
1089
+ } else if (setupAct.type === "hover") {
1090
+ setupResult = await actionExecutor.hover(setupAct.target, setupAct.relativeTarget);
1091
+ } else {
1092
+ continue;
1093
+ }
1094
+ if (!setupResult.success) {
1095
+ const setupMsg = setupResult.error || "Setup action failed";
1096
+ const outcome = classifyFailure(`Setup failed: ${setupMsg}`, dynamicTest.level);
1097
+ reporter.reportTest({ description: dynamicTest.description, level: dynamicLevel }, outcome.status, outcome.detail);
1098
+ continue;
1099
+ }
1100
+ }
1101
+ }
1102
+ const failuresBeforeTest = failures.length;
1103
+ const warningsBeforeTest = warnings.length;
1104
+ const skippedBeforeTest = skipped.length;
962
1105
  const shouldSkipTest = await strategy.shouldSkipTest(dynamicTest, page);
963
1106
  if (shouldSkipTest) {
964
1107
  const skipMessage = `Skipping test - component-specific conditions not met`;
@@ -966,7 +1109,6 @@ This usually means:
966
1109
  reporter.reportTest({ description: dynamicTest.description, level: dynamicLevel }, "skip", skipMessage);
967
1110
  continue;
968
1111
  }
969
- const actionExecutor = new ActionExecutor(page, componentContract.selectors, actionTimeoutMs);
970
1112
  const assertionRunner = new AssertionRunner(page, componentContract.selectors, assertionTimeoutMs);
971
1113
  let shouldAbortCurrentTest = false;
972
1114
  let actionOutcome = null;
@@ -978,7 +1120,11 @@ This usually means:
978
1120
  }
979
1121
  let result;
980
1122
  if (act.type === "focus") {
981
- result = await actionExecutor.focus(act.target);
1123
+ if (act.target === "relative" && act.relativeTarget) {
1124
+ result = await actionExecutor.focus("relative", act.relativeTarget);
1125
+ } else {
1126
+ result = await actionExecutor.focus(act.target);
1127
+ }
982
1128
  } else if (act.type === "type" && act.value) {
983
1129
  result = await actionExecutor.type(act.target, act.value);
984
1130
  } else if (act.type === "click") {
@@ -1056,7 +1202,14 @@ This usually means:
1056
1202
  Make sure your dev server is running at ${url}`);
1057
1203
  } else if (error.message.includes("Timeout") && error.message.includes("waitFor")) {
1058
1204
  throw new Error(
1059
- "\n\u274C CRITICAL: Component not found on page!\nThe component selector could not be found within 30 seconds.\nThis usually means:\n - The component didn't render\n - The URL is incorrect\n - The component selector was not provided to the component utility, or a wrong selector was used\n"
1205
+ `
1206
+ \u274C CRITICAL: Component not found on page!
1207
+ The component selector could not be found within ${componentReadyTimeoutMs}ms.
1208
+ This usually means:
1209
+ - The component didn't render
1210
+ - The URL is incorrect
1211
+ - The component selector was not provided to the component utility, or a wrong selector was used
1212
+ `
1060
1213
  );
1061
1214
  } else if (error.message.includes("Target page, context or browser has been closed")) {
1062
1215
  throw new Error(