@soulbatical/tetra-dev-toolkit 1.20.0 → 2.0.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/README.md +235 -238
- package/bin/tetra-setup.js +2 -172
- package/lib/checks/health/index.js +0 -1
- package/lib/checks/health/scanner.js +1 -3
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/hygiene/stella-compliance.js +2 -2
- package/lib/checks/security/deprecated-supabase-admin.js +6 -15
- package/lib/checks/security/direct-supabase-client.js +4 -22
- package/lib/checks/security/frontend-supabase-queries.js +1 -1
- package/lib/checks/security/hardcoded-secrets.js +2 -5
- package/lib/checks/security/systemdb-whitelist.js +27 -116
- package/lib/config.js +1 -7
- package/lib/runner.js +7 -120
- package/package.json +2 -7
- package/bin/tetra-check-peers.js +0 -359
- package/bin/tetra-db-push.js +0 -91
- package/bin/tetra-migration-lint.js +0 -317
- package/bin/tetra-security-gate.js +0 -293
- package/bin/tetra-smoke.js +0 -532
- package/lib/checks/health/smoke-readiness.js +0 -150
- package/lib/checks/security/config-rls-alignment.js +0 -637
- package/lib/checks/security/mixed-db-usage.js +0 -204
- package/lib/checks/security/rls-live-audit.js +0 -255
- package/lib/checks/security/route-config-alignment.js +0 -342
- package/lib/checks/security/rpc-security-mode.js +0 -175
- package/lib/checks/security/tetra-core-compliance.js +0 -197
package/bin/tetra-smoke.js
DELETED
|
@@ -1,532 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Tetra Smoke — Config-driven + auto-discover post-deploy smoke tester
|
|
5
|
-
*
|
|
6
|
-
* Runs HTTP-based smoke tests against live endpoints.
|
|
7
|
-
* Two modes:
|
|
8
|
-
* 1. Config-driven: tests from .tetra-quality.json smoke section
|
|
9
|
-
* 2. Auto-discover: fetches /api/health/routes and tests ALL endpoints
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* tetra-smoke # Config + auto-discover
|
|
13
|
-
* tetra-smoke --url <backend-url> # Override backend URL
|
|
14
|
-
* tetra-smoke --discover-only # Only auto-discover (skip config checks)
|
|
15
|
-
* tetra-smoke --no-discover # Only config checks (skip auto-discover)
|
|
16
|
-
* tetra-smoke --json # JSON output for CI
|
|
17
|
-
* tetra-smoke --ci # GitHub Actions annotations
|
|
18
|
-
* tetra-smoke --notify # Telegram notification on failure
|
|
19
|
-
* tetra-smoke --post-deploy # Wait + retry mode for post-deploy
|
|
20
|
-
*
|
|
21
|
-
* Exit codes:
|
|
22
|
-
* 0 = all checks passed
|
|
23
|
-
* 1 = one or more checks failed
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { program } from 'commander'
|
|
27
|
-
import chalk from 'chalk'
|
|
28
|
-
import { readFileSync, existsSync } from 'fs'
|
|
29
|
-
import { join } from 'path'
|
|
30
|
-
|
|
31
|
-
function mergeHeaders(globalHeaders, endpointHeaders) {
|
|
32
|
-
return { ...(globalHeaders || {}), ...(endpointHeaders || {}) }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function loadSmokeConfig(projectRoot) {
|
|
36
|
-
const configFile = join(projectRoot, '.tetra-quality.json')
|
|
37
|
-
if (!existsSync(configFile)) return null
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const config = JSON.parse(readFileSync(configFile, 'utf-8'))
|
|
41
|
-
return config.smoke || null
|
|
42
|
-
} catch {
|
|
43
|
-
return null
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function httpCheck(url, options = {}) {
|
|
48
|
-
const timeout = options.timeout || 10000
|
|
49
|
-
const start = Date.now()
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const fetchOptions = {
|
|
53
|
-
method: options.method || 'GET',
|
|
54
|
-
signal: AbortSignal.timeout(timeout),
|
|
55
|
-
headers: options.headers || {},
|
|
56
|
-
redirect: 'follow',
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const resp = await fetch(url, fetchOptions)
|
|
60
|
-
|
|
61
|
-
const duration = Date.now() - start
|
|
62
|
-
const body = await resp.text().catch(() => '')
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
url,
|
|
66
|
-
status: resp.status,
|
|
67
|
-
duration,
|
|
68
|
-
bodyLength: body.length,
|
|
69
|
-
passed: options.expectStatus ? resp.status === options.expectStatus : resp.ok,
|
|
70
|
-
body: body.substring(0, 500),
|
|
71
|
-
}
|
|
72
|
-
} catch (err) {
|
|
73
|
-
return {
|
|
74
|
-
url,
|
|
75
|
-
status: 0,
|
|
76
|
-
duration: Date.now() - start,
|
|
77
|
-
bodyLength: 0,
|
|
78
|
-
passed: false,
|
|
79
|
-
error: err.message,
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ============================================================================
|
|
85
|
-
// AUTO-DISCOVER: Fetch /api/health/routes and test all endpoints
|
|
86
|
-
// ============================================================================
|
|
87
|
-
|
|
88
|
-
async function discoverAndTestRoutes(baseUrl, options = {}) {
|
|
89
|
-
const timeout = options.timeout || 10000
|
|
90
|
-
const results = []
|
|
91
|
-
|
|
92
|
-
// Fetch route manifest
|
|
93
|
-
let routes
|
|
94
|
-
try {
|
|
95
|
-
const resp = await fetch(`${baseUrl}/api/health/routes`, {
|
|
96
|
-
signal: AbortSignal.timeout(timeout),
|
|
97
|
-
})
|
|
98
|
-
if (!resp.ok) {
|
|
99
|
-
return {
|
|
100
|
-
passed: true,
|
|
101
|
-
results: [{
|
|
102
|
-
name: 'Route Discovery',
|
|
103
|
-
url: `${baseUrl}/api/health/routes`,
|
|
104
|
-
status: resp.status,
|
|
105
|
-
duration: 0,
|
|
106
|
-
passed: true, // Don't fail if endpoint doesn't exist yet
|
|
107
|
-
skipped: true,
|
|
108
|
-
error: `Route discovery not available (HTTP ${resp.status}) — deploy tetra-core update first`,
|
|
109
|
-
}],
|
|
110
|
-
discoveredCount: 0,
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const data = await resp.json()
|
|
115
|
-
routes = data.routes || []
|
|
116
|
-
} catch (err) {
|
|
117
|
-
return {
|
|
118
|
-
passed: true,
|
|
119
|
-
results: [{
|
|
120
|
-
name: 'Route Discovery',
|
|
121
|
-
url: `${baseUrl}/api/health/routes`,
|
|
122
|
-
status: 0,
|
|
123
|
-
duration: 0,
|
|
124
|
-
passed: true,
|
|
125
|
-
skipped: true,
|
|
126
|
-
error: `Route discovery failed: ${err.message}`,
|
|
127
|
-
}],
|
|
128
|
-
discoveredCount: 0,
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Filter: only test GET routes (POST/PUT/DELETE would mutate data)
|
|
133
|
-
const getRoutes = routes.filter(r => r.method === 'GET')
|
|
134
|
-
|
|
135
|
-
// Skip routes with :params (we don't have valid IDs to test with)
|
|
136
|
-
// But we CAN test list endpoints, counts, filters, etc.
|
|
137
|
-
const testableRoutes = getRoutes.filter(r => !r.path.includes(':'))
|
|
138
|
-
|
|
139
|
-
// Skip certain paths that need special handling
|
|
140
|
-
const skipPaths = [
|
|
141
|
-
'/api/health', // Already tested
|
|
142
|
-
'/api/health/routes', // This endpoint itself
|
|
143
|
-
]
|
|
144
|
-
|
|
145
|
-
const routesToTest = testableRoutes.filter(r => !skipPaths.includes(r.path))
|
|
146
|
-
|
|
147
|
-
// Expected status per auth level (without providing auth token)
|
|
148
|
-
const expectedStatus = {
|
|
149
|
-
public: null, // Could be 200, 400 (missing params), or 422 — just not 500
|
|
150
|
-
admin: 401, // Must require auth
|
|
151
|
-
user: 401, // Must require auth
|
|
152
|
-
superadmin: 401, // Must require auth
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Test routes in parallel batches of 10
|
|
156
|
-
const BATCH_SIZE = 10
|
|
157
|
-
for (let i = 0; i < routesToTest.length; i += BATCH_SIZE) {
|
|
158
|
-
const batch = routesToTest.slice(i, i + BATCH_SIZE)
|
|
159
|
-
|
|
160
|
-
const batchResults = await Promise.all(
|
|
161
|
-
batch.map(async (route) => {
|
|
162
|
-
const expected = expectedStatus[route.auth]
|
|
163
|
-
const r = await httpCheck(`${baseUrl}${route.path}`, {
|
|
164
|
-
timeout,
|
|
165
|
-
expectStatus: expected || undefined,
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
// For public routes: accept anything except 500 (server error)
|
|
169
|
-
if (route.auth === 'public') {
|
|
170
|
-
r.passed = r.status > 0 && r.status < 500
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
name: `${route.auth.toUpperCase()} ${route.path}`,
|
|
175
|
-
auth: route.auth,
|
|
176
|
-
...r,
|
|
177
|
-
}
|
|
178
|
-
})
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
results.push(...batchResults)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const allPassed = results.every(r => r.passed)
|
|
185
|
-
return {
|
|
186
|
-
passed: allPassed,
|
|
187
|
-
results,
|
|
188
|
-
discoveredCount: routesToTest.length,
|
|
189
|
-
totalRoutes: routes.length,
|
|
190
|
-
skippedParamRoutes: getRoutes.length - testableRoutes.length,
|
|
191
|
-
skippedNonGet: routes.length - getRoutes.length,
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ============================================================================
|
|
196
|
-
// CONFIG-DRIVEN SMOKE TESTS
|
|
197
|
-
// ============================================================================
|
|
198
|
-
|
|
199
|
-
async function runConfigTests(config, options) {
|
|
200
|
-
const baseUrl = options.url || config.baseUrl
|
|
201
|
-
const frontendUrl = config.frontendUrl
|
|
202
|
-
const timeout = config.timeout || 10000
|
|
203
|
-
const results = []
|
|
204
|
-
|
|
205
|
-
if (!baseUrl) {
|
|
206
|
-
return { passed: false, results: [], error: 'No baseUrl configured' }
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const checks = config.checks || {}
|
|
210
|
-
|
|
211
|
-
// 1. Health check
|
|
212
|
-
if (checks.health !== false) {
|
|
213
|
-
const r = await httpCheck(`${baseUrl}/api/health`, { timeout })
|
|
214
|
-
results.push({ name: 'Health', ...r })
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// 2. Deep health check
|
|
218
|
-
if (checks.healthDeep) {
|
|
219
|
-
const r = await httpCheck(`${baseUrl}/api/health?deep=true`, { timeout })
|
|
220
|
-
if (r.passed) {
|
|
221
|
-
try {
|
|
222
|
-
const data = JSON.parse(r.body)
|
|
223
|
-
if (data.status === 'degraded') {
|
|
224
|
-
r.passed = false
|
|
225
|
-
r.error = `Degraded: ${JSON.stringify(data.checks)}`
|
|
226
|
-
}
|
|
227
|
-
} catch { /* non-JSON is fine for basic health */ }
|
|
228
|
-
}
|
|
229
|
-
results.push({ name: 'Deep Health', ...r })
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// 3. Public endpoints
|
|
233
|
-
if (checks.publicEndpoints) {
|
|
234
|
-
for (const ep of checks.publicEndpoints) {
|
|
235
|
-
const expectedStatus = ep.expect?.status || 200
|
|
236
|
-
const r = await httpCheck(`${baseUrl}${ep.path}`, {
|
|
237
|
-
timeout,
|
|
238
|
-
expectStatus: expectedStatus,
|
|
239
|
-
headers: mergeHeaders(config.headers, ep.headers),
|
|
240
|
-
})
|
|
241
|
-
results.push({
|
|
242
|
-
name: ep.description || `Public: ${ep.path}`,
|
|
243
|
-
...r,
|
|
244
|
-
})
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// 4. Auth endpoints (should return 401 without token)
|
|
249
|
-
if (checks.authEndpoints) {
|
|
250
|
-
for (const ep of checks.authEndpoints) {
|
|
251
|
-
const expectedStatus = ep.expect?.status || 401
|
|
252
|
-
const r = await httpCheck(`${baseUrl}${ep.path}`, {
|
|
253
|
-
timeout,
|
|
254
|
-
expectStatus: expectedStatus,
|
|
255
|
-
headers: mergeHeaders(config.headers, ep.headers),
|
|
256
|
-
})
|
|
257
|
-
results.push({
|
|
258
|
-
name: ep.description || `Auth wall: ${ep.path}`,
|
|
259
|
-
...r,
|
|
260
|
-
})
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// 5. Frontend pages
|
|
265
|
-
if (checks.frontendPages && frontendUrl) {
|
|
266
|
-
for (const ep of checks.frontendPages) {
|
|
267
|
-
const expectedStatus = ep.expect?.status || 200
|
|
268
|
-
const r = await httpCheck(`${frontendUrl}${ep.path}`, {
|
|
269
|
-
timeout,
|
|
270
|
-
expectStatus: expectedStatus,
|
|
271
|
-
})
|
|
272
|
-
if (r.passed && r.bodyLength < 100) {
|
|
273
|
-
r.passed = false
|
|
274
|
-
r.error = `Body too small (${r.bodyLength} bytes) — possibly empty page`
|
|
275
|
-
}
|
|
276
|
-
results.push({
|
|
277
|
-
name: ep.description || `Frontend: ${ep.path}`,
|
|
278
|
-
...r,
|
|
279
|
-
})
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const allPassed = results.every(r => r.passed)
|
|
284
|
-
return { passed: allPassed, results }
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ============================================================================
|
|
288
|
-
// OUTPUT
|
|
289
|
-
// ============================================================================
|
|
290
|
-
|
|
291
|
-
function printResults(configResults, discoverResults, options) {
|
|
292
|
-
if (options.json) {
|
|
293
|
-
console.log(JSON.stringify({ config: configResults, discover: discoverResults }, null, 2))
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Config results
|
|
298
|
-
if (configResults && configResults.results.length > 0) {
|
|
299
|
-
console.log(chalk.blue.bold('\n Config Smoke Tests\n'))
|
|
300
|
-
printCheckResults(configResults.results, options)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Discover results
|
|
304
|
-
if (discoverResults && discoverResults.results.length > 0) {
|
|
305
|
-
const stats = []
|
|
306
|
-
if (discoverResults.totalRoutes) stats.push(`${discoverResults.totalRoutes} total routes`)
|
|
307
|
-
if (discoverResults.discoveredCount) stats.push(`${discoverResults.discoveredCount} testable`)
|
|
308
|
-
if (discoverResults.skippedParamRoutes) stats.push(`${discoverResults.skippedParamRoutes} skipped (need :id)`)
|
|
309
|
-
if (discoverResults.skippedNonGet) stats.push(`${discoverResults.skippedNonGet} skipped (POST/PUT/DELETE)`)
|
|
310
|
-
|
|
311
|
-
console.log(chalk.blue.bold('\n Auto-Discovered Route Tests'))
|
|
312
|
-
if (stats.length > 0) {
|
|
313
|
-
console.log(chalk.gray(` ${stats.join(' | ')}`))
|
|
314
|
-
}
|
|
315
|
-
console.log()
|
|
316
|
-
|
|
317
|
-
// Group by auth level
|
|
318
|
-
const byAuth = {}
|
|
319
|
-
for (const r of discoverResults.results) {
|
|
320
|
-
const auth = r.auth || 'other'
|
|
321
|
-
if (!byAuth[auth]) byAuth[auth] = []
|
|
322
|
-
byAuth[auth].push(r)
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
for (const [auth, results] of Object.entries(byAuth)) {
|
|
326
|
-
const passed = results.filter(r => r.passed).length
|
|
327
|
-
const failed = results.filter(r => !r.passed).length
|
|
328
|
-
const authLabel = auth.toUpperCase()
|
|
329
|
-
|
|
330
|
-
if (failed === 0) {
|
|
331
|
-
console.log(chalk.green(` ✓ ${authLabel}: ${passed}/${results.length} passed`))
|
|
332
|
-
} else {
|
|
333
|
-
console.log(chalk.red(` ✗ ${authLabel}: ${failed}/${results.length} failed`))
|
|
334
|
-
// Show only failed ones
|
|
335
|
-
for (const r of results.filter(r => !r.passed)) {
|
|
336
|
-
console.log(chalk.red(` ✗ ${r.name} [${r.status}] ${r.error || ''}`))
|
|
337
|
-
if (options.ci) {
|
|
338
|
-
console.log(`::error title=Smoke Test Failed::${r.name}: ${r.error || `HTTP ${r.status}`}`)
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Summary
|
|
346
|
-
const allResults = [
|
|
347
|
-
...(configResults?.results || []),
|
|
348
|
-
...(discoverResults?.results || []),
|
|
349
|
-
]
|
|
350
|
-
const totalPassed = allResults.filter(r => r.passed).length
|
|
351
|
-
const totalFailed = allResults.filter(r => !r.passed).length
|
|
352
|
-
const total = allResults.length
|
|
353
|
-
|
|
354
|
-
console.log()
|
|
355
|
-
if (totalFailed === 0) {
|
|
356
|
-
console.log(chalk.green.bold(` All ${total} checks passed\n`))
|
|
357
|
-
} else {
|
|
358
|
-
console.log(chalk.red.bold(` ${totalFailed}/${total} checks failed\n`))
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function printCheckResults(results, options) {
|
|
363
|
-
for (const r of results) {
|
|
364
|
-
const icon = r.passed ? chalk.green('✓') : chalk.red('✗')
|
|
365
|
-
const duration = chalk.gray(`${r.duration}ms`)
|
|
366
|
-
const status = r.status ? chalk.gray(`[${r.status}]`) : ''
|
|
367
|
-
|
|
368
|
-
console.log(` ${icon} ${r.name} ${status} ${duration}`)
|
|
369
|
-
|
|
370
|
-
if (!r.passed && r.error) {
|
|
371
|
-
console.log(chalk.red(` ${r.error}`))
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (r.skipped) {
|
|
375
|
-
console.log(chalk.yellow(` ${r.error}`))
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (options.ci && !r.passed && !r.skipped) {
|
|
379
|
-
console.log(`::error title=Smoke Test Failed::${r.name}: ${r.error || `HTTP ${r.status}`}`)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (r.passed && r.duration > 3000) {
|
|
383
|
-
console.log(chalk.yellow(` ⚠ Slow response (${r.duration}ms)`))
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function sendTelegramNotification(allResults, config) {
|
|
389
|
-
const ralphUrl = process.env.RALPH_MANAGER_API || 'http://localhost:3005'
|
|
390
|
-
|
|
391
|
-
const failedChecks = allResults.filter(r => !r.passed)
|
|
392
|
-
const lines = [
|
|
393
|
-
`🔥 *Smoke Test FAILED*`,
|
|
394
|
-
'',
|
|
395
|
-
`Project: ${config.baseUrl}`,
|
|
396
|
-
`Failed: ${failedChecks.length}/${allResults.length}`,
|
|
397
|
-
'',
|
|
398
|
-
...failedChecks.slice(0, 10).map(r => `❌ ${r.name}: ${r.error || `HTTP ${r.status}`}`),
|
|
399
|
-
...(failedChecks.length > 10 ? [`... and ${failedChecks.length - 10} more`] : []),
|
|
400
|
-
]
|
|
401
|
-
|
|
402
|
-
try {
|
|
403
|
-
await fetch(`${ralphUrl}/api/internal/telegram/send`, {
|
|
404
|
-
method: 'POST',
|
|
405
|
-
headers: { 'Content-Type': 'application/json' },
|
|
406
|
-
body: JSON.stringify({ message: lines.join('\n') }),
|
|
407
|
-
signal: AbortSignal.timeout(5000),
|
|
408
|
-
})
|
|
409
|
-
} catch {
|
|
410
|
-
console.log(chalk.yellow(' Could not send Telegram notification'))
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// ============================================================================
|
|
415
|
-
// MAIN
|
|
416
|
-
// ============================================================================
|
|
417
|
-
|
|
418
|
-
program
|
|
419
|
-
.name('tetra-smoke')
|
|
420
|
-
.description('Config-driven + auto-discover post-deploy smoke tester')
|
|
421
|
-
.version('1.0.0')
|
|
422
|
-
.option('--url <url>', 'Override backend URL')
|
|
423
|
-
.option('--frontend-url <url>', 'Override frontend URL')
|
|
424
|
-
.option('--discover-only', 'Only run auto-discovered route tests')
|
|
425
|
-
.option('--no-discover', 'Skip auto-discover, only run config tests')
|
|
426
|
-
.option('--json', 'JSON output')
|
|
427
|
-
.option('--ci', 'GitHub Actions annotations')
|
|
428
|
-
.option('--notify', 'Send Telegram notification on failure')
|
|
429
|
-
.option('--post-deploy', 'Wait + retry mode (waits 30s, retries 3x)')
|
|
430
|
-
.option('--timeout <ms>', 'Request timeout in ms', '10000')
|
|
431
|
-
.action(async (options) => {
|
|
432
|
-
try {
|
|
433
|
-
const projectRoot = process.cwd()
|
|
434
|
-
const smokeConfig = loadSmokeConfig(projectRoot)
|
|
435
|
-
|
|
436
|
-
if (!smokeConfig && !options.url) {
|
|
437
|
-
console.error(chalk.red('\n No smoke config found in .tetra-quality.json and no --url provided.\n'))
|
|
438
|
-
console.log(chalk.gray(' Add a "smoke" section to .tetra-quality.json:'))
|
|
439
|
-
console.log(chalk.gray(' {'))
|
|
440
|
-
console.log(chalk.gray(' "smoke": {'))
|
|
441
|
-
console.log(chalk.gray(' "baseUrl": "https://your-app.railway.app",'))
|
|
442
|
-
console.log(chalk.gray(' "checks": { "health": true, "healthDeep": true }'))
|
|
443
|
-
console.log(chalk.gray(' }'))
|
|
444
|
-
console.log(chalk.gray(' }\n'))
|
|
445
|
-
process.exit(1)
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const config = {
|
|
449
|
-
baseUrl: options.url || smokeConfig?.baseUrl,
|
|
450
|
-
frontendUrl: options.frontendUrl || smokeConfig?.frontendUrl,
|
|
451
|
-
timeout: parseInt(options.timeout, 10) || smokeConfig?.timeout || 10000,
|
|
452
|
-
checks: smokeConfig?.checks || { health: true },
|
|
453
|
-
headers: smokeConfig?.headers || {},
|
|
454
|
-
notify: smokeConfig?.notify || {},
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const runTests = async () => {
|
|
458
|
-
let configResults = null
|
|
459
|
-
let discoverResults = null
|
|
460
|
-
|
|
461
|
-
// Run config tests (unless --discover-only)
|
|
462
|
-
if (!options.discoverOnly) {
|
|
463
|
-
configResults = await runConfigTests(config, options)
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// Run auto-discover tests (unless --no-discover or explicitly disabled)
|
|
467
|
-
if (options.discover !== false) {
|
|
468
|
-
discoverResults = await discoverAndTestRoutes(config.baseUrl, {
|
|
469
|
-
timeout: config.timeout,
|
|
470
|
-
})
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return { configResults, discoverResults }
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Post-deploy mode: wait then retry
|
|
477
|
-
if (options.postDeploy) {
|
|
478
|
-
console.log(chalk.gray(' Post-deploy mode: waiting 30s for deploy to stabilize...\n'))
|
|
479
|
-
await new Promise(r => setTimeout(r, 30000))
|
|
480
|
-
|
|
481
|
-
let lastResult = null
|
|
482
|
-
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
483
|
-
console.log(chalk.gray(` Attempt ${attempt}/3...`))
|
|
484
|
-
lastResult = await runTests()
|
|
485
|
-
|
|
486
|
-
const allPassed = (lastResult.configResults?.passed !== false) &&
|
|
487
|
-
(lastResult.discoverResults?.passed !== false)
|
|
488
|
-
if (allPassed) break
|
|
489
|
-
|
|
490
|
-
if (attempt < 3) {
|
|
491
|
-
console.log(chalk.yellow(` Attempt ${attempt} failed, retrying in 15s...`))
|
|
492
|
-
await new Promise(r => setTimeout(r, 15000))
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
printResults(lastResult.configResults, lastResult.discoverResults, options)
|
|
497
|
-
|
|
498
|
-
const allResults = [
|
|
499
|
-
...(lastResult.configResults?.results || []),
|
|
500
|
-
...(lastResult.discoverResults?.results || []),
|
|
501
|
-
]
|
|
502
|
-
const allPassed = allResults.every(r => r.passed || r.skipped)
|
|
503
|
-
|
|
504
|
-
if (!allPassed && (options.notify || config.notify?.onFailure)) {
|
|
505
|
-
await sendTelegramNotification(allResults, config)
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
process.exit(allPassed ? 0 : 1)
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Normal mode
|
|
512
|
-
const { configResults, discoverResults } = await runTests()
|
|
513
|
-
printResults(configResults, discoverResults, options)
|
|
514
|
-
|
|
515
|
-
const allResults = [
|
|
516
|
-
...(configResults?.results || []),
|
|
517
|
-
...(discoverResults?.results || []),
|
|
518
|
-
]
|
|
519
|
-
const allPassed = allResults.every(r => r.passed || r.skipped)
|
|
520
|
-
|
|
521
|
-
if (!allPassed && (options.notify || config.notify?.onFailure)) {
|
|
522
|
-
await sendTelegramNotification(allResults, config)
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
process.exit(allPassed ? 0 : 1)
|
|
526
|
-
} catch (err) {
|
|
527
|
-
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
528
|
-
process.exit(1)
|
|
529
|
-
}
|
|
530
|
-
})
|
|
531
|
-
|
|
532
|
-
program.parse()
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Health Check: Smoke Test Readiness
|
|
3
|
-
*
|
|
4
|
-
* Checks if a project has proper smoke test and E2E infrastructure:
|
|
5
|
-
* - Smoke config in .tetra-quality.json
|
|
6
|
-
* - Health endpoint configured
|
|
7
|
-
* - E2E test files exist and are runnable
|
|
8
|
-
* - Post-deploy workflow exists
|
|
9
|
-
*
|
|
10
|
-
* Score: up to 5 points
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
14
|
-
import { join } from 'path'
|
|
15
|
-
import { createCheck } from './types.js'
|
|
16
|
-
|
|
17
|
-
export async function check(projectPath) {
|
|
18
|
-
const result = createCheck('smoke-readiness', 5, {
|
|
19
|
-
hasSmokeConfig: false,
|
|
20
|
-
hasHealthEndpoint: false,
|
|
21
|
-
hasE2ETests: false,
|
|
22
|
-
hasPostDeployWorkflow: false,
|
|
23
|
-
hasSmokeEndpoints: false,
|
|
24
|
-
smokeEndpointCount: 0,
|
|
25
|
-
e2eTestCount: 0,
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
// 1. Check for smoke config in .tetra-quality.json (+1 point)
|
|
29
|
-
const configFile = join(projectPath, '.tetra-quality.json')
|
|
30
|
-
if (existsSync(configFile)) {
|
|
31
|
-
try {
|
|
32
|
-
const config = JSON.parse(readFileSync(configFile, 'utf-8'))
|
|
33
|
-
if (config.smoke) {
|
|
34
|
-
result.details.hasSmokeConfig = true
|
|
35
|
-
result.score += 1
|
|
36
|
-
|
|
37
|
-
// Check if smoke endpoints are configured
|
|
38
|
-
const checks = config.smoke.checks || {}
|
|
39
|
-
const endpointCount =
|
|
40
|
-
(checks.publicEndpoints?.length || 0) +
|
|
41
|
-
(checks.authEndpoints?.length || 0) +
|
|
42
|
-
(checks.frontendPages?.length || 0)
|
|
43
|
-
|
|
44
|
-
if (endpointCount > 0) {
|
|
45
|
-
result.details.hasSmokeEndpoints = true
|
|
46
|
-
result.details.smokeEndpointCount = endpointCount
|
|
47
|
-
result.score += 1
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
} catch { /* invalid JSON */ }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// 2. Check for health endpoint in createApp config (+1 point)
|
|
54
|
-
// Look for createApp usage or /api/health route
|
|
55
|
-
const healthIndicators = [
|
|
56
|
-
'createApp', // tetra-core createApp includes /api/health
|
|
57
|
-
'/api/health', // explicit health endpoint
|
|
58
|
-
'healthcheck', // Railway/Docker healthcheck
|
|
59
|
-
]
|
|
60
|
-
|
|
61
|
-
let hasHealthEndpoint = false
|
|
62
|
-
const srcDirs = ['src', 'backend/src']
|
|
63
|
-
for (const srcDir of srcDirs) {
|
|
64
|
-
const srcPath = join(projectPath, srcDir)
|
|
65
|
-
if (!existsSync(srcPath)) continue
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const scanForHealth = (dir) => {
|
|
69
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
-
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
71
|
-
const fullPath = join(dir, entry.name)
|
|
72
|
-
if (entry.isDirectory()) {
|
|
73
|
-
scanForHealth(fullPath)
|
|
74
|
-
} else if (entry.isFile() && /\.(ts|js)$/.test(entry.name)) {
|
|
75
|
-
try {
|
|
76
|
-
const content = readFileSync(fullPath, 'utf-8')
|
|
77
|
-
if (healthIndicators.some(h => content.includes(h))) {
|
|
78
|
-
hasHealthEndpoint = true
|
|
79
|
-
}
|
|
80
|
-
} catch { /* skip */ }
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
scanForHealth(srcPath)
|
|
85
|
-
} catch { /* skip */ }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (hasHealthEndpoint) {
|
|
89
|
-
result.details.hasHealthEndpoint = true
|
|
90
|
-
result.score += 1
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// 3. Check for E2E test files (+1 point)
|
|
94
|
-
const e2eDirs = ['e2e', 'tests/e2e', 'test/e2e', 'cypress/e2e', 'playwright']
|
|
95
|
-
let e2eCount = 0
|
|
96
|
-
|
|
97
|
-
for (const dir of e2eDirs) {
|
|
98
|
-
const dirPath = join(projectPath, dir)
|
|
99
|
-
if (!existsSync(dirPath)) continue
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const countE2E = (path) => {
|
|
103
|
-
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
104
|
-
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
105
|
-
countE2E(join(path, entry.name))
|
|
106
|
-
} else if (entry.isFile() && /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) {
|
|
107
|
-
e2eCount++
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
countE2E(dirPath)
|
|
112
|
-
} catch { /* skip */ }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (e2eCount > 0) {
|
|
116
|
-
result.details.hasE2ETests = true
|
|
117
|
-
result.details.e2eTestCount = e2eCount
|
|
118
|
-
result.score += 1
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// 4. Check for post-deploy or smoke workflow (+1 point)
|
|
122
|
-
const workflowDir = join(projectPath, '.github/workflows')
|
|
123
|
-
if (existsSync(workflowDir)) {
|
|
124
|
-
try {
|
|
125
|
-
const workflows = readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
126
|
-
for (const wf of workflows) {
|
|
127
|
-
const content = readFileSync(join(workflowDir, wf), 'utf-8').toLowerCase()
|
|
128
|
-
if (content.includes('tetra-smoke') || content.includes('smoke-tests') || content.includes('post-deploy')) {
|
|
129
|
-
result.details.hasPostDeployWorkflow = true
|
|
130
|
-
result.details.workflowFile = wf
|
|
131
|
-
result.score += 1
|
|
132
|
-
break
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
} catch { /* skip */ }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Status
|
|
139
|
-
result.score = Math.min(result.score, result.maxScore)
|
|
140
|
-
|
|
141
|
-
if (result.score === 0) {
|
|
142
|
-
result.status = 'error'
|
|
143
|
-
result.details.message = 'No smoke test infrastructure — add smoke config to .tetra-quality.json'
|
|
144
|
-
} else if (result.score < 3) {
|
|
145
|
-
result.status = 'warning'
|
|
146
|
-
result.details.message = 'Incomplete smoke test coverage'
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return result
|
|
150
|
-
}
|