aria-ease 6.8.0 → 6.9.1

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 (40) hide show
  1. package/README.md +68 -6
  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 +794 -90
  24. package/dist/index.d.cts +136 -1
  25. package/dist/index.d.ts +136 -1
  26. package/dist/index.js +415 -10
  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/{chunk-2TOYEY5L.js → chunk-XERMSYEH.js} +12 -3
  32. package/dist/src/utils/test/{configLoader-LD4RV2WQ.js → configLoader-YE2CYGDG.js} +21 -0
  33. package/dist/src/utils/test/{contractTestRunnerPlaywright-IRJOAEMT.js → contractTestRunnerPlaywright-LC5OAVXB.js} +262 -200
  34. package/dist/src/utils/test/dsl/index.cjs +320 -0
  35. package/dist/src/utils/test/dsl/index.d.cts +136 -0
  36. package/dist/src/utils/test/dsl/index.d.ts +136 -0
  37. package/dist/src/utils/test/dsl/index.js +318 -0
  38. package/dist/src/utils/test/index.cjs +472 -88
  39. package/dist/src/utils/test/index.js +97 -12
  40. package/package.json +9 -3
@@ -32,11 +32,13 @@ var ContractReporter = class {
32
32
  skipped = 0;
33
33
  warnings = 0;
34
34
  isPlaywright = false;
35
+ isCustomContract = false;
35
36
  apgUrl = "https://www.w3.org/WAI/ARIA/apg/";
36
37
  hasPrintedStaticSection = false;
37
38
  hasPrintedDynamicSection = false;
38
- constructor(isPlaywright = false) {
39
+ constructor(isPlaywright = false, isCustomContract = false) {
39
40
  this.isPlaywright = isPlaywright;
41
+ this.isCustomContract = isCustomContract;
40
42
  }
41
43
  log(message) {
42
44
  process.stderr.write(message + "\n");
@@ -197,6 +199,13 @@ ${"\u2500".repeat(60)}`);
197
199
  const totalPasses = this.staticPasses + dynamicPasses;
198
200
  const totalFailures = this.staticFailures + dynamicFailures;
199
201
  const totalRun = totalPasses + totalFailures + this.warnings;
202
+ const getComponentMessage = () => {
203
+ const componentDisplayName = `${this.componentName.charAt(0).toUpperCase()}${this.componentName.slice(1)}`;
204
+ if (this.isCustomContract) {
205
+ return `${componentDisplayName} component validates against your custom accessibility policy \u2713`;
206
+ }
207
+ return `${componentDisplayName} component meets Aria-Ease baseline WAI-ARIA expectations \u2713`;
208
+ };
200
209
  if (failures.length > 0) {
201
210
  this.reportFailures(failures);
202
211
  }
@@ -208,7 +217,7 @@ ${"\u2550".repeat(60)}`);
208
217
  `);
209
218
  if (totalFailures === 0 && this.skipped === 0 && this.warnings === 0) {
210
219
  this.log(`\u2705 All ${totalRun} tests passed!`);
211
- this.log(` ${this.componentName.charAt(0).toUpperCase()}${this.componentName.slice(1)} component meets WAI-ARIA expectations for Roles, States, Properties, and Keyboard Interactions \u2713`);
220
+ this.log(` ${getComponentMessage()}`);
212
221
  } else if (totalFailures === 0) {
213
222
  this.log(`\u2705 ${totalPasses}/${totalRun} tests passed`);
214
223
  if (this.skipped > 0) {
@@ -217,7 +226,7 @@ ${"\u2550".repeat(60)}`);
217
226
  if (this.warnings > 0) {
218
227
  this.log(`\u26A0\uFE0F ${this.warnings} warning${this.warnings > 1 ? "s" : ""}`);
219
228
  }
220
- this.log(` ${this.componentName.charAt(0).toUpperCase()}${this.componentName.slice(1)} component meets WAI-ARIA expectations for Roles, States, Properties, and Keyboard Interactions \u2713`);
229
+ this.log(` ${getComponentMessage()}`);
221
230
  } else {
222
231
  this.log(`\u274C ${totalFailures} test${totalFailures > 1 ? "s" : ""} failed`);
223
232
  this.log(`\u2705 ${totalPasses} test${totalPasses > 1 ? "s" : ""} passed`);
@@ -55,6 +55,9 @@ function validateConfig(config) {
55
55
  if (comp.path !== void 0 && typeof comp.path !== "string") {
56
56
  errors.push(`test.components[${idx}].path must be a string when provided`);
57
57
  }
58
+ if (comp.strategyPath !== void 0 && typeof comp.strategyPath !== "string") {
59
+ errors.push(`test.components[${idx}].strategyPath must be a string when provided`);
60
+ }
58
61
  if (comp.strictness !== void 0 && !["minimal", "balanced", "strict", "paranoid"].includes(comp.strictness)) {
59
62
  errors.push(`test.components[${idx}].strictness must be one of: minimal, balanced, strict, paranoid`);
60
63
  }
@@ -69,6 +72,24 @@ function validateConfig(config) {
69
72
  }
70
73
  }
71
74
  }
