@testspectra/cli 1.1.7 → 1.1.8-rc.2

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 (261) hide show
  1. package/bin/.testspectra-runner.hash +1 -1
  2. package/bin/spectra.js +0 -0
  3. package/bin/testspectra-runner.exe +0 -0
  4. package/demo-app/assets/index-BhlW-eXG.css +1 -0
  5. package/demo-app/assets/index-DOtRypCa.js +154 -0
  6. package/demo-app/index.html +2 -2
  7. package/dist/.tsbuildinfo +1 -1
  8. package/dist/commands/__tests__/run-e2e.test.js +270 -29
  9. package/dist/commands/__tests__/run.test.js +92 -16
  10. package/dist/commands/demo.js +17 -7
  11. package/dist/commands/init.d.ts +1 -1
  12. package/dist/commands/init.js +154 -149
  13. package/dist/commands/lint.d.ts +5 -0
  14. package/dist/commands/lint.js +55 -0
  15. package/dist/commands/refactor.d.ts +2 -1
  16. package/dist/commands/refactor.js +129 -35
  17. package/dist/commands/run.d.ts +2 -3
  18. package/dist/commands/run.js +93 -28
  19. package/dist/commands/skills.d.ts +9 -0
  20. package/dist/commands/skills.js +28 -0
  21. package/dist/commands/sync-types.js +1 -0
  22. package/dist/commands/test.d.ts +17 -0
  23. package/dist/commands/test.js +104 -0
  24. package/dist/commands/watch.js +9 -3
  25. package/dist/config/loader.js +1 -1
  26. package/dist/config/schema.d.ts +5 -114
  27. package/dist/config/schema.js +1 -17
  28. package/dist/generator/__tests__/tsconfig-isolation.test.js +6 -2
  29. package/dist/generator/__tests__/type-generator.test.js +1 -1
  30. package/dist/generator/tsconfig-generator.js +44 -16
  31. package/dist/generator/type-generator.d.ts +24 -1
  32. package/dist/generator/type-generator.js +104 -8
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +76 -40
  35. package/dist/linter/__tests__/anonymous-export.test.js +184 -0
  36. package/dist/linter/__tests__/component-test-imports.test.d.ts +1 -0
  37. package/dist/linter/__tests__/component-test-imports.test.js +115 -0
  38. package/dist/linter/__tests__/shared-lib-boundary.test.d.ts +1 -0
  39. package/dist/linter/__tests__/shared-lib-boundary.test.js +270 -0
  40. package/dist/linter/__tests__/spec-schema.test.d.ts +1 -0
  41. package/dist/linter/__tests__/spec-schema.test.js +369 -0
  42. package/dist/linter/__tests__/step-parity.test.d.ts +1 -0
  43. package/dist/linter/__tests__/step-parity.test.js +372 -0
  44. package/dist/linter/discovery.d.ts +30 -0
  45. package/dist/linter/discovery.js +249 -0
  46. package/dist/linter/extractor.d.ts +67 -0
  47. package/dist/linter/extractor.js +245 -0
  48. package/dist/linter/index.d.ts +7 -0
  49. package/dist/linter/index.js +7 -0
  50. package/dist/linter/parser.d.ts +24 -0
  51. package/dist/linter/parser.js +219 -0
  52. package/dist/linter/schemas/spec.schema.d.ts +83 -0
  53. package/dist/linter/schemas/spec.schema.js +150 -0
  54. package/dist/linter/schemas/suite.schema.d.ts +59 -0
  55. package/dist/linter/schemas/suite.schema.js +83 -0
  56. package/dist/linter/spec-linter.d.ts +23 -0
  57. package/dist/linter/spec-linter.js +630 -0
  58. package/dist/linter/types.d.ts +28 -0
  59. package/dist/linter/types.js +1 -0
  60. package/dist/refactor/__tests__/refactor-engine.test.js +698 -0
  61. package/dist/refactor/refactor-engine.d.ts +25 -0
  62. package/dist/refactor/refactor-engine.js +374 -42
  63. package/dist/reporter/semantic-formatter.js +22 -2
  64. package/dist/reporter/types.d.ts +1 -1
  65. package/dist/runner/bridge.d.ts +1 -1
  66. package/dist/runner/bridge.js +34 -21
  67. package/dist/runner/reporter.d.ts +2 -0
  68. package/dist/runner/reporter.js +11 -3
  69. package/dist/utils/__tests__/api-server.test.d.ts +1 -0
  70. package/dist/utils/__tests__/api-server.test.js +62 -0
  71. package/dist/utils/__tests__/demo-server.test.d.ts +1 -0
  72. package/dist/utils/__tests__/demo-server.test.js +35 -0
  73. package/dist/utils/api-server.d.ts +19 -0
  74. package/dist/utils/api-server.js +179 -0
  75. package/dist/utils/demo-server.d.ts +5 -0
  76. package/dist/utils/demo-server.js +8 -1
  77. package/package.json +29 -25
  78. package/templates/default/CLAUDE.md +16 -11
  79. package/templates/default/global-hooks/after/mobile.hook.ts +3 -3
  80. package/templates/default/global-hooks/after/web.hook.ts +3 -3
  81. package/templates/default/global-hooks/before/mobile.hook.ts +3 -3
  82. package/templates/default/global-hooks/before/web.hook.ts +3 -3
  83. package/templates/default/package.json +3 -3
  84. package/templates/default/page-objects/AuthPage/mobile.ts +5 -0
  85. package/templates/default/page-objects/AuthPage/web.ts +10 -0
  86. package/templates/default/page-objects/MatchersPage/mobile.ts +70 -46
  87. package/templates/default/page-objects/MatchersPage/web.ts +43 -37
  88. package/templates/default/page-objects/NavigationPage/mobile.ts +14 -14
  89. package/templates/default/page-objects/NavigationPage/web.ts +8 -8
  90. package/templates/default/page-objects/PlaygroundPage/mobile.ts +42 -36
  91. package/templates/default/page-objects/PlaygroundPage/web.ts +31 -31
  92. package/templates/default/specs/ApiInterception/TC-0008-fetch-mocking/mobile.test.ts +53 -11
  93. package/templates/default/specs/ApiInterception/TC-0008-fetch-mocking/spec.md +19 -12
  94. package/templates/default/specs/ApiInterception/TC-0008-fetch-mocking/web.test.ts +31 -27
  95. package/templates/default/specs/ApiInterception/TC-0009-post-mocking/android.test.ts +3 -0
  96. package/templates/default/specs/ApiInterception/TC-0009-post-mocking/common.test.ts +3 -0
  97. package/templates/default/specs/ApiInterception/TC-0009-post-mocking/mobile.test.ts +27 -16
  98. package/templates/default/specs/ApiInterception/TC-0009-post-mocking/spec.md +19 -12
  99. package/templates/default/specs/ApiInterception/TC-0009-post-mocking/web.test.ts +26 -24
  100. package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/common.test.ts +30 -0
  101. package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/spec.md +22 -0
  102. package/templates/default/specs/ApiInterception/TC-0013-real-network-call-recorded/common.test.ts +27 -0
  103. package/templates/default/specs/ApiInterception/TC-0013-real-network-call-recorded/spec.md +20 -0
  104. package/templates/default/specs/ApiInterception/TC-0015-mock-server-sent-events/spec.md +24 -0
  105. package/templates/default/specs/ApiInterception/TC-0015-mock-server-sent-events/web.test.ts +26 -0
  106. package/templates/default/specs/ApiInterception/TC-0016-bypass-websocket-with-app-level-mock/spec.md +37 -0
  107. package/templates/default/specs/ApiInterception/TC-0016-bypass-websocket-with-app-level-mock/web.test.ts +21 -0
  108. package/templates/default/specs/ApiInterception/TC-0017-bypass-captcha-with-test-mode-flag/spec.md +29 -0
  109. package/templates/default/specs/ApiInterception/TC-0017-bypass-captcha-with-test-mode-flag/web.test.ts +16 -0
  110. package/templates/default/specs/ApiInterception/suite.md +10 -9
  111. package/templates/default/specs/ApiSeeding/TC-0014-seed-and-cleanup-via-hooks/common.test.ts +30 -0
  112. package/templates/default/specs/ApiSeeding/TC-0014-seed-and-cleanup-via-hooks/spec.md +23 -0
  113. package/templates/default/specs/ApiSeeding/hooks/after/common.hook.ts +16 -0
  114. package/templates/default/specs/ApiSeeding/hooks/before/common.hook.ts +28 -0
  115. package/templates/default/specs/ApiSeeding/suite.md +9 -0
  116. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +7 -0
  117. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/spec.md +19 -0
  118. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/web.test.ts +9 -0
  119. package/templates/default/specs/Authentication/suite.md +9 -0
  120. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +9 -9
  121. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/spec.md +17 -12
  122. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +19 -19
  123. package/templates/default/specs/BrowserContext/TC-0022-history-navigation/mobile.test.ts +17 -0
  124. package/templates/default/specs/BrowserContext/TC-0022-history-navigation/spec.md +28 -0
  125. package/templates/default/specs/BrowserContext/TC-0022-history-navigation/web.test.ts +26 -0
  126. package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/common.test.ts +26 -0
  127. package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/spec.md +19 -12
  128. package/templates/default/specs/ContentMatchers/TC-0006-collections-and-empty/mobile.test.ts +11 -11
  129. package/templates/default/specs/ContentMatchers/TC-0006-collections-and-empty/spec.md +17 -12
  130. package/templates/default/specs/ContentMatchers/TC-0006-collections-and-empty/web.test.ts +11 -11
  131. package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/common.test.ts +14 -0
  132. package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/spec.md +21 -0
  133. package/templates/default/specs/DeepLink/TC-0018-navigate-via-deep-link/mobile.test.ts +28 -0
  134. package/templates/default/specs/DeepLink/TC-0018-navigate-via-deep-link/spec.md +29 -0
  135. package/templates/default/specs/DeepLink/suite.md +9 -0
  136. package/templates/default/specs/ElementMatchers/TC-0003-visibility-and-dom/mobile.test.ts +11 -11
  137. package/templates/default/specs/ElementMatchers/TC-0003-visibility-and-dom/spec.md +17 -12
  138. package/templates/default/specs/ElementMatchers/TC-0003-visibility-and-dom/web.test.ts +11 -11
  139. package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/mobile.test.ts +29 -29
  140. package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/spec.md +19 -12
  141. package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/web.test.ts +29 -29
  142. package/templates/default/specs/ElementMatchers/suite.md +10 -9
  143. package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
  144. package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
  145. package/templates/default/specs/EnvironmentConfig/suite.md +9 -0
  146. package/templates/default/specs/PermissionRationale/TC-0010-camera-permission-allow/mobile.test.ts +26 -0
  147. package/templates/default/specs/PermissionRationale/TC-0010-camera-permission-allow/spec.md +22 -0
  148. package/templates/default/specs/PermissionRationale/TC-0011-camera-permission-deny/mobile.test.ts +13 -0
  149. package/templates/default/specs/PermissionRationale/TC-0011-camera-permission-deny/spec.md +20 -0
  150. package/templates/default/specs/PermissionRationale/suite.md +9 -0
  151. package/templates/default/specs/Playground/TC-0001-form-controls/mobile.test.ts +6 -6
  152. package/templates/default/specs/Playground/TC-0001-form-controls/spec.md +18 -12
  153. package/templates/default/specs/Playground/TC-0001-form-controls/web.test.ts +6 -6
  154. package/templates/default/specs/Playground/TC-0002-toggle-elements/mobile.test.ts +6 -6
  155. package/templates/default/specs/Playground/TC-0002-toggle-elements/spec.md +18 -12
  156. package/templates/default/specs/Playground/TC-0002-toggle-elements/web.test.ts +6 -6
  157. package/templates/default/spectra.config.ts +52 -68
  158. package/templates/default/support/actions/fillCredentials/mobile.action.ts +4 -4
  159. package/templates/default/support/actions/fillCredentials/web.action.ts +4 -4
  160. package/templates/default/support/actions/seedSession/mobile.action.ts +9 -0
  161. package/templates/default/support/actions/seedSession/web.action.ts +13 -0
  162. package/templates/default/support/steps/togglePlaygroundStates/mobile.step.ts +5 -5
  163. package/templates/default/support/steps/togglePlaygroundStates/web.step.ts +5 -5
  164. package/templates/default/support/steps/verifyPlaygroundState/mobile.step.ts +6 -6
  165. package/templates/default/support/steps/verifyPlaygroundState/web.step.ts +6 -6
  166. package/templates/nx/.nx/workspace-data/59842386-c7c4-44ca-b2c2-20e1700bd13d.db +0 -0
  167. package/templates/nx/.nx/workspace-data/59842386-c7c4-44ca-b2c2-20e1700bd13d.db-shm +0 -0
  168. package/templates/nx/.nx/workspace-data/59842386-c7c4-44ca-b2c2-20e1700bd13d.db-wal +0 -0
  169. package/templates/nx/.nx/workspace-data/d/daemon.log +1577 -168
  170. package/templates/nx/.nx/workspace-data/d/server-process.json +3 -0
  171. package/templates/nx/.nx/workspace-data/file-map.json +464 -0
  172. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -0
  173. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  174. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +13485 -0
  175. package/templates/nx/.nx/workspace-data/project-graph.json +14389 -0
  176. package/templates/nx/CLAUDE.md +10 -10
  177. package/templates/nx/package.json +3 -3
  178. package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0008-fetch-mocking/mobile.test.ts +27 -3
  179. package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0008-fetch-mocking/web.test.ts +27 -27
  180. package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0009-post-mocking/mobile.test.ts +24 -3
  181. package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0009-post-mocking/web.test.ts +24 -24
  182. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -3
  183. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +19 -19
  184. package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0005-text-and-values/mobile.test.ts +3 -3
  185. package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0005-text-and-values/web.test.ts +17 -17
  186. package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0006-collections-and-empty/mobile.test.ts +3 -3
  187. package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0006-collections-and-empty/web.test.ts +11 -11
  188. package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0003-visibility-and-dom/mobile.test.ts +3 -3
  189. package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0003-visibility-and-dom/web.test.ts +11 -11
  190. package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0004-states-and-attributes/mobile.test.ts +3 -3
  191. package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0004-states-and-attributes/web.test.ts +29 -29
  192. package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
  193. package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
  194. package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/suite.md +9 -0
  195. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0001-form-controls/mobile.test.ts +3 -3
  196. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0001-form-controls/web.test.ts +6 -6
  197. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0002-toggle-elements/mobile.test.ts +3 -3
  198. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0002-toggle-elements/web.test.ts +6 -6
  199. package/templates/nx/pnpm-lock.yaml +6663 -6663
  200. package/templates/nx/pnpm-workspace.yaml +9 -9
  201. package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +33 -33
  202. package/templates/nx/shared/testing/page-objects/MatchersPage/web.ts +91 -37
  203. package/templates/nx/shared/testing/page-objects/NavigationPage/mobile.ts +5 -5
  204. package/templates/nx/shared/testing/page-objects/NavigationPage/web.ts +8 -8
  205. package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +26 -26
  206. package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +31 -31
  207. package/templates/nx/shared/testing/project.json +19 -19
  208. package/templates/nx/shared/testing/support/actions/fillCredentials/mobile.action.ts +4 -4
  209. package/templates/nx/shared/testing/support/actions/fillCredentials/web.action.ts +4 -4
  210. package/templates/nx/shared/testing/support/steps/togglePlaygroundStates/mobile.step.ts +3 -3
  211. package/templates/nx/shared/testing/support/steps/togglePlaygroundStates/web.step.ts +5 -5
  212. package/templates/nx/shared/testing/support/steps/verifyPlaygroundState/mobile.step.ts +4 -4
  213. package/templates/nx/shared/testing/support/steps/verifyPlaygroundState/web.step.ts +6 -6
  214. package/templates/nx/spectra.config.ts +52 -68
  215. package/templates/react-component/.cursorrules +17 -0
  216. package/templates/react-component/.vscode/extensions.json +5 -0
  217. package/templates/react-component/CLAUDE.md +14 -0
  218. package/templates/react-component/README.md +69 -0
  219. package/templates/react-component/fixtures/component-mock.json +12 -0
  220. package/templates/react-component/global-hooks/after/web.hook.ts +3 -0
  221. package/templates/react-component/global-hooks/before/web.hook.ts +3 -0
  222. package/templates/react-component/package.json +27 -0
  223. package/templates/react-component/page-objects/BadgeComponent/web.ts +23 -0
  224. package/templates/react-component/page-objects/ButtonComponent/web.ts +23 -0
  225. package/templates/react-component/page-objects/InputComponent/web.ts +13 -0
  226. package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/spec.md +18 -0
  227. package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/web.test.tsx +11 -0
  228. package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/spec.md +18 -0
  229. package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/web.test.tsx +10 -0
  230. package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/spec.md +18 -0
  231. package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/web.test.tsx +16 -0
  232. package/templates/react-component/specs/BadgeComponent/suite.md +12 -0
  233. package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/spec.md +19 -0
  234. package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/web.test.tsx +28 -0
  235. package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/spec.md +19 -0
  236. package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/web.test.tsx +22 -0
  237. package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/spec.md +18 -0
  238. package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/web.test.tsx +8 -0
  239. package/templates/react-component/specs/ButtonComponent/suite.md +12 -0
  240. package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/spec.md +20 -0
  241. package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/web.test.tsx +28 -0
  242. package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/spec.md +18 -0
  243. package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/web.test.tsx +9 -0
  244. package/templates/react-component/specs/InputComponent/suite.md +12 -0
  245. package/templates/react-component/spectra.config.ts +27 -0
  246. package/templates/react-component/src/components/Badge/Badge.tsx +60 -0
  247. package/templates/react-component/src/components/Button/Button.tsx +57 -0
  248. package/templates/react-component/src/components/Input/Input.tsx +41 -0
  249. package/templates/react-component/src/test-utils.tsx +23 -0
  250. package/templates/react-component/support/actions/fillInputField/web.action.ts +9 -0
  251. package/templates/react-component/support/steps/verifyButtonState/web.step.ts +12 -0
  252. package/templates/react-component/tsconfig.json +8 -0
  253. package/bin/testspectra-runner +0 -0
  254. package/demo-app/assets/index-DPfv5_EF.js +0 -116
  255. package/demo-app/assets/index-gIwu2EMV.css +0 -1
  256. package/dist/commands/__tests__/cloud-run.test.js +0 -93
  257. package/dist/commands/cloud-run.d.ts +0 -13
  258. package/dist/commands/cloud-run.js +0 -120
  259. package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/mobile.test.ts +0 -17
  260. package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/web.test.ts +0 -17
  261. /package/dist/{commands/__tests__/cloud-run.test.d.ts → linter/__tests__/anonymous-export.test.d.ts} +0 -0
@@ -0,0 +1,270 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import { SpecLinter } from '../spec-linter.js';
6
+ import { isInsideSharedLibrary, findSharedLibraryRoots } from '../discovery.js';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
10
+ const tempDir = path.join(__dirname, '__temp_shared_boundary__');
11
+ beforeEach(() => {
12
+ fs.mkdirSync(tempDir, { recursive: true });
13
+ });
14
+ afterEach(() => {
15
+ if (fs.existsSync(tempDir)) {
16
+ fs.rmSync(tempDir, { recursive: true, force: true });
17
+ }
18
+ });
19
+ describe('findSharedLibraryRoots()', () => {
20
+ it('discovers parent folders of project.json files containing both scope:shared and type:shared', () => {
21
+ const sharedDir1 = path.join(tempDir, 'shared', 'testing');
22
+ const sharedDir2 = path.join(tempDir, 'libs', 'common-helpers');
23
+ const nonSharedDir = path.join(tempDir, 'packages', 'playground', 'e2e');
24
+ fs.mkdirSync(sharedDir1, { recursive: true });
25
+ fs.mkdirSync(sharedDir2, { recursive: true });
26
+ fs.mkdirSync(nonSharedDir, { recursive: true });
27
+ fs.writeFileSync(path.join(sharedDir1, 'project.json'), JSON.stringify({ name: 'shared-testing', tags: ['scope:shared', 'type:shared'] }));
28
+ fs.writeFileSync(path.join(sharedDir2, 'project.json'), JSON.stringify({ name: 'common-helpers', tags: ['scope:shared', 'type:shared'] }));
29
+ fs.writeFileSync(path.join(nonSharedDir, 'project.json'), JSON.stringify({ name: 'playground-e2e', tags: ['scope:playground', 'type:e2e'] }));
30
+ const roots = findSharedLibraryRoots(tempDir);
31
+ expect(roots).toHaveLength(2);
32
+ expect(roots).toContain(sharedDir1);
33
+ expect(roots).toContain(sharedDir2);
34
+ expect(roots).not.toContain(nonSharedDir);
35
+ });
36
+ });
37
+ describe('isInsideSharedLibrary()', () => {
38
+ it('returns true for files inside a project tagged with scope:shared or type:shared in project.json', () => {
39
+ const sharedDir = path.join(tempDir, 'shared', 'testing');
40
+ fs.mkdirSync(sharedDir, { recursive: true });
41
+ fs.writeFileSync(path.join(sharedDir, 'project.json'), JSON.stringify({
42
+ name: 'shared-testing',
43
+ tags: ['scope:shared', 'type:shared'],
44
+ }));
45
+ const stepFile = path.join(sharedDir, 'support', 'steps', 'web.step.ts');
46
+ const specFile = path.join(sharedDir, 'specs', 'TC-001', 'spec.md');
47
+ const actionFile = path.join(sharedDir, 'support', 'actions', 'web.action.ts');
48
+ expect(isInsideSharedLibrary(stepFile)).toBe(true);
49
+ expect(isInsideSharedLibrary(specFile)).toBe(true);
50
+ expect(isInsideSharedLibrary(actionFile)).toBe(true);
51
+ expect(isInsideSharedLibrary(sharedDir)).toBe(true);
52
+ });
53
+ it('returns true for shared library regardless of folder name if tagged in project.json', () => {
54
+ const customLibDir = path.join(tempDir, 'custom-helpers', 'common');
55
+ fs.mkdirSync(customLibDir, { recursive: true });
56
+ fs.writeFileSync(path.join(customLibDir, 'project.json'), JSON.stringify({
57
+ name: 'custom-common-lib',
58
+ tags: ['scope:shared', 'type:shared'],
59
+ }));
60
+ const actionFile = path.join(customLibDir, 'support', 'actions', 'click.action.ts');
61
+ expect(isInsideSharedLibrary(actionFile)).toBe(true);
62
+ });
63
+ it('returns false when folder is named "shared" but project.json is not tagged as shared', () => {
64
+ const sharedAppDir = path.join(tempDir, 'shared-app');
65
+ fs.mkdirSync(sharedAppDir, { recursive: true });
66
+ fs.writeFileSync(path.join(sharedAppDir, 'project.json'), JSON.stringify({
67
+ name: 'shared-app',
68
+ tags: ['scope:shared-app', 'type:e2e'],
69
+ }));
70
+ const specFile = path.join(sharedAppDir, 'specs', 'TC-001', 'spec.md');
71
+ expect(isInsideSharedLibrary(specFile)).toBe(false);
72
+ });
73
+ it('returns false for feature package project.json without shared tags', () => {
74
+ const featureDir = path.join(tempDir, 'packages', 'playground', 'e2e');
75
+ fs.mkdirSync(featureDir, { recursive: true });
76
+ fs.writeFileSync(path.join(featureDir, 'project.json'), JSON.stringify({
77
+ name: 'playground-e2e',
78
+ tags: ['scope:playground', 'type:e2e'],
79
+ }));
80
+ const specFile = path.join(featureDir, 'specs', 'TC-001', 'spec.md');
81
+ expect(isInsideSharedLibrary(specFile)).toBe(false);
82
+ });
83
+ it('returns false when no project.json exists in directory tree', () => {
84
+ const nonProjectSpec = path.join(tempDir, 'standalone', 'specs', 'TC-001', 'spec.md');
85
+ expect(isInsideSharedLibrary(nonProjectSpec)).toBe(false);
86
+ });
87
+ });
88
+ describe('testspectra/no-specs-in-shared-lib validation', () => {
89
+ it('emits testspectra/no-specs-in-shared-lib when spec.md is found inside shared/testing/', () => {
90
+ const sharedLibDir = path.join(tempDir, 'shared', 'testing');
91
+ fs.mkdirSync(sharedLibDir, { recursive: true });
92
+ fs.writeFileSync(path.join(sharedLibDir, 'project.json'), JSON.stringify({
93
+ name: 'shared-testing',
94
+ tags: ['scope:shared', 'type:shared'],
95
+ }));
96
+ const sharedSpecDir = path.join(sharedLibDir, 'specs', 'TC-error');
97
+ fs.mkdirSync(sharedSpecDir, { recursive: true });
98
+ fs.writeFileSync(path.join(sharedSpecDir, 'spec.md'), `---
99
+ id: TC-ERROR-001
100
+ title: Spec inside shared lib
101
+ priority: High
102
+ caseType: Positive
103
+ status: draft
104
+ ---
105
+
106
+ # Spec inside shared lib
107
+
108
+ ## Test Steps
109
+ 1. Step one
110
+ `);
111
+ const result = SpecLinter.validateWorkspace(tempDir);
112
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
113
+ expect(errors).toHaveLength(1);
114
+ expect(errors[0].severity).toBe('error');
115
+ expect(errors[0].message).toContain('shared testing library');
116
+ });
117
+ it('emits testspectra/no-specs-in-shared-lib when suite.md is found inside shared/testing/', () => {
118
+ const sharedLibDir = path.join(tempDir, 'shared', 'testing');
119
+ fs.mkdirSync(sharedLibDir, { recursive: true });
120
+ fs.writeFileSync(path.join(sharedLibDir, 'project.json'), JSON.stringify({
121
+ name: 'shared-testing',
122
+ tags: ['scope:shared', 'type:shared'],
123
+ }));
124
+ const sharedSuiteDir = path.join(sharedLibDir, 'support');
125
+ fs.mkdirSync(sharedSuiteDir, { recursive: true });
126
+ fs.writeFileSync(path.join(sharedSuiteDir, 'suite.md'), `---
127
+ id: suite-error
128
+ name: Suite inside shared lib
129
+ status: active
130
+ ---
131
+
132
+ # Suite: Inside Shared Lib
133
+ `);
134
+ const result = SpecLinter.validateWorkspace(tempDir);
135
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
136
+ expect(errors).toHaveLength(1);
137
+ expect(errors[0].message).toContain('shared testing library');
138
+ });
139
+ it('emits testspectra/no-specs-in-shared-lib when spec.md is inside a shared library with non-"shared" folder name', () => {
140
+ const customLibDir = path.join(tempDir, 'libs', 'my-testing-utils');
141
+ fs.mkdirSync(customLibDir, { recursive: true });
142
+ fs.writeFileSync(path.join(customLibDir, 'project.json'), JSON.stringify({
143
+ name: 'my-testing-utils',
144
+ tags: ['scope:shared', 'type:shared'],
145
+ }));
146
+ const specDir = path.join(customLibDir, 'specs', 'TC-custom');
147
+ fs.mkdirSync(specDir, { recursive: true });
148
+ fs.writeFileSync(path.join(specDir, 'spec.md'), `---
149
+ id: TC-CUSTOM-001
150
+ title: Spec inside custom shared lib
151
+ priority: High
152
+ caseType: Positive
153
+ status: draft
154
+ ---
155
+
156
+ # Spec inside custom shared lib
157
+
158
+ ## Test Steps
159
+ 1. Step one
160
+ `);
161
+ const result = SpecLinter.validateWorkspace(tempDir);
162
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
163
+ expect(errors).toHaveLength(1);
164
+ expect(errors[0].severity).toBe('error');
165
+ expect(errors[0].message).toContain('shared testing library');
166
+ });
167
+ it('does NOT emit testspectra/no-specs-in-shared-lib for specs in feature package directories', () => {
168
+ const featureDir = path.join(tempDir, 'packages', 'playground', 'e2e');
169
+ fs.mkdirSync(featureDir, { recursive: true });
170
+ fs.writeFileSync(path.join(featureDir, 'project.json'), JSON.stringify({
171
+ name: 'playground-e2e',
172
+ tags: ['scope:playground', 'type:e2e'],
173
+ }));
174
+ const featureSpecDir = path.join(featureDir, 'specs', 'TC-001');
175
+ fs.mkdirSync(featureSpecDir, { recursive: true });
176
+ fs.writeFileSync(path.join(featureSpecDir, 'spec.md'), `---
177
+ id: TC-001
178
+ title: Feature spec
179
+ priority: High
180
+ caseType: Positive
181
+ status: draft
182
+ ---
183
+
184
+ # Feature spec
185
+
186
+ ## Test Steps
187
+ 1. Step one
188
+ `);
189
+ const result = SpecLinter.validateWorkspace(tempDir);
190
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
191
+ expect(errors).toHaveLength(0);
192
+ });
193
+ it('does NOT emit testspectra/no-specs-in-shared-lib when folder is named "shared" but project.json has e2e tags', () => {
194
+ const sharedE2eDir = path.join(tempDir, 'shared-feature', 'e2e');
195
+ fs.mkdirSync(sharedE2eDir, { recursive: true });
196
+ fs.writeFileSync(path.join(sharedE2eDir, 'project.json'), JSON.stringify({
197
+ name: 'shared-feature-e2e',
198
+ tags: ['scope:shared-feature', 'type:e2e'],
199
+ }));
200
+ const specDir = path.join(sharedE2eDir, 'specs', 'TC-001');
201
+ fs.mkdirSync(specDir, { recursive: true });
202
+ fs.writeFileSync(path.join(specDir, 'spec.md'), `---
203
+ id: TC-001
204
+ title: Shared named feature spec
205
+ priority: High
206
+ caseType: Positive
207
+ status: draft
208
+ ---
209
+
210
+ # Shared named feature spec
211
+
212
+ ## Test Steps
213
+ 1. Step one
214
+ `);
215
+ const result = SpecLinter.validateWorkspace(tempDir);
216
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
217
+ expect(errors).toHaveLength(0);
218
+ });
219
+ it('does NOT emit testspectra/no-specs-in-shared-lib for specs in root specs/ directory', () => {
220
+ const rootSpecDir = path.join(tempDir, 'specs', 'TC-001');
221
+ fs.mkdirSync(rootSpecDir, { recursive: true });
222
+ fs.writeFileSync(path.join(rootSpecDir, 'spec.md'), `---
223
+ id: TC-001
224
+ title: Root spec
225
+ priority: High
226
+ caseType: Positive
227
+ status: draft
228
+ ---
229
+
230
+ # Root spec
231
+
232
+ ## Test Steps
233
+ 1. Step one
234
+ `);
235
+ const result = SpecLinter.validateWorkspace(tempDir);
236
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
237
+ expect(errors).toHaveLength(0);
238
+ });
239
+ it('emits multiple testspectra/no-specs-in-shared-lib when multiple specs found in shared library', () => {
240
+ const sharedLibDir = path.join(tempDir, 'shared', 'testing');
241
+ fs.mkdirSync(sharedLibDir, { recursive: true });
242
+ fs.writeFileSync(path.join(sharedLibDir, 'project.json'), JSON.stringify({
243
+ name: 'shared-testing',
244
+ tags: ['scope:shared', 'type:shared'],
245
+ }));
246
+ const sharedDir1 = path.join(sharedLibDir, 'specs', 'TC-001');
247
+ const sharedDir2 = path.join(sharedLibDir, 'specs', 'TC-002');
248
+ fs.mkdirSync(sharedDir1, { recursive: true });
249
+ fs.mkdirSync(sharedDir2, { recursive: true });
250
+ const specContent = `---
251
+ id: TC-TEST
252
+ title: Test
253
+ priority: High
254
+ caseType: Positive
255
+ status: draft
256
+ ---
257
+
258
+ # Test
259
+
260
+ ## Test Steps
261
+ 1. Step
262
+ `;
263
+ fs.writeFileSync(path.join(sharedDir1, 'spec.md'), specContent);
264
+ fs.writeFileSync(path.join(sharedDir2, 'spec.md'), specContent);
265
+ const result = SpecLinter.validateWorkspace(tempDir);
266
+ const errors = result.diagnostics.filter((d) => d.code === 'testspectra/no-specs-in-shared-lib');
267
+ expect(errors).toHaveLength(2);
268
+ });
269
+ });
270
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,369 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import { SpecLinter } from '../spec-linter.js';
6
+ import { parseMarkdownFrontmatter } from '../parser.js';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ describe('Spec Schema Linter (Stage 1 & Stage 2)', () => {
10
+ const tempDir = path.join(__dirname, '__temp_specs__');
11
+ beforeEach(() => {
12
+ fs.mkdirSync(tempDir, { recursive: true });
13
+ });
14
+ afterEach(() => {
15
+ if (fs.existsSync(tempDir)) {
16
+ fs.rmSync(tempDir, { recursive: true, force: true });
17
+ }
18
+ });
19
+ describe('Spec File Validation (spec.md)', () => {
20
+ it('passes validation for a well-formed spec.md with frontmatter, H1 title, and Test Steps', () => {
21
+ const specPath = path.join(tempDir, 'spec.md');
22
+ fs.writeFileSync(specPath, `---
23
+ id: TC-0001-valid
24
+ title: Verify user authentication
25
+ priority: High
26
+ caseType: Positive
27
+ status: ready-for-automation
28
+ coverage:
29
+ web: automated
30
+ mobile: automated
31
+ tags:
32
+ - auth
33
+ ---
34
+
35
+ # Verify user authentication
36
+
37
+ ## Test Steps
38
+ 1. Open login page and submit credentials
39
+ `);
40
+ const result = SpecLinter.validateSpecFile(specPath);
41
+ expect(result.diagnostics).toHaveLength(0);
42
+ expect(result.id).toBe('TC-0001-valid');
43
+ });
44
+ it('reports testspectra/missing-frontmatter when required frontmatter attributes are missing', () => {
45
+ const specPath = path.join(tempDir, 'spec.md');
46
+ fs.writeFileSync(specPath, `---
47
+ id: TC-0002-missing-attrs
48
+ title: Missing priority and caseType
49
+ status: draft
50
+ ---
51
+
52
+ # Missing priority and caseType
53
+
54
+ ## Test Steps
55
+ 1. Step one
56
+ `);
57
+ const result = SpecLinter.validateSpecFile(specPath);
58
+ const codes = result.diagnostics.map((d) => d.code);
59
+ expect(codes).toContain('testspectra/missing-frontmatter');
60
+ const missingPriority = result.diagnostics.find((d) => d.code === 'testspectra/missing-frontmatter' &&
61
+ d.message.includes("Missing required frontmatter attribute 'priority'"));
62
+ expect(missingPriority).toBeDefined();
63
+ expect(missingPriority?.severity).toBe('error');
64
+ const missingCaseType = result.diagnostics.find((d) => d.code === 'testspectra/missing-frontmatter' &&
65
+ d.message.includes("Missing required frontmatter attribute 'caseType'"));
66
+ expect(missingCaseType).toBeDefined();
67
+ expect(missingCaseType?.severity).toBe('error');
68
+ });
69
+ it('differentiates missing status (testspectra/missing-frontmatter) from invalid status value (testspectra/invalid-frontmatter-enum)', () => {
70
+ // 1. Missing status -> testspectra/missing-frontmatter
71
+ const missingSpecPath = path.join(tempDir, 'missing-status.spec.md');
72
+ fs.writeFileSync(missingSpecPath, `---
73
+ id: TC-0002-missing-status
74
+ title: Missing Status Spec
75
+ priority: High
76
+ caseType: Positive
77
+ ---
78
+
79
+ # Missing Status Spec
80
+
81
+ ## Test Steps
82
+ 1. Step one
83
+ `);
84
+ const missingResult = SpecLinter.validateSpecFile(missingSpecPath);
85
+ const missingStatusDiag = missingResult.diagnostics.find((d) => d.code === 'testspectra/missing-frontmatter' &&
86
+ d.message === "Missing required frontmatter attribute 'status'");
87
+ expect(missingStatusDiag).toBeDefined();
88
+ expect(missingStatusDiag?.severity).toBe('error');
89
+ expect(missingResult.diagnostics.some((d) => d.code === 'testspectra/invalid-frontmatter-enum')).toBe(false);
90
+ // 2. Invalid status -> testspectra/invalid-frontmatter-enum
91
+ const invalidSpecPath = path.join(tempDir, 'invalid-status.spec.md');
92
+ fs.writeFileSync(invalidSpecPath, `---
93
+ id: TC-0002-invalid-status
94
+ title: Invalid Status Spec
95
+ priority: High
96
+ caseType: Positive
97
+ status: not-a-valid-status
98
+ ---
99
+
100
+ # Invalid Status Spec
101
+
102
+ ## Test Steps
103
+ 1. Step one
104
+ `);
105
+ const invalidResult = SpecLinter.validateSpecFile(invalidSpecPath);
106
+ const invalidStatusDiag = invalidResult.diagnostics.find((d) => d.code === 'testspectra/invalid-frontmatter-enum' &&
107
+ d.message === 'Invalid status value. Expected one of: draft, ready-for-automation, automated, deprecated');
108
+ expect(invalidStatusDiag).toBeDefined();
109
+ expect(invalidStatusDiag?.severity).toBe('error');
110
+ expect(invalidResult.diagnostics.some((d) => d.message.includes("Missing required frontmatter attribute 'status'"))).toBe(false);
111
+ });
112
+ it('reports testspectra/invalid-frontmatter-enum when enum attributes have invalid values', () => {
113
+ const specPath = path.join(tempDir, 'spec.md');
114
+ fs.writeFileSync(specPath, `---
115
+ id: TC-0003-invalid-enum
116
+ title: Invalid priority value
117
+ priority: SuperHigh
118
+ caseType: MaybePositive
119
+ status: unknown-status
120
+ ---
121
+
122
+ # Invalid priority value
123
+
124
+ ## Test Steps
125
+ 1. Step one
126
+ `);
127
+ const result = SpecLinter.validateSpecFile(specPath);
128
+ const enumErrors = result.diagnostics.filter((d) => d.code === 'testspectra/invalid-frontmatter-enum');
129
+ expect(enumErrors.length).toBeGreaterThanOrEqual(1);
130
+ const priorityErr = enumErrors.find((d) => d.message.toLowerCase().includes('priority'));
131
+ expect(priorityErr).toBeDefined();
132
+ expect(priorityErr?.line).toBe(4); // Priority is on line 4
133
+ });
134
+ it('reports testspectra/missing-frontmatter when required H1 title section is missing in spec.md body', () => {
135
+ const specPath = path.join(tempDir, 'spec.md');
136
+ fs.writeFileSync(specPath, `---
137
+ id: TC-0004-no-h1
138
+ title: No Heading Section
139
+ priority: High
140
+ caseType: Positive
141
+ status: draft
142
+ ---
143
+
144
+ ## Test Steps
145
+ 1. Step without H1
146
+ `);
147
+ const result = SpecLinter.validateSpecFile(specPath);
148
+ const missingHeading = result.diagnostics.find((d) => d.code === 'testspectra/missing-frontmatter' && d.message.includes('expected top-level test title'));
149
+ expect(missingHeading).toBeDefined();
150
+ expect(missingHeading?.severity).toBe('error');
151
+ });
152
+ it('reports testspectra/missing-frontmatter when required ## Test Steps section is missing in spec.md body', () => {
153
+ const specPath = path.join(tempDir, 'spec.md');
154
+ fs.writeFileSync(specPath, `---
155
+ id: TC-0005-no-steps
156
+ title: No Steps Heading
157
+ priority: High
158
+ caseType: Positive
159
+ status: draft
160
+ ---
161
+
162
+ # No Steps Heading
163
+
164
+ Some narrative without Test Steps section.
165
+ `);
166
+ const result = SpecLinter.validateSpecFile(specPath);
167
+ const missingSteps = result.diagnostics.find((d) => d.code === 'testspectra/missing-frontmatter' && d.message.includes('expected "## Test Steps"'));
168
+ expect(missingSteps).toBeDefined();
169
+ expect(missingSteps?.severity).toBe('error');
170
+ });
171
+ it('warns when multiple H1 headings are declared in spec.md body', () => {
172
+ const specPath = path.join(tempDir, 'spec.md');
173
+ fs.writeFileSync(specPath, `---
174
+ id: TC-0006-multiple-h1
175
+ title: Multiple Headings
176
+ priority: High
177
+ caseType: Positive
178
+ status: draft
179
+ ---
180
+
181
+ # First Title
182
+
183
+ # Second Title
184
+
185
+ ## Test Steps
186
+ 1. Step
187
+ `);
188
+ const result = SpecLinter.validateSpecFile(specPath);
189
+ const multipleH1 = result.diagnostics.find((d) => d.severity === 'warning' && d.message.includes('Multiple top-level H1 headings'));
190
+ expect(multipleH1).toBeDefined();
191
+ });
192
+ it('reports testspectra/parse-error on malformed YAML syntax', () => {
193
+ const specPath = path.join(tempDir, 'spec.md');
194
+ fs.writeFileSync(specPath, `---
195
+ id: [broken yaml
196
+ title: Test
197
+ ---
198
+ `);
199
+ const result = SpecLinter.validateSpecFile(specPath);
200
+ expect(result.diagnostics.some((d) => d.code === 'testspectra/parse-error')).toBe(true);
201
+ });
202
+ });
203
+ describe('Suite File Validation (suite.md)', () => {
204
+ it('passes validation for a well-formed suite.md with H1 suite heading', () => {
205
+ const suitePath = path.join(tempDir, 'suite.md');
206
+ fs.writeFileSync(suitePath, `---
207
+ id: suite-auth
208
+ name: Authentication
209
+ description: User authentication flows
210
+ executionOrder: 1.0
211
+ parallel: true
212
+ status: active
213
+ ---
214
+
215
+ # Suite: Authentication
216
+ `);
217
+ const result = SpecLinter.validateSuiteFile(suitePath);
218
+ expect(result.diagnostics).toHaveLength(0);
219
+ expect(result.id).toBe('suite-auth');
220
+ });
221
+ it('reports testspectra/missing-frontmatter when suite id or title/name is missing', () => {
222
+ const suitePath = path.join(tempDir, 'suite.md');
223
+ fs.writeFileSync(suitePath, `---
224
+ description: No id and no name
225
+ ---
226
+
227
+ # Suite: Incomplete
228
+ `);
229
+ const result = SpecLinter.validateSuiteFile(suitePath);
230
+ expect(result.diagnostics.some((d) => d.code === 'testspectra/missing-frontmatter')).toBe(true);
231
+ });
232
+ it('reports testspectra/invalid-frontmatter-enum when suite status has invalid enum value', () => {
233
+ const suitePath = path.join(tempDir, 'suite.md');
234
+ fs.writeFileSync(suitePath, `---
235
+ id: suite-invalid-status
236
+ name: Invalid Status Suite
237
+ status: unknown-status
238
+ ---
239
+
240
+ # Suite: Invalid Status Suite
241
+ `);
242
+ const result = SpecLinter.validateSuiteFile(suitePath);
243
+ const enumErr = result.diagnostics.find((d) => d.code === 'testspectra/invalid-frontmatter-enum');
244
+ expect(enumErr).toBeDefined();
245
+ expect(enumErr?.severity).toBe('error');
246
+ });
247
+ it('supports in-memory content override for suite.md without writing to disk', () => {
248
+ const suitePath = path.join(tempDir, 'non-existent-suite.md');
249
+ const inMemoryContent = `---
250
+ id: suite-in-memory
251
+ title: In Memory Suite
252
+ status: active
253
+ ---
254
+
255
+ # Suite: In Memory Suite
256
+ `;
257
+ const result = SpecLinter.validateSuiteFile(suitePath, inMemoryContent);
258
+ expect(result.diagnostics).toHaveLength(0);
259
+ expect(result.id).toBe('suite-in-memory');
260
+ });
261
+ it('reports testspectra/missing-frontmatter when suite H1 title heading is missing', () => {
262
+ const suitePath = path.join(tempDir, 'suite.md');
263
+ fs.writeFileSync(suitePath, `---
264
+ id: suite-no-heading
265
+ name: No Heading
266
+ ---
267
+
268
+ Body without H1 suite heading.
269
+ `);
270
+ const result = SpecLinter.validateSuiteFile(suitePath);
271
+ const headingErr = result.diagnostics.find((d) => d.code === 'testspectra/missing-frontmatter' && d.message.includes('expected top-level suite title'));
272
+ expect(headingErr).toBeDefined();
273
+ });
274
+ });
275
+ describe('Workspace Validation & Duplicate ID Detection (testspectra/duplicate-spec-id)', () => {
276
+ it('detects duplicate IDs across different spec files', () => {
277
+ const tc1Dir = path.join(tempDir, 'TC-1');
278
+ const tc2Dir = path.join(tempDir, 'TC-2');
279
+ fs.mkdirSync(tc1Dir, { recursive: true });
280
+ fs.mkdirSync(tc2Dir, { recursive: true });
281
+ const specContent = `---
282
+ id: TC-DUPLICATE-001
283
+ title: Duplicate test case
284
+ priority: High
285
+ caseType: Positive
286
+ status: automated
287
+ ---
288
+
289
+ # Duplicate test case
290
+
291
+ ## Test Steps
292
+ 1. Duplicate step
293
+ `;
294
+ fs.writeFileSync(path.join(tc1Dir, 'spec.md'), specContent);
295
+ fs.writeFileSync(path.join(tc2Dir, 'spec.md'), specContent);
296
+ const result = SpecLinter.validateWorkspace(tempDir);
297
+ expect(result.valid).toBe(false);
298
+ const dupErrors = result.diagnostics.filter((d) => d.code === 'testspectra/duplicate-spec-id');
299
+ expect(dupErrors).toHaveLength(1);
300
+ expect(dupErrors[0].message).toContain('TC-DUPLICATE-001');
301
+ });
302
+ it('successfully validates workspace with valid specs', () => {
303
+ const validSuiteDir = path.join(tempDir, 'ValidSuite');
304
+ const validTcDir = path.join(validSuiteDir, 'TC-001');
305
+ fs.mkdirSync(validTcDir, { recursive: true });
306
+ fs.writeFileSync(path.join(validSuiteDir, 'suite.md'), `---
307
+ id: suite-valid
308
+ name: Valid Suite
309
+ status: active
310
+ ---
311
+
312
+ # Suite: Valid Suite
313
+ `);
314
+ fs.writeFileSync(path.join(validTcDir, 'spec.md'), `---
315
+ id: TC-VALID-001
316
+ title: Valid Test Case
317
+ priority: High
318
+ caseType: Positive
319
+ status: automated
320
+ ---
321
+
322
+ # Valid Test Case
323
+
324
+ ## Test Steps
325
+ 1. Open page
326
+ `);
327
+ fs.writeFileSync(path.join(validTcDir, 'web.test.ts'), `it('Valid Test Case', async () => {});\n`);
328
+ const result = SpecLinter.validateWorkspace(tempDir);
329
+ expect(result.valid).toBe(true);
330
+ expect(result.stats.errorCount).toBe(0);
331
+ expect(result.stats.totalSpecs).toBe(1);
332
+ expect(result.stats.totalSuites).toBe(1);
333
+ });
334
+ });
335
+ describe('parseMarkdownFrontmatter Delimiter Detection Cases', () => {
336
+ it('Case 1: detects missing opening delimiter when closing delimiter exists', () => {
337
+ const content = `id: suite-api-interception\nname: ApiInterception\n---\n\n# Suite: ApiInterception\n`;
338
+ const result = parseMarkdownFrontmatter(content, 'specs/api/suite.md');
339
+ expect(result.hasFrontmatter).toBe(false);
340
+ expect(result.diagnostics).toHaveLength(1);
341
+ expect(result.diagnostics[0].code).toBe('testspectra/missing-frontmatter');
342
+ expect(result.diagnostics[0].message).toBe('Missing opening YAML frontmatter delimiter (---) at line 1');
343
+ });
344
+ it('Case 2: detects missing closing delimiter when opening delimiter exists', () => {
345
+ const content = `---\nid: suite-api-interception\nname: ApiInterception\n\n# Suite: ApiInterception\n`;
346
+ const result = parseMarkdownFrontmatter(content, 'specs/api/suite.md');
347
+ expect(result.hasFrontmatter).toBe(false);
348
+ expect(result.diagnostics).toHaveLength(1);
349
+ expect(result.diagnostics[0].code).toBe('testspectra/missing-frontmatter');
350
+ expect(result.diagnostics[0].message).toBe('Missing closing YAML frontmatter delimiter (---). Expected closing "---"');
351
+ });
352
+ it('Case 3: detects missing delimiters when frontmatter properties exist', () => {
353
+ const content = `id: suite-api-interception\nname: ApiInterception\nexecutionOrder: 1.4\nparallel: true\n\n# Suite: ApiInterception\n`;
354
+ const result = parseMarkdownFrontmatter(content, 'specs/api/suite.md');
355
+ expect(result.hasFrontmatter).toBe(false);
356
+ expect(result.diagnostics).toHaveLength(1);
357
+ expect(result.diagnostics[0].code).toBe('testspectra/missing-frontmatter');
358
+ expect(result.diagnostics[0].message).toBe('Missing YAML frontmatter delimiters (---) at line 1');
359
+ });
360
+ it('Case 4: detects missing delimiter when file is empty or only has markdown heading', () => {
361
+ const content = `# Suite: ApiInterception\n`;
362
+ const result = parseMarkdownFrontmatter(content, 'specs/api/suite.md');
363
+ expect(result.hasFrontmatter).toBe(false);
364
+ expect(result.diagnostics).toHaveLength(1);
365
+ expect(result.diagnostics[0].code).toBe('testspectra/missing-frontmatter');
366
+ expect(result.diagnostics[0].message).toBe('Missing YAML frontmatter delimiter (---) at line 1');
367
+ });
368
+ });
369
+ });
@@ -0,0 +1 @@
1
+ export {};