@soulbatical/tetra-dev-toolkit 1.19.0 → 1.20.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/README.md +76 -0
- package/bin/tetra-security-gate.js +0 -0
- package/bin/tetra-setup.js +172 -2
- package/bin/tetra-smoke.js +532 -0
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/health/smoke-readiness.js +150 -0
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/hygiene/stella-compliance.js +2 -2
- package/lib/checks/security/config-rls-alignment.js +1 -1
- package/lib/checks/security/direct-supabase-client.js +9 -0
- package/lib/checks/security/hardcoded-secrets.js +5 -2
- package/lib/checks/security/rpc-security-mode.js +19 -0
- package/lib/runner.js +3 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -231,6 +231,82 @@ If any step fails, fix it before writing code. No exceptions.
|
|
|
231
231
|
|
|
232
232
|
---
|
|
233
233
|
|
|
234
|
+
## Claude Code statusline
|
|
235
|
+
|
|
236
|
+
A 3-line statusline for Claude Code that shows context usage, costs, project health, and session metadata.
|
|
237
|
+
|
|
238
|
+
### Setup
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
# Symlink the script
|
|
242
|
+
ln -sf /path/to/tetra/packages/dev-toolkit/hooks/statusline.sh ~/.claude/hooks/statusline.sh
|
|
243
|
+
|
|
244
|
+
# Add to ~/.claude/settings.json
|
|
245
|
+
{
|
|
246
|
+
"statusLine": {
|
|
247
|
+
"type": "command",
|
|
248
|
+
"command": "~/.claude/hooks/statusline.sh"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### What it shows
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
repo: vibecodingacademy tasks: 3 open / 12 done started by: user cmux: workspace:308 ⠂ Claude Code
|
|
257
|
+
opus ██████░░░░░░░░░ 44% context: 89K / 200K 99% cached this turn: +7K
|
|
258
|
+
$3.76 5m20s (api: 3m5s) lines: +47 -12 turn #5 main* v2.1.73
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Line 1 — project dashboard**
|
|
262
|
+
|
|
263
|
+
| Field | Source | Description |
|
|
264
|
+
|-------|--------|-------------|
|
|
265
|
+
| `repo:` | `workspace.project_dir` | Current project directory name |
|
|
266
|
+
| `tasks:` | `.ralph/@fix_plan.md` | Open/done task count from Ralph fix plan |
|
|
267
|
+
| `started by:` | Process tree + cmux | `user`, `monica`, `ralph`, `cursor`, `vscode` |
|
|
268
|
+
| `cmux:` | `cmux identify` + `cmux list-workspaces` | Workspace ref and name (only in cmux terminal) |
|
|
269
|
+
|
|
270
|
+
**Line 2 — context window**
|
|
271
|
+
|
|
272
|
+
| Field | Source | Description |
|
|
273
|
+
|-------|--------|-------------|
|
|
274
|
+
| Model | `model.id` | Short name: `opus`, `sonnet`, `haiku` |
|
|
275
|
+
| Progress bar | `context_window.used_percentage` | 15-char bar, green <50%, yellow 50-80%, red >80% |
|
|
276
|
+
| `context:` | input + cache tokens | Effective tokens in window vs max |
|
|
277
|
+
| `cached` | `cache_read / context` | % of context served from prompt cache (saves cost) |
|
|
278
|
+
| `this turn:` | Delta from previous turn | Token growth this turn (helps spot expensive hooks) |
|
|
279
|
+
|
|
280
|
+
**Line 3 — session stats**
|
|
281
|
+
|
|
282
|
+
| Field | Source | Description |
|
|
283
|
+
|-------|--------|-------------|
|
|
284
|
+
| Cost | `cost.total_cost_usd` | Session cost so far |
|
|
285
|
+
| Duration | `cost.total_duration_ms` | Wall clock time |
|
|
286
|
+
| API time | `cost.total_api_duration_ms` | Time spent waiting for API responses |
|
|
287
|
+
| Lines | `cost.total_lines_added/removed` | Code changes this session |
|
|
288
|
+
| Turn | Delta tracker | Number of assistant responses |
|
|
289
|
+
| Branch | `git branch` | Current branch, `*` if dirty |
|
|
290
|
+
| Version | `version` | Claude Code version |
|
|
291
|
+
|
|
292
|
+
### How "started by" detection works
|
|
293
|
+
|
|
294
|
+
1. If running in cmux: checks workspace name — `monica:*` → `monica`
|
|
295
|
+
2. Fallback: walks the process tree looking for `ralph`, `cursor`, `code` (VS Code)
|
|
296
|
+
3. Default: `user` (manual terminal session)
|
|
297
|
+
|
|
298
|
+
### Task colors
|
|
299
|
+
|
|
300
|
+
| Color | Meaning |
|
|
301
|
+
|-------|---------|
|
|
302
|
+
| Green | 0 open tasks (all done) |
|
|
303
|
+
| Yellow | 1-10 open tasks |
|
|
304
|
+
| Red | 10+ open tasks |
|
|
305
|
+
|
|
306
|
+
Projects without `.ralph/@fix_plan.md` don't show the tasks field.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
234
310
|
## Changelog
|
|
235
311
|
|
|
236
312
|
### 1.16.0
|
|
File without changes
|
package/bin/tetra-setup.js
CHANGED
|
@@ -43,7 +43,7 @@ program
|
|
|
43
43
|
console.log('')
|
|
44
44
|
|
|
45
45
|
const components = component === 'all' || !component
|
|
46
|
-
? ['hooks', 'ci', 'config']
|
|
46
|
+
? ['hooks', 'ci', 'config', 'smoke']
|
|
47
47
|
: [component]
|
|
48
48
|
|
|
49
49
|
for (const comp of components) {
|
|
@@ -81,9 +81,12 @@ program
|
|
|
81
81
|
case 'license-audit':
|
|
82
82
|
await setupLicenseAudit(options)
|
|
83
83
|
break
|
|
84
|
+
case 'smoke':
|
|
85
|
+
await setupSmoke(options)
|
|
86
|
+
break
|
|
84
87
|
default:
|
|
85
88
|
console.log(`Unknown component: ${comp}`)
|
|
86
|
-
console.log('Available: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, knip, license-audit')
|
|
89
|
+
console.log('Available: hooks, ci, config, smoke, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, knip, license-audit')
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -867,4 +870,171 @@ async function setupLicenseAudit(options) {
|
|
|
867
870
|
console.log(' 📦 Run: npm install --save-dev license-checker')
|
|
868
871
|
}
|
|
869
872
|
|
|
873
|
+
// ─── Smoke Tests ─────────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
async function setupSmoke(options) {
|
|
876
|
+
console.log('🔥 Setting up smoke tests...')
|
|
877
|
+
|
|
878
|
+
// Step 1: Detect project name from git remote or package.json
|
|
879
|
+
let projectName = null
|
|
880
|
+
try {
|
|
881
|
+
const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
882
|
+
const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/)
|
|
883
|
+
if (match) projectName = match[1]
|
|
884
|
+
} catch { /* no git remote */ }
|
|
885
|
+
|
|
886
|
+
if (!projectName) {
|
|
887
|
+
try {
|
|
888
|
+
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'))
|
|
889
|
+
projectName = pkg.name?.replace(/^@[^/]+\//, '') || null
|
|
890
|
+
} catch { /* no package.json */ }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (!projectName) {
|
|
894
|
+
const { basename } = await import('path')
|
|
895
|
+
projectName = basename(projectRoot)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
console.log(` Project: ${projectName}`)
|
|
899
|
+
|
|
900
|
+
// Step 2: Get deploy config from ralph-manager
|
|
901
|
+
let backendUrl = null
|
|
902
|
+
let frontendUrl = null
|
|
903
|
+
|
|
904
|
+
const ralphUrl = process.env.RALPH_MANAGER_API || 'http://localhost:3005'
|
|
905
|
+
try {
|
|
906
|
+
const resp = await fetch(`${ralphUrl}/api/internal/projects?name=${encodeURIComponent(projectName)}`, {
|
|
907
|
+
signal: AbortSignal.timeout(5000),
|
|
908
|
+
})
|
|
909
|
+
if (resp.ok) {
|
|
910
|
+
const { data } = await resp.json()
|
|
911
|
+
if (data?.deploy_config) {
|
|
912
|
+
const dc = data.deploy_config
|
|
913
|
+
backendUrl = dc.backend?.url || (dc.domains?.api_domain ? `https://${dc.domains.api_domain}` : null)
|
|
914
|
+
frontendUrl = dc.frontend?.url || (dc.domains?.frontend_domain ? `https://${dc.domains.frontend_domain}` : null)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
} catch {
|
|
918
|
+
console.log(' ⚠️ Could not reach ralph-manager — using manual detection')
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Fallback: detect from railway.json
|
|
922
|
+
if (!backendUrl) {
|
|
923
|
+
const railwayPath = join(projectRoot, 'railway.json')
|
|
924
|
+
if (existsSync(railwayPath)) {
|
|
925
|
+
// Railway auto-deploy URL convention: {service-name}-production.up.railway.app
|
|
926
|
+
backendUrl = `https://${projectName}-production.up.railway.app`
|
|
927
|
+
console.log(` ℹ️ Guessed Railway URL: ${backendUrl}`)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (!backendUrl) {
|
|
932
|
+
console.log(' ❌ Could not detect production URL.')
|
|
933
|
+
console.log(' Add deploy_config in ralph-manager or pass --url manually.')
|
|
934
|
+
console.log(' You can also add "smoke.baseUrl" to .tetra-quality.json manually.')
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
console.log(` Backend: ${backendUrl}`)
|
|
939
|
+
if (frontendUrl) console.log(` Frontend: ${frontendUrl}`)
|
|
940
|
+
|
|
941
|
+
// Step 3: Add smoke config to .tetra-quality.json
|
|
942
|
+
const configPath = join(projectRoot, '.tetra-quality.json')
|
|
943
|
+
let config = {}
|
|
944
|
+
|
|
945
|
+
if (existsSync(configPath)) {
|
|
946
|
+
try {
|
|
947
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
|
948
|
+
} catch { /* invalid JSON, start fresh */ }
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (!config.smoke || options.force) {
|
|
952
|
+
config.smoke = {
|
|
953
|
+
baseUrl: backendUrl,
|
|
954
|
+
...(frontendUrl ? { frontendUrl } : {}),
|
|
955
|
+
timeout: 10000,
|
|
956
|
+
checks: {
|
|
957
|
+
health: true,
|
|
958
|
+
healthDeep: true,
|
|
959
|
+
authEndpoints: [
|
|
960
|
+
{ path: '/api/admin/users', expect: { status: 401 }, description: 'Auth wall: admin' },
|
|
961
|
+
],
|
|
962
|
+
...(frontendUrl ? {
|
|
963
|
+
frontendPages: [
|
|
964
|
+
{ path: '/', expect: { status: 200 }, description: 'Homepage' },
|
|
965
|
+
]
|
|
966
|
+
} : {}),
|
|
967
|
+
},
|
|
968
|
+
notify: {
|
|
969
|
+
telegram: true,
|
|
970
|
+
onSuccess: false,
|
|
971
|
+
onFailure: true,
|
|
972
|
+
},
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
976
|
+
console.log(' ✅ Added smoke config to .tetra-quality.json')
|
|
977
|
+
} else {
|
|
978
|
+
console.log(' ⏭️ Smoke config already exists (use --force to overwrite)')
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Step 4: Create post-deploy GitHub Actions workflow
|
|
982
|
+
const workflowDir = join(projectRoot, '.github/workflows')
|
|
983
|
+
if (!existsSync(workflowDir)) {
|
|
984
|
+
mkdirSync(workflowDir, { recursive: true })
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const smokeWorkflowPath = join(workflowDir, 'post-deploy-tests.yml')
|
|
988
|
+
if (!existsSync(smokeWorkflowPath) || options.force) {
|
|
989
|
+
let workflowContent = `name: Post-Deploy Smoke Tests
|
|
990
|
+
|
|
991
|
+
on:
|
|
992
|
+
# Triggered after deploy via webhook
|
|
993
|
+
repository_dispatch:
|
|
994
|
+
types: [deploy-completed]
|
|
995
|
+
|
|
996
|
+
# Manual trigger
|
|
997
|
+
workflow_dispatch:
|
|
998
|
+
|
|
999
|
+
# Scheduled: every 6 hours
|
|
1000
|
+
schedule:
|
|
1001
|
+
- cron: '0 */6 * * *'
|
|
1002
|
+
|
|
1003
|
+
jobs:
|
|
1004
|
+
smoke:
|
|
1005
|
+
uses: mralbertzwolle/tetra/.github/workflows/smoke-tests.yml@main
|
|
1006
|
+
with:
|
|
1007
|
+
backend-url: ${backendUrl}
|
|
1008
|
+
`
|
|
1009
|
+
if (frontendUrl) {
|
|
1010
|
+
workflowContent += ` frontend-url: ${frontendUrl}\n`
|
|
1011
|
+
}
|
|
1012
|
+
workflowContent += ` wait-seconds: 30
|
|
1013
|
+
post-deploy: true
|
|
1014
|
+
`
|
|
1015
|
+
|
|
1016
|
+
writeFileSync(smokeWorkflowPath, workflowContent)
|
|
1017
|
+
console.log(' ✅ Created .github/workflows/post-deploy-tests.yml')
|
|
1018
|
+
} else {
|
|
1019
|
+
console.log(' ⏭️ Post-deploy workflow already exists (use --force to overwrite)')
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Step 5: Verify smoke tests work
|
|
1023
|
+
console.log('')
|
|
1024
|
+
console.log(' 🧪 Quick verification...')
|
|
1025
|
+
try {
|
|
1026
|
+
const resp = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(10000) })
|
|
1027
|
+
if (resp.ok) {
|
|
1028
|
+
console.log(` ✅ ${backendUrl}/api/health → ${resp.status} OK`)
|
|
1029
|
+
} else {
|
|
1030
|
+
console.log(` ⚠️ ${backendUrl}/api/health → ${resp.status}`)
|
|
1031
|
+
}
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
console.log(` ❌ ${backendUrl}/api/health → ${err.message}`)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
console.log('')
|
|
1037
|
+
console.log(' Next: run `tetra-smoke` to test all endpoints')
|
|
1038
|
+
}
|
|
1039
|
+
|
|
870
1040
|
program.parse()
|
|
@@ -0,0 +1,532 @@
|
|
|
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()
|
|
@@ -38,3 +38,4 @@ export { check as checkLicenseAudit } from './license-audit.js'
|
|
|
38
38
|
export { check as checkSast } from './sast.js'
|
|
39
39
|
export { check as checkBundleSize } from './bundle-size.js'
|
|
40
40
|
export { check as checkSecurityLayers } from './security-layers.js'
|
|
41
|
+
export { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
@@ -34,6 +34,7 @@ import { check as checkLicenseAudit } from './license-audit.js'
|
|
|
34
34
|
import { check as checkSast } from './sast.js'
|
|
35
35
|
import { check as checkBundleSize } from './bundle-size.js'
|
|
36
36
|
import { check as checkSecurityLayers } from './security-layers.js'
|
|
37
|
+
import { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
37
38
|
import { calculateHealthStatus } from './types.js'
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -76,7 +77,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
76
77
|
checkLicenseAudit(projectPath),
|
|
77
78
|
checkSast(projectPath),
|
|
78
79
|
checkBundleSize(projectPath),
|
|
79
|
-
checkSecurityLayers(projectPath)
|
|
80
|
+
checkSecurityLayers(projectPath),
|
|
81
|
+
checkSmokeReadiness(projectPath)
|
|
80
82
|
])
|
|
81
83
|
|
|
82
84
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'} HealthCheckType
|
|
8
|
+
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'} HealthCheckType
|
|
9
9
|
*
|
|
10
10
|
* @typedef {'ok'|'warning'|'error'} HealthStatus
|
|
11
11
|
*
|
|
@@ -29,7 +29,7 @@ const DUPLICATE_PATTERNS = [
|
|
|
29
29
|
{
|
|
30
30
|
pattern: /const QUESTIONS_DIR\s*=\s*join\(tmpdir\(\)/,
|
|
31
31
|
label: 'Local QUESTIONS_DIR (telegram question store)',
|
|
32
|
-
allowedIn: ['stella/src/telegram.ts']
|
|
32
|
+
allowedIn: ['stella/src/telegram.ts', 'backend/src/features/telegram/']
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
pattern: /function detectWorkspaceRef\(\)/,
|
|
@@ -49,7 +49,7 @@ const DUPLICATE_PATTERNS = [
|
|
|
49
49
|
{
|
|
50
50
|
pattern: /function splitMessage\(text:\s*string/,
|
|
51
51
|
label: 'Local splitMessage helper (for telegram)',
|
|
52
|
-
allowedIn: ['stella/src/telegram.ts', 'tools/helpers.ts']
|
|
52
|
+
allowedIn: ['stella/src/telegram.ts', 'tools/helpers.ts', 'backend/src/features/telegram/']
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
pattern: /function playMacAlert\(\)/,
|
|
@@ -115,7 +115,7 @@ function parseMigrations(projectRoot) {
|
|
|
115
115
|
const relFile = file.replace(projectRoot + '/', '')
|
|
116
116
|
|
|
117
117
|
// Handle DROP POLICY — removes policy from earlier migration
|
|
118
|
-
const dropPolicyMatches = content.matchAll(/DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?"
|
|
118
|
+
const dropPolicyMatches = content.matchAll(/DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?"([^"]+)"\s+ON\s+(?:public\.)?(\w+)/gi)
|
|
119
119
|
for (const m of dropPolicyMatches) {
|
|
120
120
|
const policyName = m[1]
|
|
121
121
|
const table = m[2]
|
|
@@ -63,6 +63,15 @@ const ALLOWED_FILES = [
|
|
|
63
63
|
// Domain middleware (sets RLS session vars — needs direct client)
|
|
64
64
|
/middleware\/domainOrganizationMiddleware\.ts$/,
|
|
65
65
|
|
|
66
|
+
// Auth routes that only use Supabase Auth API (not DB queries)
|
|
67
|
+
/routes\/auth\.ts$/,
|
|
68
|
+
|
|
69
|
+
// WebSocket auth verification (only uses auth.getUser for token validation)
|
|
70
|
+
/services\/terminalWebSocket\.ts$/,
|
|
71
|
+
|
|
72
|
+
// Frontend Supabase client (Vite apps — client-side auth only, no Tetra backend)
|
|
73
|
+
/frontend\/src\/lib\/supabase\.ts$/,
|
|
74
|
+
|
|
66
75
|
// Scripts (not production code)
|
|
67
76
|
/scripts\//,
|
|
68
77
|
]
|
|
@@ -34,10 +34,13 @@ export async function run(config, projectRoot) {
|
|
|
34
34
|
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Get all source files
|
|
37
|
+
// Get all source files (always exclude node_modules, even nested ones)
|
|
38
38
|
const files = await glob('**/*.{ts,tsx,js,jsx,json}', {
|
|
39
39
|
cwd: projectRoot,
|
|
40
|
-
ignore:
|
|
40
|
+
ignore: [
|
|
41
|
+
'**/node_modules/**',
|
|
42
|
+
...config.ignore
|
|
43
|
+
]
|
|
41
44
|
})
|
|
42
45
|
|
|
43
46
|
for (const file of files) {
|
|
@@ -82,6 +82,9 @@ export async function run(config, projectRoot) {
|
|
|
82
82
|
const userWhitelist = (config.supabase?.securityDefinerWhitelist || [])
|
|
83
83
|
const whitelist = new Set([...BUILTIN_DEFINER_WHITELIST, ...userWhitelist])
|
|
84
84
|
|
|
85
|
+
// Sort files by name (timestamp order) so later migrations override earlier ones
|
|
86
|
+
sqlFiles.sort()
|
|
87
|
+
|
|
85
88
|
// Track latest definition per function (migrations can override)
|
|
86
89
|
const functions = new Map() // funcName → { securityMode, file, line, isDataQuery }
|
|
87
90
|
|
|
@@ -91,6 +94,22 @@ export async function run(config, projectRoot) {
|
|
|
91
94
|
|
|
92
95
|
const relFile = file.replace(projectRoot + '/', '')
|
|
93
96
|
|
|
97
|
+
// Handle DROP FUNCTION — removes function from tracking
|
|
98
|
+
const dropFuncMatches = content.matchAll(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(?:public\.)?(\w+)/gi)
|
|
99
|
+
for (const m of dropFuncMatches) {
|
|
100
|
+
functions.delete(m[1])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Handle ALTER FUNCTION ... SECURITY INVOKER/DEFINER — overrides security mode
|
|
104
|
+
const alterFuncMatches = content.matchAll(/ALTER\s+FUNCTION\s+(?:public\.)?(\w+)(?:\s*\([^)]*\))?\s+SECURITY\s+(INVOKER|DEFINER)/gi)
|
|
105
|
+
for (const m of alterFuncMatches) {
|
|
106
|
+
const existing = functions.get(m[1])
|
|
107
|
+
if (existing) {
|
|
108
|
+
existing.securityMode = m[2].toUpperCase()
|
|
109
|
+
existing.file = relFile
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
// Find all CREATE [OR REPLACE] FUNCTION statements
|
|
95
114
|
const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(/gi
|
|
96
115
|
let match
|
package/lib/runner.js
CHANGED
|
@@ -47,6 +47,7 @@ import { check as checkBundleSize } from './checks/health/bundle-size.js'
|
|
|
47
47
|
import { check as checkSast } from './checks/health/sast.js'
|
|
48
48
|
import { check as checkLicenseAudit } from './checks/health/license-audit.js'
|
|
49
49
|
import { check as checkSecurityLayers } from './checks/health/security-layers.js'
|
|
50
|
+
import { check as checkSmokeReadiness } from './checks/health/smoke-readiness.js'
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* Adapt a health check (score-based) to the runner format (meta + run).
|
|
@@ -116,7 +117,8 @@ const ALL_CHECKS = {
|
|
|
116
117
|
adaptHealthCheck('bundle-size', 'Bundle Size Monitoring', 'low', checkBundleSize),
|
|
117
118
|
adaptHealthCheck('sast', 'Static Application Security Testing', 'medium', checkSast),
|
|
118
119
|
adaptHealthCheck('license-audit', 'License Compliance', 'low', checkLicenseAudit),
|
|
119
|
-
adaptHealthCheck('security-layers', 'Security Layer Coverage', 'high', checkSecurityLayers)
|
|
120
|
+
adaptHealthCheck('security-layers', 'Security Layer Coverage', 'high', checkSecurityLayers),
|
|
121
|
+
adaptHealthCheck('smoke-readiness', 'Smoke Test Readiness', 'medium', checkSmokeReadiness)
|
|
120
122
|
],
|
|
121
123
|
codeQuality: [
|
|
122
124
|
apiResponseFormat,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"tetra-migration-lint": "./bin/tetra-migration-lint.js",
|
|
34
34
|
"tetra-db-push": "./bin/tetra-db-push.js",
|
|
35
35
|
"tetra-check-peers": "./bin/tetra-check-peers.js",
|
|
36
|
-
"tetra-security-gate": "./bin/tetra-security-gate.js"
|
|
36
|
+
"tetra-security-gate": "./bin/tetra-security-gate.js",
|
|
37
|
+
"tetra-smoke": "./bin/tetra-smoke.js"
|
|
37
38
|
},
|
|
38
39
|
"files": [
|
|
39
40
|
"bin/",
|