75
+ if (cfg.contracts !== void 0) {
76
+ if (!Array.isArray(cfg.contracts)) {
77
+ errors.push("contracts must be an array");
78
+ } else {
79
+ cfg.contracts.forEach((contract, idx) => {
80
+ if (typeof contract !== "object" || contract === null) {
81
+ errors.push(`contracts[${idx}] must be an object`);
82
+ } else {
83
+ if (typeof contract.src !== "string") {
84
+ errors.push(`contracts[${idx}].src is required and must be a string`);
85
+ }
86
+ if (contract.out !== void 0 && typeof contract.out !== "string") {
87
+ errors.push(`contracts[${idx}].out must be a string`);
88
+ }
89
+ }
90
+ });
91
+ }
92
+ }
72
93
  return { valid: errors.length === 0, errors };
73
94
  }
74
95
  async function loadConfigFile(filePath) {
@@ -1,220 +1,158 @@
1
- import { ContractReporter, normalizeStrictness, contract_default, createTestPage, normalizeLevel, resolveEnforcement } from './chunk-2TOYEY5L.js';
1
+ import { contract_default, ContractReporter, normalizeStrictness, createTestPage, normalizeLevel, resolveEnforcement } from './chunk-XERMSYEH.js';
2
2
  import { readFileSync } from 'fs';
3
+ import path2 from 'path';
4
+ import { pathToFileURL } from 'url';
3
5
  import { expect } from '@playwright/test';
4
6
 
5
- var ComboboxComponentStrategy = class {
6
- constructor(mainSelector, selectors, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
7
- this.mainSelector = mainSelector;
8
- this.selectors = selectors;
9
- this.actionTimeoutMs = actionTimeoutMs;
10
- this.assertionTimeoutMs = assertionTimeoutMs;
11
- }
12
- async resetState(page) {
13
- if (!this.selectors.popup) return;
14
- const popupSelector = this.selectors.popup;
15
- const popupElement = page.locator(popupSelector).first();
16
- const isPopupVisible = await popupElement.isVisible().catch(() => false);
17
- if (!isPopupVisible) return;
18
- let menuClosed = false;
19
- let closeSelector = this.selectors.input;
20
- if (!closeSelector && this.selectors.focusable) {
21
- closeSelector = this.selectors.focusable;
22
- } else if (!closeSelector) {
23
- closeSelector = this.selectors.trigger;
24
- }
25
- if (closeSelector) {
26
- const closeElement = page.locator(closeSelector).first();
27
- await closeElement.focus();
28
- await page.keyboard.press("Escape");
29
- menuClosed = await expect(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
30
- }
31
- if (!menuClosed && this.selectors.trigger) {
32
- const triggerElement = page.locator(this.selectors.trigger).first();
33
- await triggerElement.click({ timeout: this.actionTimeoutMs });
34
- menuClosed = await expect(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
35
- }
36
- if (!menuClosed) {
37
- await page.mouse.click(10, 10);
38
- menuClosed = await expect(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
39
- }
40
- if (!menuClosed) {
41
- throw new Error(
42
- `\u274C FATAL: Cannot close combobox popup between tests. Popup remains visible after trying:
43
- 1. Escape key
44
- 2. Clicking trigger
45
- 3. Clicking outside
46
- This indicates a problem with the combobox component's close functionality.`
47
- );
48
- }
49
- if (this.selectors.input) {
50
- await page.locator(this.selectors.input).first().clear();
51
- }
52
- }
53
- async shouldSkipTest() {
54
- return false;
7
+ var StrategyRegistry = class {
8
+ builtInStrategies = /* @__PURE__ */ new Map();
9
+ constructor() {
10
+ this.registerBuiltInStrategies();
55
11
  }
56
- getMainSelector() {
57
- return this.mainSelector;
58
- }
59
- };
60
- var AccordionComponentStrategy = class {
61
- constructor(mainSelector, selectors, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
62
- this.mainSelector = mainSelector;
63
- this.selectors = selectors;
64
- this.actionTimeoutMs = actionTimeoutMs;
65
- this.assertionTimeoutMs = assertionTimeoutMs;
12
+ /**
13
+ * Register built-in strategies
14
+ */
15
+ registerBuiltInStrategies() {
16
+ this.builtInStrategies.set(
17
+ "menu",
18
+ () => import('./MenuComponentStrategy-VKZQYLBE.js').then(
19
+ (m) => m.MenuComponentStrategy
20
+ )
21
+ );
22
+ this.builtInStrategies.set(
23
+ "accordion",
24
+ () => import('./AccordionComponentStrategy-WRHZOEN6.js').then(
25
+ (m) => m.AccordionComponentStrategy
26
+ )
27
+ );
28
+ this.builtInStrategies.set(
29
+ "combobox",
30
+ () => import('./ComboboxComponentStrategy-5AECQSRN.js').then(
31
+ (m) => m.ComboboxComponentStrategy
32
+ )
33
+ );
34
+ this.builtInStrategies.set(
35
+ "tabs",
36
+ () => import('./TabsComponentStrategy-BKG53SEV.js').then(
37
+ (m) => m.TabsComponentStrategy
38
+ )
39
+ );
40
+ this.builtInStrategies.set(
41
+ "combobox.listbox",
42
+ () => import('./ComboboxComponentStrategy-5AECQSRN.js').then(
43
+ (m) => m.ComboboxComponentStrategy
44
+ )
45
+ );
66
46
  }
67
- async resetState(page) {
68
- if (!this.selectors.panel || !this.selectors.trigger || this.selectors.popup) {
69
- return;
70
- }
71
- const triggerSelector = this.selectors.trigger;
72
- const panelSelector = this.selectors.panel;
73
- if (!triggerSelector || !panelSelector) return;
74
- const allTriggers = await page.locator(triggerSelector).all();
75
- for (const trigger of allTriggers) {
76
- const isExpanded = await trigger.getAttribute("aria-expanded") === "true";
77
- const triggerPanel = await trigger.getAttribute("aria-controls");
78
- if (isExpanded && triggerPanel) {
79
- await trigger.click({ timeout: this.actionTimeoutMs });
80
- const panel = page.locator(`#${triggerPanel}`);
81
- await expect(panel).toBeHidden({ timeout: this.assertionTimeoutMs }).catch(() => {
82
- });
47
+ /**
48
+ * Load a strategy - either from custom path or built-in registry
49
+ * @param componentName - Component name (e.g., "menu", "accordion")
50
+ * @param customStrategyPath - Optional custom strategy file path
51
+ * @returns Strategy constructor function or null if not found
52
+ */
53
+ async loadStrategy(componentName, customStrategyPath, configBaseDir) {
54
+ try {
55
+ if (customStrategyPath) {
56
+ try {
57
+ const resolvedCustomPath = path2.isAbsolute(customStrategyPath) ? customStrategyPath : path2.resolve(configBaseDir || process.cwd(), customStrategyPath);
58
+ const customModule = await import(pathToFileURL(resolvedCustomPath).href);
59
+ const strategy = customModule.default || customModule;
60
+ if (!strategy) {
61
+ throw new Error(`No default export found in ${customStrategyPath}`);
62
+ }
63
+ return strategy;
64
+ } catch (error) {
65
+ throw new Error(
66
+ `Failed to load custom strategy from ${customStrategyPath}: ${error instanceof Error ? error.message : String(error)}`
67
+ );
68
+ }
83
69
  }
84
- }
85
- }
86
- async shouldSkipTest() {
87
- return false;
88
- }
89
- getMainSelector() {
90
- return this.mainSelector;
91
- }
92
- };
93
- var MenuComponentStrategy = class {
94
- constructor(mainSelector, selectors, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
95
- this.mainSelector = mainSelector;
96
- this.selectors = selectors;
97
- this.actionTimeoutMs = actionTimeoutMs;
98
- this.assertionTimeoutMs = assertionTimeoutMs;
99
- }
100
- async resetState(page) {
101
- if (!this.selectors.popup) return;
102
- const popupSelector = this.selectors.popup;
103
- const popupElement = page.locator(popupSelector).first();
104
- const isPopupVisible = await popupElement.isVisible().catch(() => false);
105
- if (!isPopupVisible) return;
106
- let menuClosed = false;
107
- let closeSelector = this.selectors.input;
108
- if (!closeSelector && this.selectors.focusable) {
109
- closeSelector = this.selectors.focusable;
110
- } else if (!closeSelector) {
111
- closeSelector = this.selectors.trigger;
112
- }
113
- if (closeSelector) {
114
- const closeElement = page.locator(closeSelector).first();
115
- await closeElement.focus();
116
- await page.keyboard.press("Escape");
117
- menuClosed = await expect(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
118
- }
119
- if (!menuClosed && this.selectors.trigger) {
120
- const triggerElement = page.locator(this.selectors.trigger).first();
121
- await triggerElement.click({ timeout: this.actionTimeoutMs });
122
- menuClosed = await expect(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
123
- }
124
- if (!menuClosed) {
125
- await page.mouse.click(10, 10);
126
- menuClosed = await expect(popupElement).toBeHidden({ timeout: this.assertionTimeoutMs }).then(() => true).catch(() => false);
127
- }
128
- if (!menuClosed) {
70
+ const builtInLoader = this.builtInStrategies.get(componentName);
71
+ if (!builtInLoader) {
72
+ return null;
73
+ }
74
+ return builtInLoader();
75
+ } catch (error) {
129
76
  throw new Error(
130
- `\u274C FATAL: Cannot close menu between tests. Menu remains visible after trying:
131
- 1. Escape key
132
- 2. Clicking trigger
133
- 3. Clicking outside
134
- This indicates a problem with the menu component's close functionality.`
77
+ `Strategy loading failed for ${componentName}: ${error instanceof Error ? error.message : String(error)}`
135
78
  );
136
79
  }
137
- if (this.selectors.input) {
138
- await page.locator(this.selectors.input).first().clear();
139
- }
140
- if (this.selectors.trigger) {
141
- const triggerElement = page.locator(this.selectors.trigger).first();
142
- await triggerElement.focus();
143
- }
144
80
  }
145
- async shouldSkipTest(test, page) {
146
- const requiresSubmenu = test.action.some(
147
- (act) => act.target === "submenu" || act.target === "submenuTrigger" || act.target === "submenuItems"
148
- ) || test.assertions.some(
149
- (assertion) => assertion.target === "submenu" || assertion.target === "submenuTrigger" || assertion.target === "submenuItems"
150
- );
151
- if (!requiresSubmenu) {
152
- return false;
153
- }
154
- const submenuTriggerSelector = this.selectors.submenuTrigger;
155
- if (!submenuTriggerSelector) {
156
- return true;
157
- }
158
- const submenuTriggerCount = await page.locator(submenuTriggerSelector).count();
159
- return submenuTriggerCount === 0;
160
- }
161
- getMainSelector() {
162
- return this.mainSelector;
81
+ /**
82
+ * Check if a strategy exists (either built-in or custom path provided)
83
+ */
84
+ has(componentName, customStrategyPath) {
85
+ return !!customStrategyPath || this.builtInStrategies.has(componentName);
163
86
  }
164
87
  };
165
88
 
166
- // src/utils/test/src/component-strategies/TabsComponentStrategy.ts
167
- var TabsComponentStrategy = class {
168
- constructor(mainSelector, selectors) {
169
- this.mainSelector = mainSelector;
170
- this.selectors = selectors;
171
- }
172
- async resetState() {
89
+ // src/utils/test/src/ComponentDetector.ts
90
+ var ComponentDetector = class {
91
+ static strategyRegistry = new StrategyRegistry();
92
+ static isComponentConfig(value) {
93
+ return typeof value === "object" && value !== null;
173
94
  }
174
- async shouldSkipTest(test, page) {
175
- if (test.isVertical !== void 0 && this.selectors.tablist) {
176
- const tablistSelector = this.selectors.tablist;
177
- const tablist = page.locator(tablistSelector).first();
178
- const orientation = await tablist.getAttribute("aria-orientation");
179
- const isVertical = orientation === "vertical";
180
- if (test.isVertical !== isVertical) {
181
- return true;
182
- }
95
+ /**
96
+ * Detect and instantiate a component strategy
97
+ * Supports:
98
+ * - Built-in strategies (menu, accordion, combobox, tabs)
99
+ * - Custom strategies via config (strategyPath)
100
+ * - Custom contract paths via config (path)
101
+ * @param componentName - Component name
102
+ * @param componentConfig - Component config from ariaease.config.js
103
+ * @param actionTimeoutMs - Action timeout in milliseconds
104
+ * @param assertionTimeoutMs - Assertion timeout in milliseconds
105
+ * @returns Instantiated ComponentStrategy or null
106
+ */
107
+ static async detect(componentName, componentConfig, actionTimeoutMs = 400, assertionTimeoutMs = 400, configBaseDir) {
108
+ const typedComponentConfig = this.isComponentConfig(componentConfig) ? componentConfig : void 0;
109
+ let contractPath = typedComponentConfig?.path;
110
+ if (!contractPath) {
111
+ const contractTyped = contract_default;
112
+ contractPath = contractTyped[componentName]?.path;
183
113
  }
184
- return false;
185
- }
186
- getMainSelector() {
187
- return this.mainSelector;
188
- }
189
- };
190
- var ComponentDetector = class {
191
- static detect(componentName, actionTimeoutMs = 400, assertionTimeoutMs = 400) {
192
- const contractTyped = contract_default;
193
- const contractPath = contractTyped[componentName]?.path;
194
114
  if (!contractPath) {
195
115
  throw new Error(`Contract path not found for component: ${componentName}`);
196
116
  }
197
- const resolvedPath = new URL(contractPath, import.meta.url).pathname;
117
+ const resolvedPath = (() => {
118
+ if (path2.isAbsolute(contractPath)) return contractPath;
119
+ if (configBaseDir) {
120
+ const configResolved = path2.resolve(configBaseDir, contractPath);
121
+ try {
122
+ readFileSync(configResolved, "utf-8");
123
+ return configResolved;
124
+ } catch {
125
+ }
126
+ }
127
+ const cwdResolved = path2.resolve(process.cwd(), contractPath);
128
+ try {
129
+ readFileSync(cwdResolved, "utf-8");
130
+ return cwdResolved;
131
+ } catch {
132
+ return new URL(contractPath, import.meta.url).pathname;
133
+ }
134
+ })();
198
135
  const contractData = readFileSync(resolvedPath, "utf-8");
199
136
  const componentContract = JSON.parse(contractData);
200
137
  const selectors = componentContract.selectors;
201
- if (componentName.includes("combobox")) {
202
- const mainSelector = selectors.input || selectors.container;
203
- return new ComboboxComponentStrategy(mainSelector, selectors, actionTimeoutMs, assertionTimeoutMs);
204
- }
205
- if (componentName === "accordion") {
206
- const mainSelector = selectors.trigger || selectors.container;
207
- return new AccordionComponentStrategy(mainSelector, selectors, actionTimeoutMs, assertionTimeoutMs);
208
- }
209
- if (componentName === "menu") {
210
- const mainSelector = selectors.trigger || selectors.container;
211
- return new MenuComponentStrategy(mainSelector, selectors, actionTimeoutMs, assertionTimeoutMs);
138
+ const strategyClass = await this.strategyRegistry.loadStrategy(
139
+ componentName,
140
+ typedComponentConfig?.strategyPath,
141
+ configBaseDir
142
+ );
143
+ if (!strategyClass) {
144
+ return null;
212
145
  }
146
+ const mainSelector = selectors.trigger || selectors.input || selectors.tablist || selectors.container;
213
147
  if (componentName === "tabs") {
214
- const mainSelector = selectors.tablist || selectors.tab;
215
- return new TabsComponentStrategy(mainSelector, selectors);
148
+ return new strategyClass(mainSelector, selectors);
216
149
  }
217
- return null;
150
+ return new strategyClass(
151
+ mainSelector,
152
+ selectors,
153
+ actionTimeoutMs,
154
+ assertionTimeoutMs
155
+ );
218
156
  }
219
157
  };
220
158
 
@@ -696,17 +634,42 @@ var AssertionRunner = class {
696
634
  };
697
635
 
698
636
  // src/utils/test/src/contractTestRunnerPlaywright.ts
699
- async function runContractTestsPlaywright(componentName, url, strictness) {
700
- const reporter = new ContractReporter(true);
637
+ async function runContractTestsPlaywright(componentName, url, strictness, config, configBaseDir) {
638
+ const componentConfig = config?.test?.components?.find((c) => c.name === componentName);
639
+ const isCustomContract = !!componentConfig?.path;
640
+ const reporter = new ContractReporter(true, isCustomContract);
701
641
  const actionTimeoutMs = 400;
702
642
  const assertionTimeoutMs = 400;
703
643
  const strictnessMode = normalizeStrictness(strictness);
704
- const contractTyped = contract_default;
705
- const contractPath = contractTyped[componentName]?.path;
706
- const resolvedPath = new URL(contractPath, import.meta.url).pathname;
644
+ let contractPath = componentConfig?.path;
645
+ if (!contractPath) {
646
+ const contractTyped = contract_default;
647
+ contractPath = contractTyped[componentName]?.path;
648
+ }
649
+ if (!contractPath) {
650
+ throw new Error(`Contract path not found for component: ${componentName}`);
651
+ }
652
+ const resolvedPath = (() => {
653
+ if (path2.isAbsolute(contractPath)) return contractPath;
654
+ if (configBaseDir) {
655
+ const configResolved = path2.resolve(configBaseDir, contractPath);
656
+ try {
657
+ readFileSync(configResolved, "utf-8");
658
+ return configResolved;
659
+ } catch {
660
+ }
661
+ }
662
+ const cwdResolved = path2.resolve(process.cwd(), contractPath);
663
+ try {
664
+ readFileSync(cwdResolved, "utf-8");
665
+ return cwdResolved;
666
+ } catch {
667
+ return new URL(contractPath, import.meta.url).pathname;
668
+ }
669
+ })();
707
670
  const contractData = readFileSync(resolvedPath, "utf-8");
708
671
  const componentContract = JSON.parse(contractData);
709
- const totalTests = componentContract.static[0].assertions.length + componentContract.dynamic.length;
672
+ const totalTests = (componentContract.relationships?.length || 0) + (componentContract.static[0]?.assertions.length || 0) + componentContract.dynamic.length;
710
673
  const apgUrl = componentContract.meta?.source?.apg;
711
674
  const failures = [];
712
675
  const warnings = [];
@@ -743,7 +706,7 @@ async function runContractTestsPlaywright(componentName, url, strictness) {
743
706
  }
744
707
  await page.addStyleTag({ content: `* { transition: none !important; animation: none !important; }` });
745
708
  }
746
- const strategy = ComponentDetector.detect(componentName, actionTimeoutMs, assertionTimeoutMs);
709
+ const strategy = await ComponentDetector.detect(componentName, componentConfig, actionTimeoutMs, assertionTimeoutMs, configBaseDir);
747
710
  if (!strategy) {
748
711
  throw new Error(`Unsupported component: ${componentName}`);
749
712
  }
@@ -776,6 +739,105 @@ This usually means:
776
739
  let staticPassed = 0;
777
740
  let staticFailed = 0;
778
741
  let staticWarnings = 0;
742
+ for (const rel of componentContract.relationships || []) {
743
+ const relationshipLevel = normalizeLevel(rel.level);
744
+ if (rel.type === "aria-reference") {
745
+ const relDescription = `${rel.from}.${rel.attribute} references ${rel.to}`;
746
+ const fromSelector = componentContract.selectors[rel.from];
747
+ const toSelector = componentContract.selectors[rel.to];
748
+ if (!fromSelector || !toSelector) {
749
+ const outcome = classifyFailure(
750
+ `Relationship selector missing: from="${rel.from}" or to="${rel.to}" not found in selectors.`,
751
+ rel.level
752
+ );
753
+ if (outcome.status === "fail") staticFailed += 1;
754
+ if (outcome.status === "warn") staticWarnings += 1;
755
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
756
+ continue;
757
+ }
758
+ const fromTarget = page.locator(fromSelector).first();
759
+ const toTarget = page.locator(toSelector).first();
760
+ const fromExists = await fromTarget.count() > 0;
761
+ const toExists = await toTarget.count() > 0;
762
+ if (!fromExists || !toExists) {
763
+ const outcome = classifyFailure(
764
+ `Relationship target not found: ${!fromExists ? rel.from : rel.to}.`,
765
+ rel.level
766
+ );
767
+ if (outcome.status === "fail") staticFailed += 1;
768
+ if (outcome.status === "warn") staticWarnings += 1;
769
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
770
+ continue;
771
+ }
772
+ const attrValue = await fromTarget.getAttribute(rel.attribute);
773
+ const toId = await toTarget.getAttribute("id");
774
+ if (!toId) {
775
+ const outcome = classifyFailure(
776
+ `Relationship target "${rel.to}" must have an id for ${rel.attribute} validation.`,
777
+ rel.level
778
+ );
779
+ if (outcome.status === "fail") staticFailed += 1;
780
+ if (outcome.status === "warn") staticWarnings += 1;
781
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
782
+ continue;
783
+ }
784
+ const references = (attrValue || "").split(/\s+/).filter(Boolean);
785
+ const matches = references.includes(toId);
786
+ if (!matches) {
787
+ const outcome = classifyFailure(
788
+ `Expected ${rel.from} ${rel.attribute} to reference id "${toId}", found "${attrValue || ""}".`,
789
+ rel.level
790
+ );
791
+ if (outcome.status === "fail") staticFailed += 1;
792
+ if (outcome.status === "warn") staticWarnings += 1;
793
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
794
+ continue;
795
+ }
796
+ passes.push(`Relationship valid: ${rel.from}.${rel.attribute} -> ${rel.to} (id=${toId}).`);
797
+ staticPassed += 1;
798
+ reporter.reportStaticTest(relDescription, "pass", void 0, relationshipLevel);
799
+ continue;
800
+ }
801
+ if (rel.type === "contains") {
802
+ const relDescription = `${rel.parent} contains ${rel.child}`;
803
+ const parentSelector = componentContract.selectors[rel.parent];
804
+ const childSelector = componentContract.selectors[rel.child];
805
+ if (!parentSelector || !childSelector) {
806
+ const outcome = classifyFailure(
807
+ `Relationship selector missing: parent="${rel.parent}" or child="${rel.child}" not found in selectors.`,
808
+ rel.level
809
+ );
810
+ if (outcome.status === "fail") staticFailed += 1;
811
+ if (outcome.status === "warn") staticWarnings += 1;
812
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
813
+ continue;
814
+ }
815
+ const parent = page.locator(parentSelector).first();
816
+ const parentExists = await parent.count() > 0;
817
+ if (!parentExists) {
818
+ const outcome = classifyFailure(`Relationship parent target not found: ${rel.parent}.`, rel.level);
819
+ if (outcome.status === "fail") staticFailed += 1;
820
+ if (outcome.status === "warn") staticWarnings += 1;
821
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
822
+ continue;
823
+ }
824
+ const descendants = parent.locator(childSelector);
825
+ const descendantCount = await descendants.count();
826
+ if (descendantCount < 1) {
827
+ const outcome = classifyFailure(
828
+ `Expected ${rel.parent} to contain descendant matching selector for ${rel.child}.`,
829
+ rel.level
830
+ );
831
+ if (outcome.status === "fail") staticFailed += 1;
832
+ if (outcome.status === "warn") staticWarnings += 1;
833
+ reporter.reportStaticTest(relDescription, outcome.status, outcome.detail, outcome.level);
834
+ continue;
835
+ }
836
+ passes.push(`Relationship valid: ${rel.parent} contains ${rel.child}.`);
837
+ staticPassed += 1;
838
+ reporter.reportStaticTest(relDescription, "pass", void 0, relationshipLevel);
839
+ }
840
+ }
779
841
  const staticAssertionRunner = new AssertionRunner(page, componentContract.selectors, assertionTimeoutMs);
780
842
  for (const test of componentContract.static[0]?.assertions || []) {
781
843
  if (test.target === "relative") continue;