aria-ease 6.7.0 → 6.9.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 (39) hide show
  1. package/README.md +77 -10
  2. package/bin/AccordionComponentStrategy-4ZEIQ2V6.js +42 -0
  3. package/bin/ComboboxComponentStrategy-OGRVZXAF.js +64 -0
  4. package/bin/MenuComponentStrategy-JAMTCSNF.js +81 -0
  5. package/bin/TabsComponentStrategy-3SQURPMX.js +29 -0
  6. package/bin/buildContracts-GBOY7UXG.js +437 -0
  7. package/bin/{chunk-VPBHLMAS.js → chunk-LMSKLN5O.js} +21 -0
  8. package/bin/chunk-PK5L2SAF.js +17 -0
  9. package/bin/{chunk-2TOYEY5L.js → chunk-XERMSYEH.js} +12 -3
  10. package/bin/cli.cjs +991 -128
  11. package/bin/cli.js +33 -2
  12. package/bin/{configLoader-XRF6VM4J.js → configLoader-Q6A4JLKW.js} +1 -1
  13. package/{dist/contractTestRunnerPlaywright-UAOFNS7Z.js → bin/contractTestRunnerPlaywright-ZZNWDUYP.js} +270 -219
  14. package/bin/{test-WRIJHN6H.js → test-OND56UUL.js} +97 -10
  15. package/dist/AccordionComponentStrategy-4ZEIQ2V6.js +42 -0
  16. package/dist/ComboboxComponentStrategy-OGRVZXAF.js +64 -0
  17. package/dist/MenuComponentStrategy-JAMTCSNF.js +81 -0
  18. package/dist/TabsComponentStrategy-3SQURPMX.js +29 -0
  19. package/dist/chunk-PK5L2SAF.js +17 -0
  20. package/dist/{chunk-2TOYEY5L.js → chunk-XERMSYEH.js} +12 -3
  21. package/dist/{configLoader-IT4PWCJB.js → configLoader-WTGJAP4Z.js} +21 -0
  22. package/{bin/contractTestRunnerPlaywright-UAOFNS7Z.js → dist/contractTestRunnerPlaywright-XBWJZMR3.js} +270 -219
  23. package/dist/index.cjs +800 -96
  24. package/dist/index.d.cts +136 -1
  25. package/dist/index.d.ts +136 -1
  26. package/dist/index.js +421 -16
  27. package/dist/src/utils/test/AccordionComponentStrategy-WRHZOEN6.js +38 -0
  28. package/dist/src/utils/test/ComboboxComponentStrategy-5AECQSRN.js +60 -0
  29. package/dist/src/utils/test/MenuComponentStrategy-VKZQYLBE.js +77 -0
  30. package/dist/src/utils/test/TabsComponentStrategy-BKG53SEV.js +26 -0
  31. package/dist/src/utils/test/aria-contracts/accordion/accordion.contract.json +5 -11
  32. package/dist/src/utils/test/aria-contracts/combobox/combobox.listbox.contract.json +1 -1
  33. package/dist/src/utils/test/aria-contracts/menu/menu.contract.json +1 -1
  34. package/dist/src/utils/test/{chunk-2TOYEY5L.js → chunk-XERMSYEH.js} +12 -3
  35. package/dist/src/utils/test/{configLoader-LD4RV2WQ.js → configLoader-YE2CYGDG.js} +21 -0
  36. package/dist/src/utils/test/{contractTestRunnerPlaywright-IRJOAEMT.js → contractTestRunnerPlaywright-LC5OAVXB.js} +262 -200
  37. package/dist/src/utils/test/index.cjs +472 -88
  38. package/dist/src/utils/test/index.js +97 -12
  39. package/package.json +7 -2
@@ -5,245 +5,172 @@ import {
5
5
  normalizeLevel,
6
6
  normalizeStrictness,
7
7
  resolveEnforcement
8
- } from "./chunk-2TOYEY5L.js";
8
+ } from "./chunk-XERMSYEH.js";
9
9
  import {
10
- __export,
11
- __reExport
12
- } from "./chunk-I2KLQ2HA.js";
10
+ test_exports
11
+ } from "./chunk-PK5L2SAF.js";
12
+ import "./chunk-I2KLQ2HA.js";
13
13
 
