@soulbatical/tetra-dev-toolkit 1.20.10 → 1.20.11

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.
@@ -37,7 +37,7 @@ const ALLOWED_SCRIPT_DIRS = new Set([
37
37
 
38
38
  /** Directories whose .md content is always allowed (tooling config) */
39
39
  const ALLOWED_MD_DIRS = new Set([
40
- 'docs', '.ralph', '.claude', '.agents', 'e2e'
40
+ 'docs', '.ralph', '.claude', '.agents', 'e2e', 'tests'
41
41
  ])
42
42
 
43
43
  /** Directories where .yml/.yaml config files are allowed */
@@ -40,3 +40,4 @@ export { check as checkBundleSize } from './bundle-size.js'
40
40
  export { check as checkSecurityLayers } from './security-layers.js'
41
41
  export { check as checkSmokeReadiness } from './smoke-readiness.js'
42
42
  export { check as checkReleasePipeline } from './release-pipeline.js'
43
+ export { check as checkTestStructure } from './test-structure.js'
@@ -36,6 +36,7 @@ import { check as checkBundleSize } from './bundle-size.js'
36
36
  import { check as checkSecurityLayers } from './security-layers.js'
37
37
  import { check as checkSmokeReadiness } from './smoke-readiness.js'
38
38
  import { check as checkReleasePipeline } from './release-pipeline.js'
39
+ import { check as checkTestStructure } from './test-structure.js'
39
40
  import { calculateHealthStatus } from './types.js'
40
41
 
41
42
  /**
@@ -80,7 +81,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
80
81
  checkBundleSize(projectPath),
81
82
  checkSecurityLayers(projectPath),
82
83
  checkSmokeReadiness(projectPath),
83
- checkReleasePipeline(projectPath)
84
+ checkReleasePipeline(projectPath),
85
+ checkTestStructure(projectPath)
84
86
  ])
85
87
 
86
88
  const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Health Check: Test Structure
3
+ *
4
+ * Validates that test files follow the canonical directory structure:
5
+ * tests/
6
+ * ├── e2e/ Backend API tests (Vitest)
7
+ * ├── widget/ Widget browser tests (Playwright)
8
+ * ├── unit/ Unit tests
9
+ * └── integration/ Integration tests
10
+ *
11
+ * Detects common anti-patterns:
12
+ * - Spec/test files at repo root or in code dirs
13
+ * - Multiple e2e directories (e.g. /e2e/ AND /tests/e2e/)
14
+ * - Test helpers outside helpers/ subdirs
15
+ * - Playwright specs outside tests/widget/
16
+ * - Missing numbering prefix on e2e/widget specs
17
+ *
18
+ * Score: 5pt max
19
+ * - 1pt: tests/ directory exists
20
+ * - 1pt: No test files in wrong locations (root, code dirs)
21
+ * - 1pt: No duplicate test directories
22
+ * - 1pt: Helpers in helpers/ subdirectories
23
+ * - 1pt: Numbered spec files (01-, 02-, etc.)
24
+ */
25
+
26
+ import { existsSync, readdirSync, statSync } from 'fs'
27
+ import { join, basename } from 'path'
28
+ import { createCheck } from './types.js'
29
+
30
+ const IGNORED_DIRS = new Set([
31
+ 'node_modules', 'dist', 'build', '.git', '.next', '.cache',
32
+ 'coverage', '.nyc_output', '.playwright', 'test-results'
33
+ ])
34
+
35
+ const CODE_DIRS = ['backend', 'frontend', 'backend-mcp', 'backend-pdf', 'mcp', 'widget']
36
+
37
+ const TEST_PATTERNS = /\.(test|spec)\.(ts|tsx|js|jsx)$/
38
+
39
+ export async function check(projectPath) {
40
+ const result = createCheck('test-structure', 5, {
41
+ hasTestsDir: false,
42
+ strayTestFiles: [],
43
+ duplicateTestDirs: [],
44
+ misplacedHelpers: [],
45
+ unnumberedSpecs: [],
46
+ structure: {}
47
+ })
48
+
49
+ // 1. Check tests/ directory exists
50
+ const testsDir = join(projectPath, 'tests')
51
+ if (existsSync(testsDir) && statSync(testsDir).isDirectory()) {
52
+ result.details.hasTestsDir = true
53
+ result.score += 1
54
+
55
+ // Map structure
56
+ try {
57
+ for (const entry of readdirSync(testsDir, { withFileTypes: true })) {
58
+ if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name)) {
59
+ const subPath = join(testsDir, entry.name)
60
+ const files = readdirSync(subPath, { withFileTypes: true })
61
+ .filter(f => f.isFile() && TEST_PATTERNS.test(f.name))
62
+ .map(f => f.name)
63
+ result.details.structure[entry.name] = { files: files.length }
64
+ }
65
+ }
66
+ } catch { /* ignore */ }
67
+ }
68
+
69
+ // 2. Check for test files in wrong locations
70
+ // Root-level spec/test files
71
+ try {
72
+ for (const entry of readdirSync(projectPath, { withFileTypes: true })) {
73
+ if (entry.isFile() && TEST_PATTERNS.test(entry.name)) {
74
+ result.details.strayTestFiles.push(entry.name)
75
+ }
76
+ }
77
+ } catch { /* ignore */ }
78
+
79
+ // Test files inside code dirs (except allowed test subdirs)
80
+ for (const codeDir of CODE_DIRS) {
81
+ const codePath = join(projectPath, codeDir)
82
+ if (!existsSync(codePath)) continue
83
+ try {
84
+ const walk = (dir, rel) => {
85
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
86
+ const fullPath = join(dir, entry.name)
87
+ const relPath = `${rel}/${entry.name}`
88
+ if (entry.isDirectory()) {
89
+ if (!IGNORED_DIRS.has(entry.name) && entry.name !== 'tests' && entry.name !== '__tests__') {
90
+ walk(fullPath, relPath)
91
+ }
92
+ } else if (entry.isFile() && TEST_PATTERNS.test(entry.name)) {
93
+ result.details.strayTestFiles.push(`${codeDir}${relPath}`)
94
+ }
95
+ }
96
+ }
97
+ walk(codePath, '')
98
+ } catch { /* ignore */ }
99
+ }
100
+
101
+ if (result.details.strayTestFiles.length === 0) result.score += 1
102
+
103
+ // 3. Check for duplicate test directories (e.g. both /e2e/ and /tests/e2e/)
104
+ const rootTestDirs = ['e2e', 'test', '__tests__', 'spec', 'cypress']
105
+ for (const dir of rootTestDirs) {
106
+ const rootDir = join(projectPath, dir)
107
+ if (!existsSync(rootDir)) continue
108
+ // Check if it contains spec/test files (not just configs)
109
+ try {
110
+ const hasTests = readdirSync(rootDir).some(f => TEST_PATTERNS.test(f))
111
+ if (hasTests) {
112
+ result.details.duplicateTestDirs.push(`/${dir}/ contains test files — move to /tests/`)
113
+ }
114
+ } catch { /* ignore */ }
115
+ }
116
+
117
+ if (result.details.duplicateTestDirs.length === 0) result.score += 1
118
+
119
+ // 4. Check that helpers are in helpers/ subdirs
120
+ if (existsSync(testsDir)) {
121
+ try {
122
+ const walk = (dir, rel) => {
123
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
124
+ if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name)) {
125
+ walk(join(dir, entry.name), `${rel}/${entry.name}`)
126
+ } else if (entry.isFile()) {
127
+ const name = entry.name.toLowerCase()
128
+ const isHelper = name.includes('helper') || name.includes('util') || name.includes('fixture')
129
+ const inHelperDir = rel.includes('/helpers') || rel.includes('/fixtures') || rel.includes('/utils')
130
+ if (isHelper && !TEST_PATTERNS.test(name) && !inHelperDir) {
131
+ result.details.misplacedHelpers.push(`tests${rel}/${entry.name}`)
132
+ }
133
+ }
134
+ }
135
+ }
136
+ walk(testsDir, '')
137
+ } catch { /* ignore */ }
138
+ }
139
+
140
+ if (result.details.misplacedHelpers.length === 0) result.score += 1
141
+
142
+ // 5. Check numbered spec files in e2e and widget dirs
143
+ const numberedDirs = ['e2e', 'widget']
144
+ for (const subDir of numberedDirs) {
145
+ const dirPath = join(testsDir, subDir)
146
+ if (!existsSync(dirPath)) continue
147
+ try {
148
+ for (const file of readdirSync(dirPath)) {
149
+ if (TEST_PATTERNS.test(file) && !/^\d{2}-/.test(file)) {
150
+ result.details.unnumberedSpecs.push(`tests/${subDir}/${file}`)
151
+ }
152
+ }
153
+ } catch { /* ignore */ }
154
+ }
155
+
156
+ if (result.details.unnumberedSpecs.length === 0) result.score += 1
157
+
158
+ // Cap score
159
+ result.score = Math.min(result.score, result.maxScore)
160
+
161
+ // Status
162
+ if (result.score <= 2) {
163
+ result.status = 'error'
164
+ result.details.message = 'Test structure needs significant cleanup'
165
+ } else if (result.score < 5) {
166
+ result.status = 'warning'
167
+ result.details.message = 'Test structure has issues'
168
+ }
169
+
170
+ return result
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.10",
3
+ "version": "1.20.11",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },