@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.
@@ -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
- }