14
14
  // src/utils/test/src/contractTestRunnerPlaywright.ts
15
15
  import { readFileSync as readFileSync2 } from "fs";
16
+ import path3 from "path";
16
17
 
17
- // node_modules/@playwright/test/index.mjs
18
- var test_exports = {};
19
- __export(test_exports, {
20
- default: () => default2
21
- });
22
- __reExport(test_exports, test_star);
23
- import * as test_star from "playwright/test";
24
- import { default as default2 } from "playwright/test";
18
+ // src/utils/test/src/ComponentDetector.ts
19
+ import { readFileSync } from "fs";
20
+ import path2 from "path";
25
21
 
26
- // src/utils/test/src/component-strategies/ComboboxComponentStrategy.ts
27
- var ComboboxComponentStrategy = class {
28
- constructor(mainSelector, selectors, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
29
- this.mainSelector = mainSelector;
30
- this.selectors = selectors;
31
- this.actionTimeoutMs = actionTimeoutMs;
32
- this.assertionTimeoutMs = assertionTimeoutMs;
33
- }
34
- async resetState(page) {
35
- if (!this.selectors.popup) return;
36
- const popupSelector = this.selectors.popup;
37
- const popupElement = page.locator(popupSelector).first();
38
- const isPopupVisible = await popupElement.isVisible().catch(() => false);
39
- if (!isPopupVisible) return;
40
- let menuClosed = false;
41
- let closeSelector = this.selectors.input;
42
- if (!closeSelector && this.selectors.focusable) {
43
- closeSelector = this.selectors.focusable;
44
- } else if (!closeSelector) {
45
- closeSelector = this.selectors.trigger;
46
- }
47
- if (closeSelector) {
48
- const closeElement = page.locator(closeSelector).first();
49
- await closeElement.focus();
50
- await page.keyboard.press("Escape");
51
- menuClosed = await (0, test_exports.expect)(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
52
- }
53
- if (!menuClosed && this.selectors.trigger) {
54
- const triggerElement = page.locator(this.selectors.trigger).first();
55
- await triggerElement.click({ timeout: this.actionTimeoutMs });
56
- menuClosed = await (0, test_exports.expect)(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
57
- }
58
- if (!menuClosed) {
59
- await page.mouse.click(10, 10);
60
- menuClosed = await (0, test_exports.expect)(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
61
- }
62
- if (!menuClosed) {
63
- throw new Error(
64
- `\u274C FATAL: Cannot close combobox popup between tests. Popup remains visible after trying:
65
- 1. Escape key
66
- 2. Clicking trigger
67
- 3. Clicking outside
68
- This indicates a problem with the combobox component's close functionality.`
69
- );
70
- }
71
- if (this.selectors.input) {
72
- await page.locator(this.selectors.input).first().clear();
73
- }
22
+ // src/utils/test/src/StrategyRegistry.ts
23
+ import path from "path";
24
+ import { pathToFileURL } from "url";
25
+ var StrategyRegistry = class {
26
+ builtInStrategies = /* @__PURE__ */ new Map();
27
+ constructor() {
28
+ this.registerBuiltInStrategies();
74
29
  }
75
- async shouldSkipTest() {
76
- return false;
77
- }
78
- getMainSelector() {
79
- return this.mainSelector;
80
- }
81
- };
82
-
83
- // src/utils/test/src/component-strategies/AccordionComponentStrategy.ts
84
- var AccordionComponentStrategy = class {
85
- constructor(mainSelector, selectors, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
86
- this.mainSelector = mainSelector;
87
- this.selectors = selectors;
88
- this.actionTimeoutMs = actionTimeoutMs;
89
- this.assertionTimeoutMs = assertionTimeoutMs;
30
+ /**
31
+ * Register built-in strategies
32
+ */
33
+ registerBuiltInStrategies() {
34
+ this.builtInStrategies.set(
35
+ "menu",
36
+ () => import("./MenuComponentStrategy-JAMTCSNF.js").then(
37
+ (m) => m.MenuComponentStrategy
38
+ )
39
+ );
40
+ this.builtInStrategies.set(
41
+ "accordion",
42
+ () => import("./AccordionComponentStrategy-4ZEIQ2V6.js").then(
43
+ (m) => m.AccordionComponentStrategy
44
+ )
45
+ );
46
+ this.builtInStrategies.set(
47
+ "combobox",
48
+ () => import("./ComboboxComponentStrategy-OGRVZXAF.js").then(
49
+ (m) => m.ComboboxComponentStrategy
50
+ )
51
+ );
52
+ this.builtInStrategies.set(
53
+ "tabs",
54
+ () => import("./TabsComponentStrategy-3SQURPMX.js").then(
55
+ (m) => m.TabsComponentStrategy
56
+ )
57
+ );
58
+ this.builtInStrategies.set(
59
+ "combobox.listbox",
60
+ () => import("./ComboboxComponentStrategy-OGRVZXAF.js").then(
61
+ (m) => m.ComboboxComponentStrategy
62
+ )
63
+ );
90
64
  }
91
- async resetState(page) {
92
- if (!this.selectors.panel || !this.selectors.trigger || this.selectors.popup) {
93
- return;
94
- }
95
- const triggerSelector = this.selectors.trigger;
96
- const panelSelector = this.selectors.panel;
97
- if (!triggerSelector || !panelSelector) return;
98
- const allTriggers = await page.locator(triggerSelector).all();
99
- for (const trigger of allTriggers) {
100
- const isExpanded = await trigger.getAttribute("aria-expanded") === "true";
101
- const triggerPanel = await trigger.getAttribute("aria-controls");
102
- if (isExpanded && triggerPanel) {
103
- await trigger.click({ timeout: this.actionTimeoutMs });
104
- const panel = page.locator(`#${triggerPanel}`);
105
- await (0, test_exports.expect)(panel).toBeHidden({ timeout: this.assertionTimeoutMs }).catch(() => {
106
- });
65
+ /**
66
+ * Load a strategy - either from custom path or built-in registry
67
+ * @param componentName - Component name (e.g., "menu", "accordion")
68
+ * @param customStrategyPath - Optional custom strategy file path
69
+ * @returns Strategy constructor function or null if not found
70
+ */
71
+ async loadStrategy(componentName, customStrategyPath, configBaseDir) {
72
+ try {
73
+ if (customStrategyPath) {
74
+ try {
75
+ const resolvedCustomPath = path.isAbsolute(customStrategyPath) ? customStrategyPath : path.resolve(configBaseDir || process.cwd(), customStrategyPath);
76
+ const customModule = await import(pathToFileURL(resolvedCustomPath).href);
77
+ const strategy = customModule.default || customModule;
78
+ if (!strategy) {
79
+ throw new Error(`No default export found in ${customStrategyPath}`);
80
+ }
81
+ return strategy;
82
+ } catch (error) {
83
+ throw new Error(
84
+ `Failed to load custom strategy from ${customStrategyPath}: ${error instanceof Error ? error.message : String(error)}`
85
+ );
86
+ }
107
87
  }
108
- }
109
- }
110
- async shouldSkipTest() {
111
- return false;
112
- }
113
- getMainSelector() {
114
- return this.mainSelector;
115
- }
116
- };
117
-
118
- // src/utils/test/src/component-strategies/MenuComponentStrategy.ts
119
- var MenuComponentStrategy = class {
120
- constructor(mainSelector, selectors, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
121
- this.mainSelector = mainSelector;
122
- this.selectors = selectors;
123
- this.actionTimeoutMs = actionTimeoutMs;
124
- this.assertionTimeoutMs = assertionTimeoutMs;
125
- }
126
- async resetState(page) {
127
- if (!this.selectors.popup) return;
128
- const popupSelector = this.selectors.popup;
129
- const popupElement = page.locator(popupSelector).first();
130
- const isPopupVisible = await popupElement.isVisible().catch(() => false);
131
- if (!isPopupVisible) return;
132
- let menuClosed = false;
133
- let closeSelector = this.selectors.input;
134
- if (!closeSelector && this.selectors.focusable) {
135
- closeSelector = this.selectors.focusable;
136
- } else if (!closeSelector) {
137
- closeSelector = this.selectors.trigger;
138
- }
139
- if (closeSelector) {
140
- const closeElement = page.locator(closeSelector).first();
141
- await closeElement.focus();
142
- await page.keyboard.press("Escape");
143
- menuClosed = await (0, test_exports.expect)(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
144
- }
145
- if (!menuClosed && this.selectors.trigger) {
146
- const triggerElement = page.locator(this.selectors.trigger).first();
147
- await triggerElement.click({ timeout: this.actionTimeoutMs });
148
- menuClosed = await (0, test_exports.expect)(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
149
- }
150
- if (!menuClosed) {
151
- await page.mouse.click(10, 10);
152
- menuClosed = await (0, test_exports.expect)(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
153
- }
154
- if (!menuClosed) {
88
+ const builtInLoader = this.builtInStrategies.get(componentName);
89
+ if (!builtInLoader) {
90
+ return null;
91
+ }
92
+ return builtInLoader();
93
+ } catch (error) {
155
94
  throw new Error(
156
- `\u274C FATAL: Cannot close menu between tests. Menu remains visible after trying:
157
- 1. Escape key
158
- 2. Clicking trigger
159
- 3. Clicking outside
160
- This indicates a problem with the menu component's close functionality.`
95
+ `Strategy loading failed for ${componentName}: ${error instanceof Error ? error.message : String(error)}`
161
96
  );
162
97
  }
163
- if (this.selectors.input) {
164
- await page.locator(this.selectors.input).first().clear();
165
- }
166
- if (this.selectors.trigger) {
167
- const triggerElement = page.locator(this.selectors.trigger).first();
168
- await triggerElement.focus();
169
- }
170
- }
171
- async shouldSkipTest(test, page) {
172
- const requiresSubmenu = test.action.some(
173
- (act) => act.target === "submenu" || act.target === "submenuTrigger" || act.target === "submenuItems"
174
- ) || test.assertions.some(
175
- (assertion) => assertion.target === "submenu" || assertion.target === "submenuTrigger" || assertion.target === "submenuItems"
176
- );
177
- if (!requiresSubmenu) {
178
- return false;
179
- }
180
- const submenuTriggerSelector = this.selectors.submenuTrigger;
181
- if (!submenuTriggerSelector) {
182
- return true;
183
- }
184
- const submenuTriggerCount = await page.locator(submenuTriggerSelector).count();
185
- return submenuTriggerCount === 0;
186
- }
187
- getMainSelector() {
188
- return this.mainSelector;
189
98
  }
190
- };
191
-
192
- // src/utils/test/src/component-strategies/TabsComponentStrategy.ts
193
- var TabsComponentStrategy = class {
194
- constructor(mainSelector, selectors) {
195
- this.mainSelector = mainSelector;
196
- this.selectors = selectors;
197
- }
198
- async resetState() {
199
- }
200
- async shouldSkipTest(test, page) {
201
- if (test.isVertical !== void 0 && this.selectors.tablist) {
202
- const tablistSelector = this.selectors.tablist;
203
- const tablist = page.locator(tablistSelector).first();
204
- const orientation = await tablist.getAttribute("aria-orientation");
205
- const isVertical = orientation === "vertical";
206
- if (test.isVertical !== isVertical) {
207
- return true;
208
- }
209
- }
210
- return false;
211
- }
212
- getMainSelector() {
213
- return this.mainSelector;
99
+ /**
100
+ * Check if a strategy exists (either built-in or custom path provided)
101
+ */
102
+ has(componentName, customStrategyPath) {
103
+ return !!customStrategyPath || this.builtInStrategies.has(componentName);
214
104
  }
215
105
  };
216
106
 
217
107
  // src/utils/test/src/ComponentDetector.ts
218
- import { readFileSync } from "fs";
219
108
  var ComponentDetector = class {
220
- static detect(componentName, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
221
- const contractTyped = contract_default;
222
- const contractPath = contractTyped[componentName]?.path;
109
+ static strategyRegistry = new StrategyRegistry();
110
+ static isComponentConfig(value) {
111
+ return typeof value === "object" && value !== null;
112
+ }
113
+ /**
114
+ * Detect and instantiate a component strategy
115
+ * Supports:
116
+ * - Built-in strategies (menu, accordion, combobox, tabs)
117
+ * - Custom strategies via config (strategyPath)
118
+ * - Custom contract paths via config (path)
119
+ * @param componentName - Component name
120
+ * @param componentConfig - Component config from ariaease.config.js
121
+ * @param actionTimeoutMs - Action timeout in milliseconds
122
+ * @param assertionTimeoutMs - Assertion timeout in milliseconds
123
+ * @returns Instantiated ComponentStrategy or null
124
+ */
125
+ static async detect(componentName, componentConfig, actionTimeoutMs = 400, assertionTimeoutMs = 400, configBaseDir) {
126
+ const typedComponentConfig = this.isComponentConfig(componentConfig) ? componentConfig : void 0;
127
+ let contractPath = typedComponentConfig?.path;
128
+ if (!contractPath) {
129
+ const contractTyped = contract_default;
130
+ contractPath = contractTyped[componentName]?.path;
131
+ }
223
132
  if (!contractPath) {
224
133
  throw new Error(`Contract path not found for component: ${componentName}`);
225
134
  }
226
- const resolvedPath = new URL(contractPath, import.meta.url).pathname;
135
+ const resolvedPath = (() => {
136
+ if (path2.isAbsolute(contractPath)) return contractPath;
137
+ if (configBaseDir) {
138
+ const configResolved = path2.resolve(configBaseDir, contractPath);
139
+ try {
140
+ readFileSync(configResolved, "utf-8");
141
+ return configResolved;
142
+ } catch {
143
+ }
144
+ }
145
+ const cwdResolved = path2.resolve(process.cwd(), contractPath);
146
+ try {
147
+ readFileSync(cwdResolved, "utf-8");
148
+ return cwdResolved;
149
+ } catch {
150
+ return new URL(contractPath, import.meta.url).pathname;
151
+ }
152
+ })();
227
153
  const contractData = readFileSync(resolvedPath, "utf-8");
228
154
  const componentContract = JSON.parse(contractData);
229
155
  const selectors = componentContract.selectors;
230
- if (componentName.includes("combobox")) {
231
- const mainSelector = selectors.input || selectors.container;
232
- return new ComboboxComponentStrategy(mainSelector, selectors, actionTimeoutMs, assertionTimeoutMs);
233
- }
234
- if (componentName === "accordion") {
235
- const mainSelector = selectors.trigger || selectors.container;
236
- return new AccordionComponentStrategy(mainSelector, selectors, actionTimeoutMs, assertionTimeoutMs);
237
- }
238
- if (componentName === "menu") {
239
- const mainSelector = selectors.trigger || selectors.container;
240
- return new MenuComponentStrategy(mainSelector, selectors, actionTimeoutMs, assertionTimeoutMs);
156
+ const strategyClass = await this.strategyRegistry.loadStrategy(
157
+ componentName,
158
+ typedComponentConfig?.strategyPath,
159
+ configBaseDir
160
+ );
161
+ if (!strategyClass) {
162
+ return null;
241
163
  }
164
+ const mainSelector = selectors.trigger || selectors.input || selectors.tablist || selectors.container;
242
165
  if (componentName === "tabs") {
243
- const mainSelector = selectors.tablist || selectors.tab;
244
- return new TabsComponentStrategy(mainSelector, selectors);
166
+ return new strategyClass(mainSelector, selectors);
245
167
  }
246
- return null;
168
+ return new strategyClass(
169
+ mainSelector,
170
+ selectors,
171
+ actionTimeoutMs,
172
+ assertionTimeoutMs
173
+ );
247
174
  }
248
175
  };
249
176
 
@@ -727,17 +654,42 @@ var AssertionRunner = class {
727
654
  };
728
655
 
729
656
  // src/utils/test/src/contractTestRunnerPlaywright.ts
730
- async function runContractTestsPlaywright(componentName, url, strictness) {
731
- const reporter = new ContractReporter(true);
657
+ async function runContractTestsPlaywright(componentName, url, strictness, config, configBaseDir) {
658
+ const componentConfig = config?.test?.components?.find((c) => c.name === componentName);
659
+ const isCustomContract = !!componentConfig?.path;
660
+ const reporter = new ContractReporter(true, isCustomContract);
732
661
  const actionTimeoutMs = 400;
733
662
  const assertionTimeoutMs = 400;
734
663
  const strictnessMode = normalizeStrictness(strictness);
735
- const contractTyped = contract_default;
736
- const contractPath = contractTyped[componentName]?.path;
737
- const resolvedPath = new URL(contractPath, import.meta.url).pathname;
664
+ let contractPath = componentConfig?.path;
665
+ if (!contractPath) {
666
+ const contractTyped = contract_default;
667
+ contractPath = contractTyped[componentName]?.path;
668
+ }
669
+ if (!contractPath) {
670
+ throw new Error(`Contract path not found for component: ${componentName}`);
671
+ }
672
+ const resolvedPath = (() => {
673
+ if (path3.isAbsolute(contractPath)) return contractPath;
674
+ if (configBaseDir) {
675
+ const configResolved = path3.resolve(configBaseDir, contractPath);
676
+ try {
677
+ readFileSync2(configResolved, "utf-8");
678
+ return configResolved;
679
+ } catch {
680
+ }
681
+ }
682
+ const cwdResolved = path3.resolve(process.cwd(), contractPath);
683
+ try {
684
+ readFileSync2(cwdResolved, "utf-8");
685
+ return cwdResolved;
686
+ } catch {
687
+ return new URL(contractPath, import.meta.url).pathname;
688
+ }
689
+ })();
738
690
  const contractData = readFileSync2(resolvedPath, "utf-8");
739
691
  const componentContract = JSON.parse(contractData);
740
- const totalTests = componentContract.static[0].assertions.length + componentContract.dynamic.length;
692
+ const totalTests = (componentContract.relationships?.length || 0) + (componentContract.static[0]?.assertions.length || 0) + componentContract.dynamic.length;
741
693
  const apgUrl = componentContract.meta?.source?.apg;
742
694
  const failures = [];
743
695
  const warnings = [];
@@ -774,7 +726,7 @@ async function runContractTestsPlaywright(componentName, url, strictness) {
774
726
  }
775
727
  await page.addStyleTag({ content: `* { transition: none !important; animation: none !important; }` });
776
728
  }
777
- const strategy = ComponentDetector.detect(componentName, actionTimeoutMs, assertionTimeoutMs);
729
+ const strategy = await ComponentDetector.detect(componentName, componentConfig, actionTimeoutMs, assertionTimeoutMs, configBaseDir);
778
730
  if (!strategy) {
779
731
  throw new Error(`Unsupported component: ${componentName}`);
780
732
  }
@@ -807,6 +759,105 @@ This usually means:
807
759
  let staticPassed = 0;
808
760
  let staticFailed = 0;
809
761
  let staticWarnings = 0;
762
+ for (const rel of componentContract.relationships || []) {
763
+ const relationshipLevel = normalizeLevel(rel.level);
764
+ if (rel.type === "aria-reference") {
765
+ const relDescription = `${rel.from}.${rel.attribute} references ${rel.to}`;
766
+ const fromSelector = componentContract.selectors[rel.from];
767
+ const toSelector = componentContract.selectors[rel.to];
768
+ if (!fromSelector || !toSelector) {
769
+ const outcome = classifyFailure(
770
+ `Relationship selector missing: from="${rel.from}" or to="${rel.to}" not found in selectors.`,
771
+ rel.level
772
+ );
773
+ if (outcome.status === "fail") staticFailed += 1;
774
+ if (outcome.status === "warn") staticWarnings += 1;
775
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
776
+ continue;
777
+ }
778
+ const fromTarget = page.locator(fromSelector).first();
779
+ const toTarget = page.locator(toSelector).first();
780
+ const fromExists = await fromTarget.count() > 0;
781
+ const toExists = await toTarget.count() > 0;
782
+ if (!fromExists || !toExists) {
783
+ const outcome = classifyFailure(
784
+ `Relationship target not found: ${!fromExists ? rel.from : rel.to}.`,
785
+ rel.level
786
+ );
787
+ if (outcome.status === "fail") staticFailed += 1;
788
+ if (outcome.status === "warn") staticWarnings += 1;
789
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
790
+ continue;
791
+ }
792
+ const attrValue = await fromTarget.getAttribute(rel.attribute);
793
+ const toId = await toTarget.getAttribute("id");
794
+ if (!toId) {
795
+ const outcome = classifyFailure(
796
+ `Relationship target "${rel.to}" must have an id for ${rel.attribute} validation.`,
797
+ rel.level
798
+ );
799
+ if (outcome.status === "fail") staticFailed += 1;
800
+ if (outcome.status === "warn") staticWarnings += 1;
801
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
802
+ continue;
803
+ }
804
+ const references = (attrValue || "").split(/\s+/).filter(Boolean);
805
+ const matches = references.includes(toId);
806
+ if (!matches) {
807
+ const outcome = classifyFailure(
808
+ `Expected ${rel.from} ${rel.attribute} to reference id "${toId}", found "${attrValue || ""}".`,
809
+ rel.level
810
+ );
811
+ if (outcome.status === "fail") staticFailed += 1;
812
+ if (outcome.status === "warn") staticWarnings += 1;
813
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
814
+ continue;
815
+ }
816
+ passes.push(`Relationship valid: ${rel.from}.${rel.attribute} -> ${rel.to} (id=${toId}).`);
817
+ staticPassed += 1;
818
+ reporter.reportStaticTest(relDescription, "pass", void 0, relationshipLevel);
819
+ continue;
820
+ }
821
+ if (rel.type === "contains") {
822
+ const relDescription = `${rel.parent} contains ${rel.child}`;
823
+ const parentSelector = componentContract.selectors[rel.parent];
824
+ const childSelector = componentContract.selectors[rel.child];
825
+ if (!parentSelector || !childSelector) {
826
+ const outcome = classifyFailure(
827
+ `Relationship selector missing: parent="${rel.parent}" or child="${rel.child}" not found in selectors.`,
828
+ rel.level
829
+ );
830
+ if (outcome.status === "fail") staticFailed += 1;
831
+ if (outcome.status === "warn") staticWarnings += 1;
832
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
833
+ continue;
834
+ }
835
+ const parent = page.locator(parentSelector).first();
836
+ const parentExists = await parent.count() > 0;
837
+ if (!parentExists) {
838
+ const outcome = classifyFailure(`Relationship parent target not found: ${rel.parent}.`, rel.level);
839
+ if (outcome.status === "fail") staticFailed += 1;
840
+ if (outcome.status === "warn") staticWarnings += 1;
841
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
842
+ continue;
843
+ }
844
+ const descendants = parent.locator(childSelector);
845
+ const descendantCount = await descendants.count();
846
+ if (descendantCount < 1) {
847
+ const outcome = classifyFailure(
848
+ `Expected ${rel.parent} to contain descendant matching selector for ${rel.child}.`,
849
+ rel.level
850
+ );
851
+ if (outcome.status === "fail") staticFailed += 1;
852
+ if (outcome.status === "warn") staticWarnings += 1;
853
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
854
+ continue;
855
+ }
856
+ passes.push(`Relationship valid: ${rel.parent} contains ${rel.child}.`);
857
+ staticPassed += 1;
858
+ reporter.reportStaticTest(relDescription, "pass", void 0, relationshipLevel);
859
+ }
860
+ }
810
861
  const staticAssertionRunner = new AssertionRunner(page, componentContract.selectors, assertionTimeoutMs);
811
862
  for (const test of componentContract.static[0]?.assertions || []) {
812
863
  if (test.target === "relative") continue;