@soulbatical/tetra-dev-toolkit 1.20.25 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tetra-init-smoke.js +237 -0
- package/bin/tetra-style-audit.js +0 -0
- package/lib/checks/codeQuality/api-response-format.js +69 -34
- package/lib/checks/codeQuality/env-vars-defined.js +491 -0
- package/lib/checks/codeQuality/next-route-coverage.js +337 -0
- package/lib/checks/codeQuality/tetra-ui-ssr-safe-imports.js +335 -0
- package/lib/checks/index.js +3 -0
- package/package.json +3 -2
- package/templates/playwright-smoke.spec.ts.template +188 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tetra-init-smoke
|
|
5
|
+
*
|
|
6
|
+
* Scaffolds a Playwright smoke test suite into e2e/smoke.spec.ts.
|
|
7
|
+
*
|
|
8
|
+
* What it does:
|
|
9
|
+
* 1. Reads frontend/src/app/app/app-config.tsx (if present) and extracts
|
|
10
|
+
* all href values from the navigation config.
|
|
11
|
+
* 2. Reads doppler.yaml to discover the Doppler project name.
|
|
12
|
+
* 3. Reads .ralph/ports.json (or package.json scripts) to find the frontend port.
|
|
13
|
+
* 4. Copies the template from ../templates/playwright-smoke.spec.ts.template
|
|
14
|
+
* into e2e/smoke.spec.ts, substituting:
|
|
15
|
+
* {{NAV_ROUTES}} -> extracted route array
|
|
16
|
+
* {{FRONTEND_PORT}} -> detected frontend port
|
|
17
|
+
* {{DOPPLER_PROJECT}} -> doppler project name
|
|
18
|
+
* 5. Prints next steps: run with --update-snapshots, commit the file + baseline.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* npx tetra-init-smoke
|
|
22
|
+
* npx tetra-init-smoke --force (overwrite existing e2e/smoke.spec.ts)
|
|
23
|
+
* npx tetra-init-smoke --dry-run (preview without writing)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
27
|
+
import { join, dirname } from 'path'
|
|
28
|
+
import { fileURLToPath } from 'url'
|
|
29
|
+
|
|
30
|
+
const projectRoot = process.cwd()
|
|
31
|
+
const args = process.argv.slice(2)
|
|
32
|
+
const force = args.includes('--force') || args.includes('-f')
|
|
33
|
+
const dryRun = args.includes('--dry-run')
|
|
34
|
+
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
36
|
+
const __dirname = dirname(__filename)
|
|
37
|
+
|
|
38
|
+
// -- Helpers ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function readFileSafe(absPath) {
|
|
41
|
+
try { return readFileSync(absPath, 'utf-8') } catch { return null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function log(msg) { console.log(msg) }
|
|
45
|
+
function warn(msg) { console.warn(' WARNING: ' + msg) }
|
|
46
|
+
function ok(msg) { console.log(' OK: ' + msg) }
|
|
47
|
+
|
|
48
|
+
// -- Step 1: discover navigation routes ---------------------------------------
|
|
49
|
+
|
|
50
|
+
function extractNavRoutes(projectRoot) {
|
|
51
|
+
const candidates = [
|
|
52
|
+
join(projectRoot, 'frontend', 'src', 'app', 'app', 'app-config.tsx'),
|
|
53
|
+
join(projectRoot, 'frontend', 'src', 'app', 'app', 'app-config.ts'),
|
|
54
|
+
join(projectRoot, 'src', 'app', 'app', 'app-config.tsx'),
|
|
55
|
+
join(projectRoot, 'src', 'app', 'app', 'app-config.ts'),
|
|
56
|
+
join(projectRoot, 'app', 'app', 'app-config.tsx'),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
const content = readFileSafe(candidate)
|
|
61
|
+
if (!content) continue
|
|
62
|
+
|
|
63
|
+
const routes = []
|
|
64
|
+
const hrefRe = /href\s*:\s*['"]([^'"]+)['"]/g
|
|
65
|
+
let m
|
|
66
|
+
while ((m = hrefRe.exec(content)) !== null) {
|
|
67
|
+
const href = m[1]
|
|
68
|
+
if (href.startsWith('/') && !href.includes('://')) {
|
|
69
|
+
routes.push(href)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (routes.length > 0) {
|
|
74
|
+
ok('Found ' + routes.length + ' navigation routes in ' + candidate.replace(projectRoot + '/', ''))
|
|
75
|
+
return routes
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
warn('Could not find app-config.tsx -- NAV_ROUTES will be empty. Add routes manually.')
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- Step 2: discover Doppler project -----------------------------------------
|
|
84
|
+
|
|
85
|
+
function readDopplerProject(projectRoot) {
|
|
86
|
+
const dopplerPath = join(projectRoot, 'doppler.yaml')
|
|
87
|
+
const content = readFileSafe(dopplerPath)
|
|
88
|
+
if (!content) return null
|
|
89
|
+
const m = content.match(/project:\s*(\S+)/)
|
|
90
|
+
return m ? m[1] : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -- Step 3: discover frontend port -------------------------------------------
|
|
94
|
+
|
|
95
|
+
function detectFrontendPort(projectRoot) {
|
|
96
|
+
const portsPath = join(projectRoot, '.ralph', 'ports.json')
|
|
97
|
+
const portsContent = readFileSafe(portsPath)
|
|
98
|
+
if (portsContent) {
|
|
99
|
+
try {
|
|
100
|
+
const ports = JSON.parse(portsContent)
|
|
101
|
+
if (ports.frontend_port) return String(ports.frontend_port)
|
|
102
|
+
} catch { /* ignore */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const envLocal = readFileSafe(join(projectRoot, '.env.local'))
|
|
106
|
+
if (envLocal) {
|
|
107
|
+
const m = envLocal.match(/VITE_PORT\s*=\s*(\d+)/)
|
|
108
|
+
if (m) return m[1]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pkgPath = join(projectRoot, 'frontend', 'package.json')
|
|
112
|
+
const pkg = readFileSafe(pkgPath)
|
|
113
|
+
if (pkg) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(pkg)
|
|
116
|
+
const scripts = Object.values(parsed.scripts || {}).join(' ')
|
|
117
|
+
const m = scripts.match(/-p\s+(\d+)/)
|
|
118
|
+
if (m) return m[1]
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return '3001'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -- Step 4: load and fill template -------------------------------------------
|
|
126
|
+
|
|
127
|
+
function findTemplate() {
|
|
128
|
+
const templatePath = join(__dirname, '..', 'templates', 'playwright-smoke.spec.ts.template')
|
|
129
|
+
if (existsSync(templatePath)) return templatePath
|
|
130
|
+
|
|
131
|
+
const nmPath = join(projectRoot, 'node_modules', '@soulbatical', 'tetra-dev-toolkit', 'templates', 'playwright-smoke.spec.ts.template')
|
|
132
|
+
if (existsSync(nmPath)) return nmPath
|
|
133
|
+
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildOutput(template, opts) {
|
|
138
|
+
const navRoutes = opts.navRoutes
|
|
139
|
+
const frontendPort = opts.frontendPort
|
|
140
|
+
const dopplerProject = opts.dopplerProject
|
|
141
|
+
|
|
142
|
+
const routesArray = navRoutes.length > 0
|
|
143
|
+
? navRoutes.map(function(r) { return " '" + r + "'," }).join('\n')
|
|
144
|
+
: " // No routes detected -- add them manually, e.g. '/app/dashboard',"
|
|
145
|
+
|
|
146
|
+
return template
|
|
147
|
+
.replace(' // {{NAV_ROUTES}}', routesArray)
|
|
148
|
+
.split('{{NAV_ROUTES}}').join(routesArray)
|
|
149
|
+
.split('{{FRONTEND_PORT}}').join(frontendPort)
|
|
150
|
+
.split('{{DOPPLER_PROJECT}}').join(dopplerProject || '<your-doppler-project>')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// -- Main ---------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
log('')
|
|
156
|
+
log('Tetra Smoke Test Scaffolder')
|
|
157
|
+
log('='.repeat(50))
|
|
158
|
+
log('')
|
|
159
|
+
|
|
160
|
+
const navRoutes = extractNavRoutes(projectRoot)
|
|
161
|
+
const frontendPort = detectFrontendPort(projectRoot)
|
|
162
|
+
const dopplerProject = readDopplerProject(projectRoot)
|
|
163
|
+
|
|
164
|
+
log(' Routes: ' + navRoutes.length + ' detected')
|
|
165
|
+
log(' Port: ' + frontendPort)
|
|
166
|
+
log(' Doppler: ' + (dopplerProject || '(not found -- doppler.yaml missing)'))
|
|
167
|
+
log('')
|
|
168
|
+
|
|
169
|
+
const templatePath = findTemplate()
|
|
170
|
+
if (!templatePath) {
|
|
171
|
+
console.error(' ERROR: Template not found. Is @soulbatical/tetra-dev-toolkit installed?')
|
|
172
|
+
process.exit(1)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const template = readFileSafe(templatePath)
|
|
176
|
+
if (!template) {
|
|
177
|
+
console.error(' ERROR: Could not read template: ' + templatePath)
|
|
178
|
+
process.exit(1)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const output = buildOutput(template, { navRoutes, frontendPort, dopplerProject })
|
|
182
|
+
const outputPath = join(projectRoot, 'e2e', 'smoke.spec.ts')
|
|
183
|
+
|
|
184
|
+
if (dryRun) {
|
|
185
|
+
log('DRY RUN -- would write:')
|
|
186
|
+
log(' ' + outputPath)
|
|
187
|
+
log('')
|
|
188
|
+
log('Preview (first 40 lines):')
|
|
189
|
+
log('-'.repeat(50))
|
|
190
|
+
output.split('\n').slice(0, 40).forEach(function(l) { log(' ' + l) })
|
|
191
|
+
log(' ...')
|
|
192
|
+
log('')
|
|
193
|
+
process.exit(0)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (existsSync(outputPath) && !force) {
|
|
197
|
+
console.error(' ERROR: ' + outputPath + ' already exists.')
|
|
198
|
+
console.error(' Use --force to overwrite, or --dry-run to preview.')
|
|
199
|
+
process.exit(1)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const e2eDir = join(projectRoot, 'e2e')
|
|
203
|
+
if (!existsSync(e2eDir)) {
|
|
204
|
+
mkdirSync(e2eDir, { recursive: true })
|
|
205
|
+
ok('Created e2e/')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
writeFileSync(outputPath, output, 'utf-8')
|
|
209
|
+
ok('Written ' + outputPath.replace(projectRoot + '/', ''))
|
|
210
|
+
|
|
211
|
+
log('')
|
|
212
|
+
log('Next steps:')
|
|
213
|
+
log('='.repeat(50))
|
|
214
|
+
log('')
|
|
215
|
+
log(' 1. Install Playwright if not already:')
|
|
216
|
+
log(' npm install --save-dev @playwright/test')
|
|
217
|
+
log(' npx playwright install chromium')
|
|
218
|
+
log('')
|
|
219
|
+
log(' 2. Make sure DEV_TEST_EMAIL and DEV_TEST_PASSWORD exist in Doppler:')
|
|
220
|
+
if (dopplerProject) {
|
|
221
|
+
log(' doppler secrets set DEV_TEST_EMAIL=... --project ' + dopplerProject + ' --config dev_backend')
|
|
222
|
+
log(' doppler secrets set DEV_TEST_PASSWORD=... --project ' + dopplerProject + ' --config dev_backend')
|
|
223
|
+
} else {
|
|
224
|
+
log(' doppler secrets set DEV_TEST_EMAIL=... --project <p> --config dev_backend')
|
|
225
|
+
log(' doppler secrets set DEV_TEST_PASSWORD=... --project <p> --config dev_backend')
|
|
226
|
+
}
|
|
227
|
+
log('')
|
|
228
|
+
log(' 3. Start the dev server, then generate baseline screenshots:')
|
|
229
|
+
log(' doppler run -- npx playwright test e2e/smoke.spec.ts --update-snapshots')
|
|
230
|
+
log('')
|
|
231
|
+
log(' 4. Commit the spec and baseline:')
|
|
232
|
+
log(' git add e2e/smoke.spec.ts e2e/smoke.spec.ts-snapshots/')
|
|
233
|
+
log(' git commit -m "test: add Playwright smoke tests"')
|
|
234
|
+
log('')
|
|
235
|
+
log(' 5. Subsequent runs (CI):')
|
|
236
|
+
log(' doppler run -- npx playwright test e2e/smoke.spec.ts')
|
|
237
|
+
log('')
|
package/bin/tetra-style-audit.js
CHANGED
|
File without changes
|
|
@@ -33,46 +33,46 @@ export async function run(config, projectRoot) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
files = [...new Set(files)]
|
|
36
|
+
// Discover ALL backend source files (no path conventions assumed).
|
|
37
|
+
// Strategy: scan every .ts/.js file and detect Express route handlers by content.
|
|
38
|
+
// This catches feature folders, custom layouts, monorepo backends, etc.
|
|
39
|
+
const candidates = await glob('**/*.{ts,js,mts,cts,mjs,cjs}', {
|
|
40
|
+
cwd: projectRoot,
|
|
41
|
+
ignore: [
|
|
42
|
+
...(config.ignore || []),
|
|
43
|
+
'node_modules/**',
|
|
44
|
+
'**/node_modules/**',
|
|
45
|
+
'dist/**',
|
|
46
|
+
'**/dist/**',
|
|
47
|
+
'build/**',
|
|
48
|
+
'**/build/**',
|
|
49
|
+
'.next/**',
|
|
50
|
+
'**/.next/**',
|
|
51
|
+
'coverage/**',
|
|
52
|
+
'**/coverage/**',
|
|
53
|
+
'**/*.test.*',
|
|
54
|
+
'**/*.spec.*',
|
|
55
|
+
'**/*.d.ts',
|
|
56
|
+
'**/*.js.map',
|
|
57
|
+
// Frontend folders – these are not Express backends
|
|
58
|
+
'frontend/**',
|
|
59
|
+
'web/**',
|
|
60
|
+
'app/**',
|
|
61
|
+
'pages/**',
|
|
62
|
+
'src/app/**',
|
|
63
|
+
'src/pages/**'
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const files = [...new Set(candidates)]
|
|
69
68
|
|
|
70
69
|
if (files.length === 0) {
|
|
71
70
|
results.skipped = true
|
|
72
|
-
results.skipReason = 'No
|
|
71
|
+
results.skipReason = 'No source files found to scan'
|
|
73
72
|
return results
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
let backendFilesScanned = 0
|
|
76
76
|
for (const file of files) {
|
|
77
77
|
const filePath = `${projectRoot}/${file}`
|
|
78
78
|
let content
|
|
@@ -82,10 +82,21 @@ export async function run(config, projectRoot) {
|
|
|
82
82
|
continue
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
if (!isExpressRouteFile(content)) continue
|
|
86
|
+
|
|
87
|
+
backendFilesScanned++
|
|
85
88
|
const lines = content.split('\n')
|
|
86
89
|
analyzeFile(file, lines, results)
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
results.info.backendFilesScanned = backendFilesScanned
|
|
93
|
+
|
|
94
|
+
if (backendFilesScanned === 0) {
|
|
95
|
+
results.skipped = true
|
|
96
|
+
results.skipReason = 'No Express route handlers detected in scanned files'
|
|
97
|
+
return results
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
results.passed = results.findings.filter(f => f.severity === 'critical' || f.severity === 'high').length === 0
|
|
90
101
|
results.info.violations = results.findings.length
|
|
91
102
|
results.info.compliant = results.info.totalEndpoints - results.info.violations
|
|
@@ -93,6 +104,30 @@ export async function run(config, projectRoot) {
|
|
|
93
104
|
return results
|
|
94
105
|
}
|
|
95
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Heuristic: does this file contain Express-style route handlers that
|
|
109
|
+
* eventually call res.json() or res.status().json()?
|
|
110
|
+
*
|
|
111
|
+
* We require BOTH:
|
|
112
|
+
* 1. An indicator that this is server code (express import / Request type / router/app verb)
|
|
113
|
+
* 2. At least one res.json() / res.status(...).json() call
|
|
114
|
+
*
|
|
115
|
+
* This avoids scanning frontend files that happen to use the variable name `res`.
|
|
116
|
+
*/
|
|
117
|
+
function isExpressRouteFile(content) {
|
|
118
|
+
const hasResJsonCall = /\bres\s*(?:\.\s*status\s*\([^)]*\)\s*)?\.\s*json\s*\(/.test(content)
|
|
119
|
+
if (!hasResJsonCall) return false
|
|
120
|
+
|
|
121
|
+
const looksLikeExpress =
|
|
122
|
+
/from\s+['"]express['"]/.test(content) ||
|
|
123
|
+
/require\s*\(\s*['"]express['"]\s*\)/.test(content) ||
|
|
124
|
+
/\b(?:Request|Response|NextFunction|Router)\b/.test(content) ||
|
|
125
|
+
/\b(?:router|app)\s*\.\s*(?:get|post|put|patch|delete|all|use)\s*\(/.test(content) ||
|
|
126
|
+
/\bexpress\s*\(\s*\)/.test(content)
|
|
127
|
+
|
|
128
|
+
return looksLikeExpress
|
|
129
|
+
}
|
|
130
|
+
|
|
96
131
|
function analyzeFile(file, lines, results) {
|
|
97
132
|
for (let i = 0; i < lines.length; i++) {
|
|
98
133
|
const line = lines[i]
|