@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.
- package/bin/.testspectra-runner.hash +1 -1
- package/bin/spectra.js +0 -0
- package/bin/testspectra-runner.exe +0 -0
- package/demo-app/assets/index-BhlW-eXG.css +1 -0
- package/demo-app/assets/index-DOtRypCa.js +154 -0
- package/demo-app/index.html +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/__tests__/run-e2e.test.js +270 -29
- package/dist/commands/__tests__/run.test.js +92 -16
- package/dist/commands/demo.js +17 -7
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +154 -149
- package/dist/commands/lint.d.ts +5 -0
- package/dist/commands/lint.js +55 -0
- package/dist/commands/refactor.d.ts +2 -1
- package/dist/commands/refactor.js +129 -35
- package/dist/commands/run.d.ts +2 -3
- package/dist/commands/run.js +93 -28
- package/dist/commands/skills.d.ts +9 -0
- package/dist/commands/skills.js +28 -0
- package/dist/commands/sync-types.js +1 -0
- package/dist/commands/test.d.ts +17 -0
- package/dist/commands/test.js +104 -0
- package/dist/commands/watch.js +9 -3
- package/dist/config/loader.js +1 -1
- package/dist/config/schema.d.ts +5 -114
- package/dist/config/schema.js +1 -17
- package/dist/generator/__tests__/tsconfig-isolation.test.js +6 -2
- package/dist/generator/__tests__/type-generator.test.js +1 -1
- package/dist/generator/tsconfig-generator.js +44 -16
- package/dist/generator/type-generator.d.ts +24 -1
- package/dist/generator/type-generator.js +104 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +76 -40
- package/dist/linter/__tests__/anonymous-export.test.js +184 -0
- package/dist/linter/__tests__/component-test-imports.test.d.ts +1 -0
- package/dist/linter/__tests__/component-test-imports.test.js +115 -0
- package/dist/linter/__tests__/shared-lib-boundary.test.d.ts +1 -0
- package/dist/linter/__tests__/shared-lib-boundary.test.js +270 -0
- package/dist/linter/__tests__/spec-schema.test.d.ts +1 -0
- package/dist/linter/__tests__/spec-schema.test.js +369 -0
- package/dist/linter/__tests__/step-parity.test.d.ts +1 -0
- package/dist/linter/__tests__/step-parity.test.js +372 -0
- package/dist/linter/discovery.d.ts +30 -0
- package/dist/linter/discovery.js +249 -0
- package/dist/linter/extractor.d.ts +67 -0
- package/dist/linter/extractor.js +245 -0
- package/dist/linter/index.d.ts +7 -0
- package/dist/linter/index.js +7 -0
- package/dist/linter/parser.d.ts +24 -0
- package/dist/linter/parser.js +219 -0
- package/dist/linter/schemas/spec.schema.d.ts +83 -0
- package/dist/linter/schemas/spec.schema.js +150 -0
- package/dist/linter/schemas/suite.schema.d.ts +59 -0
- package/dist/linter/schemas/suite.schema.js +83 -0
- package/dist/linter/spec-linter.d.ts +23 -0
- package/dist/linter/spec-linter.js +630 -0
- package/dist/linter/types.d.ts +28 -0
- package/dist/linter/types.js +1 -0
- package/dist/refactor/__tests__/refactor-engine.test.js +698 -0
- package/dist/refactor/refactor-engine.d.ts +25 -0
- package/dist/refactor/refactor-engine.js +374 -42
- package/dist/reporter/semantic-formatter.js +22 -2
- package/dist/reporter/types.d.ts +1 -1
- package/dist/runner/bridge.d.ts +1 -1
- package/dist/runner/bridge.js +34 -21
- package/dist/runner/reporter.d.ts +2 -0
- package/dist/runner/reporter.js +11 -3
- package/dist/utils/__tests__/api-server.test.d.ts +1 -0
- package/dist/utils/__tests__/api-server.test.js +62 -0
- package/dist/utils/__tests__/demo-server.test.d.ts +1 -0
- package/dist/utils/__tests__/demo-server.test.js +35 -0
- package/dist/utils/api-server.d.ts +19 -0
- package/dist/utils/api-server.js +179 -0
- package/dist/utils/demo-server.d.ts +5 -0
- package/dist/utils/demo-server.js +8 -1
- package/package.json +29 -25
- package/templates/default/CLAUDE.md +16 -11
- package/templates/default/global-hooks/after/mobile.hook.ts +3 -3
- package/templates/default/global-hooks/after/web.hook.ts +3 -3
- package/templates/default/global-hooks/before/mobile.hook.ts +3 -3
- package/templates/default/global-hooks/before/web.hook.ts +3 -3
- package/templates/default/package.json +3 -3
- package/templates/default/page-objects/AuthPage/mobile.ts +5 -0
- package/templates/default/page-objects/AuthPage/web.ts +10 -0
- package/templates/default/page-objects/MatchersPage/mobile.ts +70 -46
- package/templates/default/page-objects/MatchersPage/web.ts +43 -37
- package/templates/default/page-objects/NavigationPage/mobile.ts +14 -14
- package/templates/default/page-objects/NavigationPage/web.ts +8 -8
- package/templates/default/page-objects/PlaygroundPage/mobile.ts +42 -36
- package/templates/default/page-objects/PlaygroundPage/web.ts +31 -31
- package/templates/default/specs/ApiInterception/TC-0008-fetch-mocking/mobile.test.ts +53 -11
- package/templates/default/specs/ApiInterception/TC-0008-fetch-mocking/spec.md +19 -12
- package/templates/default/specs/ApiInterception/TC-0008-fetch-mocking/web.test.ts +31 -27
- package/templates/default/specs/ApiInterception/TC-0009-post-mocking/android.test.ts +3 -0
- package/templates/default/specs/ApiInterception/TC-0009-post-mocking/common.test.ts +3 -0
- package/templates/default/specs/ApiInterception/TC-0009-post-mocking/mobile.test.ts +27 -16
- package/templates/default/specs/ApiInterception/TC-0009-post-mocking/spec.md +19 -12
- package/templates/default/specs/ApiInterception/TC-0009-post-mocking/web.test.ts +26 -24
- package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/common.test.ts +30 -0
- package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/spec.md +22 -0
- package/templates/default/specs/ApiInterception/TC-0013-real-network-call-recorded/common.test.ts +27 -0
- package/templates/default/specs/ApiInterception/TC-0013-real-network-call-recorded/spec.md +20 -0
- package/templates/default/specs/ApiInterception/TC-0015-mock-server-sent-events/spec.md +24 -0
- package/templates/default/specs/ApiInterception/TC-0015-mock-server-sent-events/web.test.ts +26 -0
- package/templates/default/specs/ApiInterception/TC-0016-bypass-websocket-with-app-level-mock/spec.md +37 -0
- package/templates/default/specs/ApiInterception/TC-0016-bypass-websocket-with-app-level-mock/web.test.ts +21 -0
- package/templates/default/specs/ApiInterception/TC-0017-bypass-captcha-with-test-mode-flag/spec.md +29 -0
- package/templates/default/specs/ApiInterception/TC-0017-bypass-captcha-with-test-mode-flag/web.test.ts +16 -0
- package/templates/default/specs/ApiInterception/suite.md +10 -9
- package/templates/default/specs/ApiSeeding/TC-0014-seed-and-cleanup-via-hooks/common.test.ts +30 -0
- package/templates/default/specs/ApiSeeding/TC-0014-seed-and-cleanup-via-hooks/spec.md +23 -0
- package/templates/default/specs/ApiSeeding/hooks/after/common.hook.ts +16 -0
- package/templates/default/specs/ApiSeeding/hooks/before/common.hook.ts +28 -0
- package/templates/default/specs/ApiSeeding/suite.md +9 -0
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +7 -0
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/spec.md +19 -0
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/web.test.ts +9 -0
- package/templates/default/specs/Authentication/suite.md +9 -0
- package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +9 -9
- package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/spec.md +17 -12
- package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +19 -19
- package/templates/default/specs/BrowserContext/TC-0022-history-navigation/mobile.test.ts +17 -0
- package/templates/default/specs/BrowserContext/TC-0022-history-navigation/spec.md +28 -0
- package/templates/default/specs/BrowserContext/TC-0022-history-navigation/web.test.ts +26 -0
- package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/common.test.ts +26 -0
- package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/spec.md +19 -12
- package/templates/default/specs/ContentMatchers/TC-0006-collections-and-empty/mobile.test.ts +11 -11
- package/templates/default/specs/ContentMatchers/TC-0006-collections-and-empty/spec.md +17 -12
- package/templates/default/specs/ContentMatchers/TC-0006-collections-and-empty/web.test.ts +11 -11
- package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/common.test.ts +14 -0
- package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/spec.md +21 -0
- package/templates/default/specs/DeepLink/TC-0018-navigate-via-deep-link/mobile.test.ts +28 -0
- package/templates/default/specs/DeepLink/TC-0018-navigate-via-deep-link/spec.md +29 -0
- package/templates/default/specs/DeepLink/suite.md +9 -0
- package/templates/default/specs/ElementMatchers/TC-0003-visibility-and-dom/mobile.test.ts +11 -11
- package/templates/default/specs/ElementMatchers/TC-0003-visibility-and-dom/spec.md +17 -12
- package/templates/default/specs/ElementMatchers/TC-0003-visibility-and-dom/web.test.ts +11 -11
- package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/mobile.test.ts +29 -29
- package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/spec.md +19 -12
- package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/web.test.ts +29 -29
- package/templates/default/specs/ElementMatchers/suite.md +10 -9
- package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
- package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
- package/templates/default/specs/EnvironmentConfig/suite.md +9 -0
- package/templates/default/specs/PermissionRationale/TC-0010-camera-permission-allow/mobile.test.ts +26 -0
- package/templates/default/specs/PermissionRationale/TC-0010-camera-permission-allow/spec.md +22 -0
- package/templates/default/specs/PermissionRationale/TC-0011-camera-permission-deny/mobile.test.ts +13 -0
- package/templates/default/specs/PermissionRationale/TC-0011-camera-permission-deny/spec.md +20 -0
- package/templates/default/specs/PermissionRationale/suite.md +9 -0
- package/templates/default/specs/Playground/TC-0001-form-controls/mobile.test.ts +6 -6
- package/templates/default/specs/Playground/TC-0001-form-controls/spec.md +18 -12
- package/templates/default/specs/Playground/TC-0001-form-controls/web.test.ts +6 -6
- package/templates/default/specs/Playground/TC-0002-toggle-elements/mobile.test.ts +6 -6
- package/templates/default/specs/Playground/TC-0002-toggle-elements/spec.md +18 -12
- package/templates/default/specs/Playground/TC-0002-toggle-elements/web.test.ts +6 -6
- package/templates/default/spectra.config.ts +52 -68
- package/templates/default/support/actions/fillCredentials/mobile.action.ts +4 -4
- package/templates/default/support/actions/fillCredentials/web.action.ts +4 -4
- package/templates/default/support/actions/seedSession/mobile.action.ts +9 -0
- package/templates/default/support/actions/seedSession/web.action.ts +13 -0
- package/templates/default/support/steps/togglePlaygroundStates/mobile.step.ts +5 -5
- package/templates/default/support/steps/togglePlaygroundStates/web.step.ts +5 -5
- package/templates/default/support/steps/verifyPlaygroundState/mobile.step.ts +6 -6
- package/templates/default/support/steps/verifyPlaygroundState/web.step.ts +6 -6
- package/templates/nx/.nx/workspace-data/59842386-c7c4-44ca-b2c2-20e1700bd13d.db +0 -0
- package/templates/nx/.nx/workspace-data/59842386-c7c4-44ca-b2c2-20e1700bd13d.db-shm +0 -0
- package/templates/nx/.nx/workspace-data/59842386-c7c4-44ca-b2c2-20e1700bd13d.db-wal +0 -0
- package/templates/nx/.nx/workspace-data/d/daemon.log +1577 -168
- package/templates/nx/.nx/workspace-data/d/server-process.json +3 -0
- package/templates/nx/.nx/workspace-data/file-map.json +464 -0
- package/templates/nx/.nx/workspace-data/lockfile.hash +1 -0
- package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
- package/templates/nx/.nx/workspace-data/parsed-lock-file.json +13485 -0
- package/templates/nx/.nx/workspace-data/project-graph.json +14389 -0
- package/templates/nx/CLAUDE.md +10 -10
- package/templates/nx/package.json +3 -3
- package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0008-fetch-mocking/mobile.test.ts +27 -3
- package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0008-fetch-mocking/web.test.ts +27 -27
- package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0009-post-mocking/mobile.test.ts +24 -3
- package/templates/nx/packages/matchers/e2e/specs/ApiInterception/TC-0009-post-mocking/web.test.ts +24 -24
- package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -3
- package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +19 -19
- package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0005-text-and-values/mobile.test.ts +3 -3
- package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0005-text-and-values/web.test.ts +17 -17
- package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0006-collections-and-empty/mobile.test.ts +3 -3
- package/templates/nx/packages/matchers/e2e/specs/ContentMatchers/TC-0006-collections-and-empty/web.test.ts +11 -11
- package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0003-visibility-and-dom/mobile.test.ts +3 -3
- package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0003-visibility-and-dom/web.test.ts +11 -11
- package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0004-states-and-attributes/mobile.test.ts +3 -3
- package/templates/nx/packages/matchers/e2e/specs/ElementMatchers/TC-0004-states-and-attributes/web.test.ts +29 -29
- package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
- package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
- package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/suite.md +9 -0
- package/templates/nx/packages/playground/e2e/specs/Playground/TC-0001-form-controls/mobile.test.ts +3 -3
- package/templates/nx/packages/playground/e2e/specs/Playground/TC-0001-form-controls/web.test.ts +6 -6
- package/templates/nx/packages/playground/e2e/specs/Playground/TC-0002-toggle-elements/mobile.test.ts +3 -3
- package/templates/nx/packages/playground/e2e/specs/Playground/TC-0002-toggle-elements/web.test.ts +6 -6
- package/templates/nx/pnpm-lock.yaml +6663 -6663
- package/templates/nx/pnpm-workspace.yaml +9 -9
- package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +33 -33
- package/templates/nx/shared/testing/page-objects/MatchersPage/web.ts +91 -37
- package/templates/nx/shared/testing/page-objects/NavigationPage/mobile.ts +5 -5
- package/templates/nx/shared/testing/page-objects/NavigationPage/web.ts +8 -8
- package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +26 -26
- package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +31 -31
- package/templates/nx/shared/testing/project.json +19 -19
- package/templates/nx/shared/testing/support/actions/fillCredentials/mobile.action.ts +4 -4
- package/templates/nx/shared/testing/support/actions/fillCredentials/web.action.ts +4 -4
- package/templates/nx/shared/testing/support/steps/togglePlaygroundStates/mobile.step.ts +3 -3
- package/templates/nx/shared/testing/support/steps/togglePlaygroundStates/web.step.ts +5 -5
- package/templates/nx/shared/testing/support/steps/verifyPlaygroundState/mobile.step.ts +4 -4
- package/templates/nx/shared/testing/support/steps/verifyPlaygroundState/web.step.ts +6 -6
- package/templates/nx/spectra.config.ts +52 -68
- package/templates/react-component/.cursorrules +17 -0
- package/templates/react-component/.vscode/extensions.json +5 -0
- package/templates/react-component/CLAUDE.md +14 -0
- package/templates/react-component/README.md +69 -0
- package/templates/react-component/fixtures/component-mock.json +12 -0
- package/templates/react-component/global-hooks/after/web.hook.ts +3 -0
- package/templates/react-component/global-hooks/before/web.hook.ts +3 -0
- package/templates/react-component/package.json +27 -0
- package/templates/react-component/page-objects/BadgeComponent/web.ts +23 -0
- package/templates/react-component/page-objects/ButtonComponent/web.ts +23 -0
- package/templates/react-component/page-objects/InputComponent/web.ts +13 -0
- package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/spec.md +18 -0
- package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/web.test.tsx +11 -0
- package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/spec.md +18 -0
- package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/web.test.tsx +10 -0
- package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/spec.md +18 -0
- package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/web.test.tsx +16 -0
- package/templates/react-component/specs/BadgeComponent/suite.md +12 -0
- package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/spec.md +19 -0
- package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/web.test.tsx +28 -0
- package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/spec.md +19 -0
- package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/web.test.tsx +22 -0
- package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/spec.md +18 -0
- package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/web.test.tsx +8 -0
- package/templates/react-component/specs/ButtonComponent/suite.md +12 -0
- package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/spec.md +20 -0
- package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/web.test.tsx +28 -0
- package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/spec.md +18 -0
- package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/web.test.tsx +9 -0
- package/templates/react-component/specs/InputComponent/suite.md +12 -0
- package/templates/react-component/spectra.config.ts +27 -0
- package/templates/react-component/src/components/Badge/Badge.tsx +60 -0
- package/templates/react-component/src/components/Button/Button.tsx +57 -0
- package/templates/react-component/src/components/Input/Input.tsx +41 -0
- package/templates/react-component/src/test-utils.tsx +23 -0
- package/templates/react-component/support/actions/fillInputField/web.action.ts +9 -0
- package/templates/react-component/support/steps/verifyButtonState/web.step.ts +12 -0
- package/templates/react-component/tsconfig.json +8 -0
- package/bin/testspectra-runner +0 -0
- package/demo-app/assets/index-DPfv5_EF.js +0 -116
- package/demo-app/assets/index-gIwu2EMV.css +0 -1
- package/dist/commands/__tests__/cloud-run.test.js +0 -93
- package/dist/commands/cloud-run.d.ts +0 -13
- package/dist/commands/cloud-run.js +0 -120
- package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/mobile.test.ts +0 -17
- package/templates/default/specs/ContentMatchers/TC-0005-text-and-values/web.test.ts +0 -17
- /package/dist/{commands/__tests__/cloud-run.test.d.ts → linter/__tests__/anonymous-export.test.d.ts} +0 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { areTitlesAligned, extractDeclaredStepsFromSpec, extractModuleExports, extractSymbolsFromTestCode, } from './extractor.js';
|
|
5
|
+
import { isInsideSharedLibrary } from './discovery.js';
|
|
6
|
+
import { parseMarkdownFrontmatter } from './parser.js';
|
|
7
|
+
import { SpecFrontmatterSchema, validateSpecSections } from './schemas/spec.schema.js';
|
|
8
|
+
import { SuiteFrontmatterSchema, validateSuiteSections } from './schemas/suite.schema.js';
|
|
9
|
+
/**
|
|
10
|
+
* Resolves an import specifier against the importing file's directory and checks whether it
|
|
11
|
+
* reaches into an ambient-global-backed directory (Page/Component Objects, Steps, Fixtures) —
|
|
12
|
+
* the only imports still forbidden in `.tsx` component test files. Everything else (React,
|
|
13
|
+
* component source under test, npm packages, test-utils helpers) is allowed in `.tsx`, unlike
|
|
14
|
+
* the fully zero-import `.ts` E2E spec contract.
|
|
15
|
+
*
|
|
16
|
+
* See docs/v2/features/component-testing/react/mount-convention.md §"Static Imports Are Allowed
|
|
17
|
+
* Here" for the rationale.
|
|
18
|
+
*/
|
|
19
|
+
function resolvesToAmbientGlobalDir(specifier, testFilePath) {
|
|
20
|
+
if (!specifier.startsWith('.'))
|
|
21
|
+
return false; // bare package specifiers (react, etc.) are fine
|
|
22
|
+
const resolved = path.normalize(path.join(path.dirname(testFilePath), specifier));
|
|
23
|
+
const segments = resolved.split(path.sep);
|
|
24
|
+
return (segments.includes('page-objects') ||
|
|
25
|
+
segments.includes('fixtures') ||
|
|
26
|
+
(segments.includes('support') && segments.includes('steps')));
|
|
27
|
+
}
|
|
28
|
+
export class SpecLinter {
|
|
29
|
+
/**
|
|
30
|
+
* Validates a single spec.md file.
|
|
31
|
+
*/
|
|
32
|
+
static validateSpecFile(filePath, contentOverride, companionCodeOverrides) {
|
|
33
|
+
const diagnostics = [];
|
|
34
|
+
if (contentOverride === undefined && !fs.existsSync(filePath)) {
|
|
35
|
+
return {
|
|
36
|
+
diagnostics: [
|
|
37
|
+
{
|
|
38
|
+
code: 'testspectra/parse-error',
|
|
39
|
+
severity: 'error',
|
|
40
|
+
message: `File does not exist: ${filePath}`,
|
|
41
|
+
filePath,
|
|
42
|
+
line: 1,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const content = contentOverride !== undefined ? contentOverride : fs.readFileSync(filePath, 'utf-8');
|
|
48
|
+
const parsed = parseMarkdownFrontmatter(content, filePath);
|
|
49
|
+
diagnostics.push(...parsed.diagnostics);
|
|
50
|
+
if (!parsed.hasFrontmatter || !parsed.data) {
|
|
51
|
+
return { diagnostics };
|
|
52
|
+
}
|
|
53
|
+
const data = parsed.data;
|
|
54
|
+
const id = typeof data.id === 'string' ? data.id : undefined;
|
|
55
|
+
const idLine = parsed.keyLineMap.get('id') || 1;
|
|
56
|
+
try {
|
|
57
|
+
SpecFrontmatterSchema.parse(data);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
if (err instanceof ZodError) {
|
|
61
|
+
for (const issue of err.issues) {
|
|
62
|
+
const field = issue.path[0] ? String(issue.path[0]) : '';
|
|
63
|
+
const line = parsed.keyLineMap.get(field) || 1;
|
|
64
|
+
if (issue.code === 'invalid_enum_value') {
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
code: 'testspectra/invalid-frontmatter-enum',
|
|
67
|
+
severity: 'error',
|
|
68
|
+
message: issue.message,
|
|
69
|
+
filePath,
|
|
70
|
+
line,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else if (issue.code === 'invalid_type' &&
|
|
74
|
+
(issue.received === 'undefined' || issue.message.toLowerCase().includes('missing required'))) {
|
|
75
|
+
diagnostics.push({
|
|
76
|
+
code: 'testspectra/missing-frontmatter',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
message: issue.message,
|
|
79
|
+
filePath,
|
|
80
|
+
line: 1,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else if (['priority', 'caseType', 'status'].includes(field)) {
|
|
84
|
+
diagnostics.push({
|
|
85
|
+
code: 'testspectra/invalid-frontmatter-enum',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
message: issue.message,
|
|
88
|
+
filePath,
|
|
89
|
+
line,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
diagnostics.push({
|
|
94
|
+
code: 'testspectra/missing-frontmatter',
|
|
95
|
+
severity: 'error',
|
|
96
|
+
message: issue.message,
|
|
97
|
+
filePath,
|
|
98
|
+
line,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
diagnostics.push({
|
|
105
|
+
code: 'testspectra/parse-error',
|
|
106
|
+
severity: 'error',
|
|
107
|
+
message: err.message,
|
|
108
|
+
filePath,
|
|
109
|
+
line: 1,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Validate markdown body sections
|
|
114
|
+
const sectionDiagnostics = validateSpecSections(parsed.body, filePath, parsed.bodyStartLine);
|
|
115
|
+
diagnostics.push(...sectionDiagnostics);
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
117
|
+
// Stage 3 & 4: AST Step Parity & Test Structure Enforcement
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
119
|
+
const declaredSteps = extractDeclaredStepsFromSpec(parsed.body, parsed.bodyStartLine);
|
|
120
|
+
const specDir = path.dirname(filePath);
|
|
121
|
+
let companionTestFiles = [];
|
|
122
|
+
try {
|
|
123
|
+
const entries = fs.readdirSync(specDir, { withFileTypes: true });
|
|
124
|
+
companionTestFiles = entries
|
|
125
|
+
.filter((e) => e.isFile() &&
|
|
126
|
+
(e.name.endsWith('.test.ts') ||
|
|
127
|
+
e.name.endsWith('.test.tsx') ||
|
|
128
|
+
e.name.endsWith('.web.ts') ||
|
|
129
|
+
e.name.endsWith('.web.tsx') ||
|
|
130
|
+
e.name.endsWith('.mobile.ts') ||
|
|
131
|
+
e.name.endsWith('.mobile.tsx') ||
|
|
132
|
+
e.name.endsWith('.android.ts') ||
|
|
133
|
+
e.name.endsWith('.android.tsx') ||
|
|
134
|
+
e.name.endsWith('.ios.ts') ||
|
|
135
|
+
e.name.endsWith('.ios.tsx') ||
|
|
136
|
+
e.name.endsWith('.common.ts') ||
|
|
137
|
+
e.name.endsWith('.common.tsx')))
|
|
138
|
+
.map((e) => path.join(specDir, e.name));
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
141
|
+
if (companionCodeOverrides) {
|
|
142
|
+
const overrideKeys = companionCodeOverrides instanceof Map
|
|
143
|
+
? Array.from(companionCodeOverrides.keys())
|
|
144
|
+
: Object.keys(companionCodeOverrides);
|
|
145
|
+
for (const overridePath of overrideKeys) {
|
|
146
|
+
if (path.dirname(overridePath) === specDir && !companionTestFiles.includes(overridePath)) {
|
|
147
|
+
companionTestFiles.push(overridePath);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (companionTestFiles.length === 0) {
|
|
152
|
+
if (declaredSteps.length > 0) {
|
|
153
|
+
for (const step of declaredSteps) {
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
code: 'testspectra/missing-step-call',
|
|
156
|
+
severity: 'error',
|
|
157
|
+
message: `Parity Error: Step "@step:${step.name}" declared in spec.md line ${step.line} is missing in test code (no companion test files found)`,
|
|
158
|
+
filePath,
|
|
159
|
+
line: step.line,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const coverage = data.coverage;
|
|
166
|
+
for (const testFilePath of companionTestFiles) {
|
|
167
|
+
const testFileName = path.basename(testFilePath);
|
|
168
|
+
let testCode = '';
|
|
169
|
+
if (companionCodeOverrides) {
|
|
170
|
+
if (companionCodeOverrides instanceof Map) {
|
|
171
|
+
testCode = companionCodeOverrides.get(testFilePath) || '';
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
testCode = companionCodeOverrides[testFilePath] || '';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!testCode) {
|
|
178
|
+
try {
|
|
179
|
+
testCode = fs.readFileSync(testFilePath, 'utf-8');
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const symbols = extractSymbolsFromTestCode(testCode, testFilePath);
|
|
186
|
+
const isComponentTestFile = testFilePath.endsWith('.tsx');
|
|
187
|
+
// testspectra/no-manual-import: Manual import statement detected (must be zero-import).
|
|
188
|
+
// `.tsx` component test files (docs/v2/features/component-testing/react/) are exempt
|
|
189
|
+
// EXCEPT for imports that reach into ambient-global-backed directories (Page/Component
|
|
190
|
+
// Objects, Steps, Fixtures) — those stay forbidden so the ambient global is the single
|
|
191
|
+
// source of truth there too, exactly as in zero-import `.ts` E2E specs.
|
|
192
|
+
for (const imp of symbols.importStatements) {
|
|
193
|
+
if (isComponentTestFile && !resolvesToAmbientGlobalDir(imp.specifier, testFilePath)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
diagnostics.push({
|
|
197
|
+
code: 'testspectra/no-manual-import',
|
|
198
|
+
severity: 'error',
|
|
199
|
+
message: isComponentTestFile
|
|
200
|
+
? `Manual import of "${imp.specifier}" is forbidden; Page/Component Objects, Steps, and Fixtures are ambient globals even in component test files`
|
|
201
|
+
: 'Manual imports forbidden; all TestSpectra APIs, Page Objects, Steps, and Fixtures are ambient globals',
|
|
202
|
+
filePath: testFilePath,
|
|
203
|
+
line: imp.line,
|
|
204
|
+
column: imp.column,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// testspectra/no-test-block: forbidden test() calls detected
|
|
208
|
+
for (const tb of symbols.testBlocks) {
|
|
209
|
+
diagnostics.push({
|
|
210
|
+
code: 'testspectra/no-test-block',
|
|
211
|
+
severity: 'error',
|
|
212
|
+
message: 'test() is forbidden; TestSpectra test scripts must strictly use it() instead of test()',
|
|
213
|
+
filePath: testFilePath,
|
|
214
|
+
line: tb.line,
|
|
215
|
+
column: tb.column,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// testspectra/single-it-required: Multiple it() blocks (or 0 it blocks) detected in a single test case script
|
|
219
|
+
if (symbols.itBlocks.length === 0) {
|
|
220
|
+
if (symbols.testBlocks.length === 0) {
|
|
221
|
+
diagnostics.push({
|
|
222
|
+
code: 'testspectra/single-it-required',
|
|
223
|
+
severity: 'error',
|
|
224
|
+
message: 'Test case script must contain exactly one it() block (found 0)',
|
|
225
|
+
filePath: testFilePath,
|
|
226
|
+
line: 1,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (symbols.itBlocks.length > 1) {
|
|
231
|
+
for (let i = 1; i < symbols.itBlocks.length; i++) {
|
|
232
|
+
const extra = symbols.itBlocks[i];
|
|
233
|
+
diagnostics.push({
|
|
234
|
+
code: 'testspectra/single-it-required',
|
|
235
|
+
severity: 'error',
|
|
236
|
+
message: `Multiple it() blocks detected (${symbols.itBlocks.length}); each test script must contain exactly one it() block`,
|
|
237
|
+
filePath: testFilePath,
|
|
238
|
+
line: extra.line,
|
|
239
|
+
column: extra.column,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// testspectra/no-describe-block: describe(...) block detected (suites are strictly structured via directory hierarchy)
|
|
244
|
+
for (const desc of symbols.describeBlocks) {
|
|
245
|
+
diagnostics.push({
|
|
246
|
+
code: 'testspectra/no-describe-block',
|
|
247
|
+
severity: 'error',
|
|
248
|
+
message: 'describe() blocks are forbidden; test suites are structured via directory hierarchy with suite.md',
|
|
249
|
+
filePath: testFilePath,
|
|
250
|
+
line: desc.line,
|
|
251
|
+
column: desc.column,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// testspectra/title-mismatch: it("...") title in test script differs from title declared in spec.md frontmatter
|
|
255
|
+
if (symbols.itBlocks.length > 0 && typeof data.title === 'string') {
|
|
256
|
+
const itTitle = symbols.itBlocks[0].title;
|
|
257
|
+
if (!areTitlesAligned(data.title, itTitle)) {
|
|
258
|
+
diagnostics.push({
|
|
259
|
+
code: 'testspectra/title-mismatch',
|
|
260
|
+
severity: 'warning',
|
|
261
|
+
message: `Test title "${itTitle}" in ${testFileName}:L${symbols.itBlocks[0].line} does not match spec.md title "${data.title}"`,
|
|
262
|
+
filePath: testFilePath,
|
|
263
|
+
line: symbols.itBlocks[0].line,
|
|
264
|
+
column: symbols.itBlocks[0].column,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Determine if platform coverage is explicitly marked manual or unsupported
|
|
269
|
+
let isPlatformAutomated = true;
|
|
270
|
+
if (coverage) {
|
|
271
|
+
if (testFileName.startsWith('web.') && (coverage.web === 'manual' || coverage.web === 'unsupported')) {
|
|
272
|
+
isPlatformAutomated = false;
|
|
273
|
+
}
|
|
274
|
+
else if ((testFileName.startsWith('mobile.') ||
|
|
275
|
+
testFileName.startsWith('android.') ||
|
|
276
|
+
testFileName.startsWith('ios.')) &&
|
|
277
|
+
(coverage.mobile === 'manual' || coverage.mobile === 'unsupported')) {
|
|
278
|
+
isPlatformAutomated = false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const codeStepNames = new Set(symbols.stepCalls.map((s) => s.name));
|
|
282
|
+
// testspectra/missing-step-call: Step declared as @step:<name> in spec.md is absent in companion test script
|
|
283
|
+
if (isPlatformAutomated) {
|
|
284
|
+
for (const declaredStep of declaredSteps) {
|
|
285
|
+
if (!codeStepNames.has(declaredStep.name)) {
|
|
286
|
+
diagnostics.push({
|
|
287
|
+
code: 'testspectra/missing-step-call',
|
|
288
|
+
severity: 'error',
|
|
289
|
+
message: `Parity Error: Step "@step:${declaredStep.name}" declared in spec.md line ${declaredStep.line} is missing in ${testFileName}`,
|
|
290
|
+
filePath,
|
|
291
|
+
line: declaredStep.line,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// testspectra/undocumented-step: Step.<name>() called in test script is not documented with @step:<name> in spec.md
|
|
297
|
+
const declaredStepNames = new Set(declaredSteps.map((s) => s.name));
|
|
298
|
+
for (const stepCall of symbols.stepCalls) {
|
|
299
|
+
if (!declaredStepNames.has(stepCall.name)) {
|
|
300
|
+
diagnostics.push({
|
|
301
|
+
code: 'testspectra/undocumented-step',
|
|
302
|
+
severity: 'warning',
|
|
303
|
+
message: `Step "${stepCall.name}" called in ${testFileName}:L${stepCall.line} is not documented with @step:${stepCall.name} in spec.md`,
|
|
304
|
+
filePath: testFilePath,
|
|
305
|
+
line: stepCall.line,
|
|
306
|
+
column: stepCall.column,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { diagnostics, id, idLine };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Validates a single suite.md file.
|
|
316
|
+
*/
|
|
317
|
+
static validateSuiteFile(filePath, contentOverride) {
|
|
318
|
+
const diagnostics = [];
|
|
319
|
+
if (contentOverride === undefined && !fs.existsSync(filePath)) {
|
|
320
|
+
return {
|
|
321
|
+
diagnostics: [
|
|
322
|
+
{
|
|
323
|
+
code: 'testspectra/parse-error',
|
|
324
|
+
severity: 'error',
|
|
325
|
+
message: `File does not exist: ${filePath}`,
|
|
326
|
+
filePath,
|
|
327
|
+
line: 1,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const content = contentOverride !== undefined ? contentOverride : fs.readFileSync(filePath, 'utf-8');
|
|
333
|
+
const parsed = parseMarkdownFrontmatter(content, filePath);
|
|
334
|
+
diagnostics.push(...parsed.diagnostics);
|
|
335
|
+
if (!parsed.hasFrontmatter || !parsed.data) {
|
|
336
|
+
return { diagnostics };
|
|
337
|
+
}
|
|
338
|
+
const data = parsed.data;
|
|
339
|
+
const id = typeof data.id === 'string' ? data.id : undefined;
|
|
340
|
+
const idLine = parsed.keyLineMap.get('id') || 1;
|
|
341
|
+
try {
|
|
342
|
+
SuiteFrontmatterSchema.parse(data);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
if (err instanceof ZodError) {
|
|
346
|
+
for (const issue of err.issues) {
|
|
347
|
+
const field = issue.path[0] ? String(issue.path[0]) : '';
|
|
348
|
+
const line = parsed.keyLineMap.get(field) || 1;
|
|
349
|
+
if (issue.code === 'invalid_enum_value') {
|
|
350
|
+
diagnostics.push({
|
|
351
|
+
code: 'testspectra/invalid-frontmatter-enum',
|
|
352
|
+
severity: 'error',
|
|
353
|
+
message: issue.message,
|
|
354
|
+
filePath,
|
|
355
|
+
line,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
else if (issue.code === 'invalid_type' &&
|
|
359
|
+
(issue.received === 'undefined' || issue.message.toLowerCase().includes('missing required'))) {
|
|
360
|
+
diagnostics.push({
|
|
361
|
+
code: 'testspectra/missing-frontmatter',
|
|
362
|
+
severity: 'error',
|
|
363
|
+
message: issue.message,
|
|
364
|
+
filePath,
|
|
365
|
+
line: 1,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else if (field === 'status') {
|
|
369
|
+
diagnostics.push({
|
|
370
|
+
code: 'testspectra/invalid-frontmatter-enum',
|
|
371
|
+
severity: 'error',
|
|
372
|
+
message: issue.message,
|
|
373
|
+
filePath,
|
|
374
|
+
line,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
diagnostics.push({
|
|
379
|
+
code: 'testspectra/missing-frontmatter',
|
|
380
|
+
severity: 'error',
|
|
381
|
+
message: issue.message,
|
|
382
|
+
filePath,
|
|
383
|
+
line,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
diagnostics.push({
|
|
390
|
+
code: 'testspectra/parse-error',
|
|
391
|
+
severity: 'error',
|
|
392
|
+
message: err.message,
|
|
393
|
+
filePath,
|
|
394
|
+
line: 1,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Validate markdown body sections
|
|
399
|
+
const sectionDiagnostics = validateSuiteSections(parsed.body, filePath, parsed.bodyStartLine);
|
|
400
|
+
diagnostics.push(...sectionDiagnostics);
|
|
401
|
+
return { diagnostics, id, idLine };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Validates all spec.md and suite.md files in a workspace or specific target path.
|
|
405
|
+
*/
|
|
406
|
+
static validateWorkspace(workspaceDir, targetPath) {
|
|
407
|
+
const rootScan = targetPath ? path.resolve(workspaceDir, targetPath) : workspaceDir;
|
|
408
|
+
const specFiles = [];
|
|
409
|
+
const suiteFiles = [];
|
|
410
|
+
// Collect files
|
|
411
|
+
if (fs.existsSync(rootScan)) {
|
|
412
|
+
const stat = fs.statSync(rootScan);
|
|
413
|
+
if (stat.isFile()) {
|
|
414
|
+
const base = path.basename(rootScan);
|
|
415
|
+
if (base === 'spec.md')
|
|
416
|
+
specFiles.push(rootScan);
|
|
417
|
+
else if (base === 'suite.md')
|
|
418
|
+
suiteFiles.push(rootScan);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
// Directory traversal
|
|
422
|
+
const traverse = (dir) => {
|
|
423
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
424
|
+
for (const entry of entries) {
|
|
425
|
+
const fullPath = path.join(dir, entry.name);
|
|
426
|
+
if (entry.isDirectory()) {
|
|
427
|
+
if (entry.name === 'node_modules' ||
|
|
428
|
+
entry.name === 'dist' ||
|
|
429
|
+
entry.name === '.git' ||
|
|
430
|
+
entry.name === '.testspectra') {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
traverse(fullPath);
|
|
434
|
+
}
|
|
435
|
+
else if (entry.isFile()) {
|
|
436
|
+
if (entry.name === 'spec.md') {
|
|
437
|
+
specFiles.push(fullPath);
|
|
438
|
+
}
|
|
439
|
+
else if (entry.name === 'suite.md') {
|
|
440
|
+
suiteFiles.push(fullPath);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
// If target not specified and `specs/` exists, scan `specs/` by default
|
|
446
|
+
const defaultSpecsDir = path.join(rootScan, 'specs');
|
|
447
|
+
if (!targetPath && fs.existsSync(defaultSpecsDir)) {
|
|
448
|
+
traverse(defaultSpecsDir);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
traverse(rootScan);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const allDiagnostics = [];
|
|
456
|
+
const idTracker = new Map();
|
|
457
|
+
let passedSpecs = 0;
|
|
458
|
+
let passedSuites = 0;
|
|
459
|
+
// Validate suites first
|
|
460
|
+
for (const suitePath of suiteFiles) {
|
|
461
|
+
const { diagnostics, id, idLine } = this.validateSuiteFile(suitePath);
|
|
462
|
+
let fileValid = diagnostics.filter((d) => d.severity === 'error').length === 0;
|
|
463
|
+
if (id) {
|
|
464
|
+
if (idTracker.has(id)) {
|
|
465
|
+
const prev = idTracker.get(id);
|
|
466
|
+
diagnostics.push({
|
|
467
|
+
code: 'testspectra/duplicate-spec-id',
|
|
468
|
+
severity: 'error',
|
|
469
|
+
message: `Duplicate suite id "${id}" detected across the workspace (first seen in ${path.relative(workspaceDir, prev.filePath)})`,
|
|
470
|
+
filePath: suitePath,
|
|
471
|
+
line: idLine || 1,
|
|
472
|
+
});
|
|
473
|
+
fileValid = false;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
idTracker.set(id, { filePath: suitePath, line: idLine || 1 });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (fileValid) {
|
|
480
|
+
passedSuites++;
|
|
481
|
+
}
|
|
482
|
+
allDiagnostics.push(...diagnostics);
|
|
483
|
+
}
|
|
484
|
+
// Validate specs
|
|
485
|
+
for (const specPath of specFiles) {
|
|
486
|
+
const { diagnostics, id, idLine } = this.validateSpecFile(specPath);
|
|
487
|
+
let fileValid = diagnostics.filter((d) => d.severity === 'error').length === 0;
|
|
488
|
+
if (id) {
|
|
489
|
+
if (idTracker.has(id)) {
|
|
490
|
+
const prev = idTracker.get(id);
|
|
491
|
+
diagnostics.push({
|
|
492
|
+
code: 'testspectra/duplicate-spec-id',
|
|
493
|
+
severity: 'error',
|
|
494
|
+
message: `Duplicate test case id "${id}" detected across the workspace (first seen in ${path.relative(workspaceDir, prev.filePath)})`,
|
|
495
|
+
filePath: specPath,
|
|
496
|
+
line: idLine || 1,
|
|
497
|
+
});
|
|
498
|
+
fileValid = false;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
idTracker.set(id, { filePath: specPath, line: idLine || 1 });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (fileValid) {
|
|
505
|
+
passedSpecs++;
|
|
506
|
+
}
|
|
507
|
+
allDiagnostics.push(...diagnostics);
|
|
508
|
+
}
|
|
509
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
510
|
+
// testspectra/no-specs-in-shared-lib: Specs or test scripts detected inside shared testing library
|
|
511
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
512
|
+
for (const specPath of specFiles) {
|
|
513
|
+
if (isInsideSharedLibrary(specPath)) {
|
|
514
|
+
allDiagnostics.push({
|
|
515
|
+
code: 'testspectra/no-specs-in-shared-lib',
|
|
516
|
+
severity: 'error',
|
|
517
|
+
message: `Test specification detected inside shared testing library; specs must be placed in feature package directories`,
|
|
518
|
+
filePath: specPath,
|
|
519
|
+
line: 1,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const suitePath of suiteFiles) {
|
|
524
|
+
if (isInsideSharedLibrary(suitePath)) {
|
|
525
|
+
allDiagnostics.push({
|
|
526
|
+
code: 'testspectra/no-specs-in-shared-lib',
|
|
527
|
+
severity: 'error',
|
|
528
|
+
message: `Suite specification detected inside shared testing library; suites must be placed in feature package directories`,
|
|
529
|
+
filePath: suitePath,
|
|
530
|
+
line: 1,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
535
|
+
// testspectra/no-manual-import (extended) & testspectra/require-anonymous-default-export: Scan shared scripts workspace-wide
|
|
536
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
537
|
+
const sharedScriptExtensions = ['.step.ts', '.action.ts', '.hook.ts'];
|
|
538
|
+
const collectSharedScripts = (dir) => {
|
|
539
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
const fullPath = path.join(dir, entry.name);
|
|
542
|
+
if (entry.isDirectory()) {
|
|
543
|
+
if (entry.name === 'node_modules' ||
|
|
544
|
+
entry.name === 'dist' ||
|
|
545
|
+
entry.name === '.git' ||
|
|
546
|
+
entry.name === '.testspectra') {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
collectSharedScripts(fullPath);
|
|
550
|
+
}
|
|
551
|
+
else if (entry.isFile()) {
|
|
552
|
+
const isSharedScript = sharedScriptExtensions.some((ext) => entry.name.endsWith(ext));
|
|
553
|
+
if (!isSharedScript)
|
|
554
|
+
continue;
|
|
555
|
+
let content = '';
|
|
556
|
+
try {
|
|
557
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
// testspectra/no-manual-import: Check for manual imports in non-test scripts
|
|
563
|
+
const symbols = extractSymbolsFromTestCode(content, fullPath);
|
|
564
|
+
for (const imp of symbols.importStatements) {
|
|
565
|
+
allDiagnostics.push({
|
|
566
|
+
code: 'testspectra/no-manual-import',
|
|
567
|
+
severity: 'error',
|
|
568
|
+
message: 'Manual imports forbidden; all TestSpectra APIs, Page Objects, Steps, and Fixtures are ambient globals',
|
|
569
|
+
filePath: fullPath,
|
|
570
|
+
line: imp.line,
|
|
571
|
+
column: imp.column,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
// testspectra/require-anonymous-default-export: Check for anonymous async default export
|
|
575
|
+
const moduleExports = extractModuleExports(content, fullPath);
|
|
576
|
+
// testspectra/require-anonymous-default-export: Named exports are forbidden
|
|
577
|
+
for (const ne of moduleExports.namedExportLines) {
|
|
578
|
+
allDiagnostics.push({
|
|
579
|
+
code: 'testspectra/require-anonymous-default-export',
|
|
580
|
+
severity: 'error',
|
|
581
|
+
message: `Named export "${ne.name}" is forbidden; shared modules must use anonymous default export only`,
|
|
582
|
+
filePath: fullPath,
|
|
583
|
+
line: ne.line,
|
|
584
|
+
column: ne.column,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
if (!moduleExports.hasAnonymousAsyncDefaultExport && moduleExports.namedExportLines.length === 0) {
|
|
588
|
+
allDiagnostics.push({
|
|
589
|
+
code: 'testspectra/require-anonymous-default-export',
|
|
590
|
+
severity: 'error',
|
|
591
|
+
message: 'Shared step/action/hook must export an anonymous async function as default export',
|
|
592
|
+
filePath: fullPath,
|
|
593
|
+
line: 1,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
// testspectra/require-anonymous-default-export: Test blocks are forbidden in shared modules
|
|
597
|
+
for (const tb of moduleExports.testBlocks) {
|
|
598
|
+
allDiagnostics.push({
|
|
599
|
+
code: 'testspectra/require-anonymous-default-export',
|
|
600
|
+
severity: 'error',
|
|
601
|
+
message: `${tb.name}() block is forbidden inside shared step/action/hook modules`,
|
|
602
|
+
filePath: fullPath,
|
|
603
|
+
line: tb.line,
|
|
604
|
+
column: tb.column,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
try {
|
|
611
|
+
collectSharedScripts(rootScan);
|
|
612
|
+
}
|
|
613
|
+
catch { }
|
|
614
|
+
const errorCount = allDiagnostics.filter((d) => d.severity === 'error').length;
|
|
615
|
+
const warningCount = allDiagnostics.filter((d) => d.severity === 'warning').length;
|
|
616
|
+
const stats = {
|
|
617
|
+
totalSpecs: specFiles.length,
|
|
618
|
+
totalSuites: suiteFiles.length,
|
|
619
|
+
passedSpecs,
|
|
620
|
+
passedSuites,
|
|
621
|
+
errorCount,
|
|
622
|
+
warningCount,
|
|
623
|
+
};
|
|
624
|
+
return {
|
|
625
|
+
valid: errorCount === 0,
|
|
626
|
+
diagnostics: allDiagnostics,
|
|
627
|
+
stats,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type DiagnosticSeverity = 'error' | 'warning';
|
|
2
|
+
export type LintDiagnosticCode = 'testspectra/parse-error' | 'testspectra/missing-frontmatter' | 'testspectra/invalid-frontmatter-enum' | 'testspectra/missing-step-call' | 'testspectra/undocumented-step' | 'testspectra/title-mismatch' | 'testspectra/no-manual-import' | 'testspectra/single-it-required' | 'testspectra/no-test-block' | 'testspectra/no-describe-block' | 'testspectra/duplicate-spec-id' | 'testspectra/no-specs-in-shared-lib' | 'testspectra/require-anonymous-default-export';
|
|
3
|
+
export interface LintDiagnostic {
|
|
4
|
+
code: LintDiagnosticCode;
|
|
5
|
+
severity: DiagnosticSeverity;
|
|
6
|
+
message: string;
|
|
7
|
+
filePath: string;
|
|
8
|
+
line: number;
|
|
9
|
+
column?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface SpecLintStats {
|
|
12
|
+
totalSpecs: number;
|
|
13
|
+
totalSuites: number;
|
|
14
|
+
passedSpecs: number;
|
|
15
|
+
passedSuites: number;
|
|
16
|
+
errorCount: number;
|
|
17
|
+
warningCount: number;
|
|
18
|
+
}
|
|
19
|
+
export interface SpecLintResult {
|
|
20
|
+
valid: boolean;
|
|
21
|
+
diagnostics: LintDiagnostic[];
|
|
22
|
+
stats: SpecLintStats;
|
|
23
|
+
}
|
|
24
|
+
export interface LintOptions {
|
|
25
|
+
strict?: boolean;
|
|
26
|
+
json?: boolean;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|