@soulbatical/tetra-dev-toolkit 1.20.10 → 1.20.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tetra-init-tests.js +185 -66
- package/bin/tetra-init.js +67 -3
- package/bin/tetra-setup.js +49 -30
- package/bin/tetra-test-audit.js +83 -0
- package/lib/audits/test-coverage-audit.js +625 -0
- package/lib/checks/health/file-organization.js +1 -1
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/rpc-param-mismatch.js +21 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/health/test-structure.js +171 -0
- package/lib/checks/stability/ci-pipeline.js +21 -6
- package/lib/templates/tests/02-crud-resources.test.ts.tmpl +135 -0
- package/lib/templates/tests/03-permissions.test.ts.tmpl +110 -0
- package/lib/templates/tests/04-business-flows.test.ts.tmpl +64 -0
- package/lib/templates/tests/05-security.test.ts.tmpl +82 -0
- package/lib/templates/tests/global-setup.ts.tmpl +73 -0
- package/package.json +3 -2
- package/lib/templates/tests/07-security.test.ts.tmpl +0 -93
package/bin/tetra-init-tests.js
CHANGED
|
@@ -3,16 +3,24 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Tetra Init Tests — Scaffold golden standard E2E test structure.
|
|
5
5
|
*
|
|
6
|
-
* Creates:
|
|
7
|
-
* tests/e2e/helpers/api-client.ts
|
|
8
|
-
* tests/e2e/helpers/test-users.ts
|
|
9
|
-
* tests/e2e/
|
|
10
|
-
* tests/e2e
|
|
11
|
-
*
|
|
6
|
+
* Creates the complete test infrastructure:
|
|
7
|
+
* tests/e2e/helpers/api-client.ts HTTP client with X-Test-Key bypass
|
|
8
|
+
* tests/e2e/helpers/test-users.ts Login + token caching
|
|
9
|
+
* tests/e2e/global-setup.ts Login once, share tokens
|
|
10
|
+
* tests/e2e/.gitignore Exclude token files
|
|
11
|
+
* tests/e2e/01-auth.test.ts Auth: login, session, refresh, logout
|
|
12
|
+
* tests/e2e/02-crud-resources.test.ts CRUD for all resources (customize)
|
|
13
|
+
* tests/e2e/03-permissions.test.ts Auth walls for all routes (customize)
|
|
14
|
+
* tests/e2e/04-business-flows.test.ts End-to-end user journeys (customize)
|
|
15
|
+
* tests/e2e/05-security.test.ts Input validation, injection, CORS
|
|
16
|
+
* vitest.config.e2e.ts Vitest config for E2E
|
|
17
|
+
* .github/workflows/ci.yml CI with API tests (if missing)
|
|
18
|
+
* package.json test:e2e script (if missing)
|
|
12
19
|
*
|
|
13
20
|
* Usage:
|
|
14
|
-
* tetra-init-tests #
|
|
21
|
+
* tetra-init-tests # Scaffold all
|
|
15
22
|
* tetra-init-tests --force # Overwrite existing files
|
|
23
|
+
* tetra-init-tests --ci-only # Only add CI workflow
|
|
16
24
|
*/
|
|
17
25
|
|
|
18
26
|
import { program } from 'commander'
|
|
@@ -24,9 +32,13 @@ const __filename = fileURLToPath(import.meta.url)
|
|
|
24
32
|
const __dirname = dirname(__filename)
|
|
25
33
|
const TEMPLATES_DIR = join(__dirname, '..', 'lib', 'templates', 'tests')
|
|
26
34
|
|
|
35
|
+
let filesCreated = 0
|
|
36
|
+
let filesSkipped = 0
|
|
37
|
+
|
|
27
38
|
function copyTemplate(templateName, destPath, force = false) {
|
|
28
39
|
if (existsSync(destPath) && !force) {
|
|
29
|
-
console.log(` ⏩ ${destPath} (exists
|
|
40
|
+
console.log(` ⏩ ${destPath} (exists)`)
|
|
41
|
+
filesSkipped++
|
|
30
42
|
return false
|
|
31
43
|
}
|
|
32
44
|
|
|
@@ -40,17 +52,38 @@ function copyTemplate(templateName, destPath, force = false) {
|
|
|
40
52
|
mkdirSync(dirname(destPath), { recursive: true })
|
|
41
53
|
writeFileSync(destPath, content)
|
|
42
54
|
console.log(` ✅ ${destPath}`)
|
|
55
|
+
filesCreated++
|
|
43
56
|
return true
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
function writeFile(destPath, content, force = false) {
|
|
47
60
|
if (existsSync(destPath) && !force) {
|
|
48
61
|
console.log(` ⏩ ${destPath} (exists)`)
|
|
62
|
+
filesSkipped++
|
|
49
63
|
return false
|
|
50
64
|
}
|
|
51
65
|
mkdirSync(dirname(destPath), { recursive: true })
|
|
52
66
|
writeFileSync(destPath, content)
|
|
53
67
|
console.log(` ✅ ${destPath}`)
|
|
68
|
+
filesCreated++
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addNpmScript(root, scriptName, scriptCmd) {
|
|
73
|
+
const pkgPath = join(root, 'package.json')
|
|
74
|
+
if (!existsSync(pkgPath)) return false
|
|
75
|
+
|
|
76
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
77
|
+
if (pkg.scripts?.[scriptName]) {
|
|
78
|
+
console.log(` ⏩ package.json script "${scriptName}" (exists)`)
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pkg.scripts = pkg.scripts || {}
|
|
83
|
+
pkg.scripts[scriptName] = scriptCmd
|
|
84
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
85
|
+
console.log(` ✅ package.json script "${scriptName}" added`)
|
|
86
|
+
filesCreated++
|
|
54
87
|
return true
|
|
55
88
|
}
|
|
56
89
|
|
|
@@ -59,27 +92,39 @@ program
|
|
|
59
92
|
.description('Scaffold Tetra golden standard E2E test structure')
|
|
60
93
|
.option('--force', 'Overwrite existing files')
|
|
61
94
|
.option('--dir <path>', 'Test directory (default: tests/e2e)', 'tests/e2e')
|
|
95
|
+
.option('--ci-only', 'Only add CI workflow, skip test files')
|
|
96
|
+
.option('--port <port>', 'Backend port (default: 3003)', '3003')
|
|
62
97
|
.action((options) => {
|
|
63
98
|
const root = process.cwd()
|
|
64
99
|
const testDir = join(root, options.dir)
|
|
65
100
|
const helpersDir = join(testDir, 'helpers')
|
|
66
101
|
const force = !!options.force
|
|
102
|
+
const port = options.port
|
|
67
103
|
|
|
68
|
-
console.log('\n Tetra Init Tests — Golden Standard E2E\n')
|
|
104
|
+
console.log('\n Tetra Init Tests — Golden Standard E2E v2\n')
|
|
69
105
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
106
|
+
if (!options.ciOnly) {
|
|
107
|
+
// ── 1. Helpers ──
|
|
108
|
+
console.log(' Helpers:')
|
|
109
|
+
copyTemplate('api-client.ts.tmpl', join(helpersDir, 'api-client.ts'), force)
|
|
110
|
+
copyTemplate('test-users.ts.tmpl', join(helpersDir, 'test-users.ts'), force)
|
|
74
111
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
112
|
+
// ── 2. Global setup ──
|
|
113
|
+
console.log('\n Setup:')
|
|
114
|
+
copyTemplate('global-setup.ts.tmpl', join(testDir, 'global-setup.ts'), force)
|
|
115
|
+
writeFile(join(testDir, '.gitignore'), '.e2e-tokens.json\n', force)
|
|
79
116
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
117
|
+
// ── 3. Test files (complete suite) ──
|
|
118
|
+
console.log('\n Test files:')
|
|
119
|
+
copyTemplate('01-auth.test.ts.tmpl', join(testDir, '01-auth.test.ts'), force)
|
|
120
|
+
copyTemplate('02-crud-resources.test.ts.tmpl', join(testDir, '02-crud-resources.test.ts'), force)
|
|
121
|
+
copyTemplate('03-permissions.test.ts.tmpl', join(testDir, '03-permissions.test.ts'), force)
|
|
122
|
+
copyTemplate('04-business-flows.test.ts.tmpl', join(testDir, '04-business-flows.test.ts'), force)
|
|
123
|
+
copyTemplate('05-security.test.ts.tmpl', join(testDir, '05-security.test.ts'), force)
|
|
124
|
+
|
|
125
|
+
// ── 4. Vitest config ──
|
|
126
|
+
console.log('\n Config:')
|
|
127
|
+
writeFile(join(root, 'vitest.config.e2e.ts'), `import { defineConfig } from 'vitest/config';
|
|
83
128
|
|
|
84
129
|
export default defineConfig({
|
|
85
130
|
test: {
|
|
@@ -88,62 +133,136 @@ export default defineConfig({
|
|
|
88
133
|
testTimeout: 30000,
|
|
89
134
|
hookTimeout: 60000,
|
|
90
135
|
sequence: { concurrent: false },
|
|
136
|
+
env: {
|
|
137
|
+
E2E_BASE_URL: process.env.E2E_BASE_URL || 'http://localhost:${port}',
|
|
138
|
+
E2E_ADMIN_EMAIL: process.env.E2E_ADMIN_EMAIL || 'e2e-admin@example.com',
|
|
139
|
+
E2E_PASSWORD: process.env.E2E_PASSWORD || 'TestPass1234',
|
|
140
|
+
RATE_LIMIT_BYPASS_KEY: process.env.RATE_LIMIT_BYPASS_KEY || '',
|
|
141
|
+
},
|
|
91
142
|
},
|
|
92
143
|
});
|
|
93
144
|
`, force)
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* Required env vars (set these in your test runner or CI):
|
|
101
|
-
* E2E_BASE_URL — Backend URL (e.g. http://localhost:3026)
|
|
102
|
-
* RATE_LIMIT_BYPASS_KEY — X-Test-Key value (from .ralph/test-config.yml)
|
|
103
|
-
*
|
|
104
|
-
* This setup should:
|
|
105
|
-
* 1. Create an admin via signup
|
|
106
|
-
* 2. Create a shared resource (track/project/workspace)
|
|
107
|
-
* 3. Invite an elevated user (coach/manager)
|
|
108
|
-
* 4. Invite a basic user (client/member)
|
|
109
|
-
* 5. Store IDs/emails in process.env for test-users.ts
|
|
110
|
-
*/
|
|
146
|
+
// ── 5. npm scripts ──
|
|
147
|
+
console.log('\n Scripts:')
|
|
148
|
+
addNpmScript(root, 'test:e2e', `E2E_BASE_URL=http://localhost:${port} vitest run --config vitest.config.e2e.ts`)
|
|
149
|
+
addNpmScript(root, 'test:e2e:watch', `E2E_BASE_URL=http://localhost:${port} vitest --config vitest.config.e2e.ts`)
|
|
150
|
+
}
|
|
111
151
|
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
// ── 6. CI workflow ──
|
|
153
|
+
console.log('\n CI:')
|
|
154
|
+
const ciPath = join(root, '.github', 'workflows', 'ci.yml')
|
|
155
|
+
if (existsSync(ciPath)) {
|
|
156
|
+
// Check if api-tests job already exists
|
|
157
|
+
const ciContent = readFileSync(ciPath, 'utf-8')
|
|
158
|
+
if (ciContent.includes('api-tests')) {
|
|
159
|
+
console.log(' ⏩ .github/workflows/ci.yml (api-tests job exists)')
|
|
160
|
+
} else {
|
|
161
|
+
console.log(' ⚠️ .github/workflows/ci.yml exists but has NO api-tests job')
|
|
162
|
+
console.log(' Add an api-tests job that runs: npx vitest run --config vitest.config.e2e.ts')
|
|
163
|
+
console.log(' See: tetra-init-tests --ci-only --force')
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
writeFile(ciPath, `name: CI
|
|
114
167
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
168
|
+
on:
|
|
169
|
+
push:
|
|
170
|
+
branches: [main, develop]
|
|
171
|
+
pull_request:
|
|
172
|
+
branches: [main, develop]
|
|
120
173
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
174
|
+
jobs:
|
|
175
|
+
typecheck:
|
|
176
|
+
runs-on: ubuntu-latest
|
|
177
|
+
steps:
|
|
178
|
+
- uses: actions/checkout@v4
|
|
179
|
+
- uses: actions/setup-node@v4
|
|
180
|
+
with:
|
|
181
|
+
node-version: 20
|
|
182
|
+
cache: npm
|
|
183
|
+
- run: npm ci
|
|
184
|
+
- run: cd backend && npx tsc --noEmit
|
|
185
|
+
|
|
186
|
+
frontend-build:
|
|
187
|
+
runs-on: ubuntu-latest
|
|
188
|
+
steps:
|
|
189
|
+
- uses: actions/checkout@v4
|
|
190
|
+
- uses: actions/setup-node@v4
|
|
191
|
+
with:
|
|
192
|
+
node-version: 20
|
|
193
|
+
cache: npm
|
|
194
|
+
- run: npm ci
|
|
195
|
+
- run: cd frontend && npx next build
|
|
196
|
+
env:
|
|
197
|
+
NEXT_PUBLIC_API_URL: http://localhost:${port}
|
|
198
|
+
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
|
|
199
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder
|
|
200
|
+
|
|
201
|
+
api-tests:
|
|
202
|
+
runs-on: ubuntu-latest
|
|
203
|
+
needs: typecheck
|
|
204
|
+
env:
|
|
205
|
+
SUPABASE_URL: \${{ secrets.E2E_SUPABASE_URL }}
|
|
206
|
+
SUPABASE_ANON_KEY: \${{ secrets.E2E_SUPABASE_ANON_KEY }}
|
|
207
|
+
SUPABASE_SERVICE_ROLE_KEY: \${{ secrets.E2E_SUPABASE_SERVICE_ROLE_KEY }}
|
|
208
|
+
SUPABASE_JWT_SECRET: \${{ secrets.E2E_SUPABASE_JWT_SECRET }}
|
|
209
|
+
E2E_ADMIN_EMAIL: \${{ secrets.E2E_ADMIN_EMAIL }}
|
|
210
|
+
E2E_PASSWORD: \${{ secrets.E2E_PASSWORD }}
|
|
211
|
+
steps:
|
|
212
|
+
- uses: actions/checkout@v4
|
|
213
|
+
- uses: actions/setup-node@v4
|
|
214
|
+
with:
|
|
215
|
+
node-version: 20
|
|
216
|
+
cache: npm
|
|
217
|
+
- run: npm ci
|
|
218
|
+
- name: Start backend
|
|
219
|
+
run: |
|
|
220
|
+
cd backend && npx tsx src/index.ts &
|
|
221
|
+
for i in $(seq 1 30); do
|
|
222
|
+
curl -sf http://localhost:${port}/api/health > /dev/null 2>&1 && break
|
|
223
|
+
sleep 2
|
|
224
|
+
done
|
|
225
|
+
curl -sf http://localhost:${port}/api/health || exit 1
|
|
226
|
+
env:
|
|
227
|
+
NODE_ENV: test
|
|
228
|
+
PORT: ${port}
|
|
229
|
+
- name: Run API e2e tests
|
|
230
|
+
run: npx vitest run --config vitest.config.e2e.ts
|
|
231
|
+
env:
|
|
232
|
+
E2E_BASE_URL: http://localhost:${port}
|
|
124
233
|
`, force)
|
|
234
|
+
}
|
|
125
235
|
|
|
126
|
-
//
|
|
236
|
+
// ── 7. Post-deploy workflow ──
|
|
237
|
+
const postDeployPath = join(root, '.github', 'workflows', 'post-deploy-tests.yml')
|
|
238
|
+
if (!existsSync(postDeployPath)) {
|
|
239
|
+
console.log(' 💡 No post-deploy-tests.yml found')
|
|
240
|
+
console.log(' Create one with: tetra-smoke --url https://your-app.railway.app')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Summary ──
|
|
127
244
|
console.log(`
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
245
|
+
─────────────────────────────────────────
|
|
246
|
+
Created: ${filesCreated} files | Skipped: ${filesSkipped} (already exist)
|
|
247
|
+
─────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
Next steps:
|
|
250
|
+
|
|
251
|
+
1. Edit global-setup.ts — set your test user email/password
|
|
252
|
+
2. Edit 02-crud-resources.test.ts — add your FeatureConfig resources
|
|
253
|
+
3. Edit 03-permissions.test.ts — add all protected routes
|
|
254
|
+
4. Edit 04-business-flows.test.ts — add your business flows
|
|
255
|
+
5. Run: npm run test:e2e
|
|
256
|
+
|
|
257
|
+
Then run tetra-test-audit to see what's NOT covered yet.
|
|
258
|
+
|
|
259
|
+
Golden standard layers:
|
|
260
|
+
01-auth Auth: login, session, refresh (universal)
|
|
261
|
+
02-crud-resources CRUD for every resource (from FeatureConfig)
|
|
262
|
+
03-permissions Auth walls for every route (auto-auditable)
|
|
263
|
+
04-business-flows End-to-end user journeys
|
|
264
|
+
05-security Input validation, injection, error format
|
|
265
|
+
06+ Project-specific features
|
|
147
266
|
`)
|
|
148
267
|
})
|
|
149
268
|
|
package/bin/tetra-init.js
CHANGED
|
@@ -430,6 +430,66 @@ async function initQuality(config, options) {
|
|
|
430
430
|
)
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
async function initCi(config, options) {
|
|
434
|
+
console.log('')
|
|
435
|
+
console.log('🔄 Initializing CI workflow...')
|
|
436
|
+
|
|
437
|
+
const workflowDir = join(projectRoot, '.github/workflows')
|
|
438
|
+
if (!existsSync(workflowDir)) {
|
|
439
|
+
mkdirSync(workflowDir, { recursive: true })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Detect project structure
|
|
443
|
+
const hasBackend = existsSync(join(projectRoot, 'backend/package.json'))
|
|
444
|
+
const hasFrontend = existsSync(join(projectRoot, 'frontend/package.json'))
|
|
445
|
+
const hasMigrations = existsSync(join(projectRoot, 'backend/supabase/migrations')) ||
|
|
446
|
+
existsSync(join(projectRoot, 'supabase/migrations'))
|
|
447
|
+
|
|
448
|
+
let workspaces
|
|
449
|
+
if (hasBackend && hasFrontend) {
|
|
450
|
+
workspaces = 'backend,frontend'
|
|
451
|
+
} else if (hasBackend) {
|
|
452
|
+
workspaces = 'backend'
|
|
453
|
+
} else {
|
|
454
|
+
workspaces = '.'
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let withLines = [` workspaces: "${workspaces}"`]
|
|
458
|
+
if (!hasFrontend) withLines.push(' skip-frontend: true')
|
|
459
|
+
if (!hasMigrations) withLines.push(' skip-migration-lint: true')
|
|
460
|
+
|
|
461
|
+
const workflowContent = `name: PR Quality Gate
|
|
462
|
+
|
|
463
|
+
on:
|
|
464
|
+
push:
|
|
465
|
+
branches: [main]
|
|
466
|
+
paths-ignore:
|
|
467
|
+
- '*.md'
|
|
468
|
+
- 'docs/**'
|
|
469
|
+
- '.ralph/**'
|
|
470
|
+
- 'LICENSE'
|
|
471
|
+
pull_request:
|
|
472
|
+
branches: [main]
|
|
473
|
+
paths-ignore:
|
|
474
|
+
- '*.md'
|
|
475
|
+
- 'docs/**'
|
|
476
|
+
- '.ralph/**'
|
|
477
|
+
- 'LICENSE'
|
|
478
|
+
|
|
479
|
+
concurrency:
|
|
480
|
+
group: quality-\${{ github.ref }}
|
|
481
|
+
cancel-in-progress: true
|
|
482
|
+
|
|
483
|
+
jobs:
|
|
484
|
+
quality:
|
|
485
|
+
uses: mralbertzwolle/tetra/.github/workflows/pr-quality.yml@main
|
|
486
|
+
with:
|
|
487
|
+
${withLines.join('\n')}
|
|
488
|
+
`
|
|
489
|
+
|
|
490
|
+
writeIfMissing(join(workflowDir, 'quality.yml'), workflowContent, options)
|
|
491
|
+
}
|
|
492
|
+
|
|
433
493
|
function checkCompleteness() {
|
|
434
494
|
console.log('')
|
|
435
495
|
console.log('🔎 Checking project completeness...')
|
|
@@ -441,6 +501,7 @@ function checkCompleteness() {
|
|
|
441
501
|
{ path: 'doppler.yaml', category: 'root', required: true },
|
|
442
502
|
{ path: 'CLAUDE.md', category: 'root', required: true },
|
|
443
503
|
{ path: '.tetra-quality.json', category: 'root', required: false },
|
|
504
|
+
{ path: '.github/workflows/quality.yml', category: 'root', required: true },
|
|
444
505
|
{ path: '.gitignore', category: 'root', required: true },
|
|
445
506
|
|
|
446
507
|
// .ralph/ files
|
|
@@ -524,7 +585,7 @@ program
|
|
|
524
585
|
.name('tetra-init')
|
|
525
586
|
.description('Initialize a Tetra project with all required config files')
|
|
526
587
|
.version('1.0.0')
|
|
527
|
-
.argument('[component]', 'Component to init: ralph, quality, check, or all (default)')
|
|
588
|
+
.argument('[component]', 'Component to init: ralph, quality, ci, check, or all (default)')
|
|
528
589
|
.option('-n, --name <name>', 'Project name (skips interactive prompt)')
|
|
529
590
|
.option('-d, --description <desc>', 'Project description')
|
|
530
591
|
.option('--backend-port <port>', 'Backend port number', parseInt)
|
|
@@ -573,7 +634,7 @@ program
|
|
|
573
634
|
console.log(` Frontend: localhost:${config.frontendPort}`)
|
|
574
635
|
|
|
575
636
|
const components = component === 'all' || !component
|
|
576
|
-
? ['ralph', 'quality']
|
|
637
|
+
? ['ralph', 'quality', 'ci']
|
|
577
638
|
: [component]
|
|
578
639
|
|
|
579
640
|
for (const comp of components) {
|
|
@@ -584,9 +645,12 @@ program
|
|
|
584
645
|
case 'quality':
|
|
585
646
|
await initQuality(config, options)
|
|
586
647
|
break
|
|
648
|
+
case 'ci':
|
|
649
|
+
await initCi(config, options)
|
|
650
|
+
break
|
|
587
651
|
default:
|
|
588
652
|
console.log(`Unknown component: ${comp}`)
|
|
589
|
-
console.log('Available: ralph, quality, check, or all (default)')
|
|
653
|
+
console.log('Available: ralph, quality, ci, check, or all (default)')
|
|
590
654
|
}
|
|
591
655
|
}
|
|
592
656
|
|
package/bin/tetra-setup.js
CHANGED
|
@@ -262,47 +262,66 @@ async function setupCi(options) {
|
|
|
262
262
|
mkdirSync(workflowDir, { recursive: true })
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Detect project structure
|
|
266
|
+
const hasBackend = existsSync(join(projectRoot, 'backend/package.json'))
|
|
267
|
+
const hasFrontend = existsSync(join(projectRoot, 'frontend/package.json'))
|
|
268
|
+
const hasMigrations = existsSync(join(projectRoot, 'backend/supabase/migrations')) ||
|
|
269
|
+
existsSync(join(projectRoot, 'supabase/migrations'))
|
|
270
|
+
|
|
271
|
+
// Determine workspaces
|
|
272
|
+
let workspaces
|
|
273
|
+
if (hasBackend && hasFrontend) {
|
|
274
|
+
workspaces = 'backend,frontend'
|
|
275
|
+
} else if (hasBackend) {
|
|
276
|
+
workspaces = 'backend'
|
|
277
|
+
} else {
|
|
278
|
+
workspaces = '.'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const skipFrontend = !hasFrontend
|
|
282
|
+
const skipMigrationLint = !hasMigrations
|
|
283
|
+
|
|
284
|
+
// Build with block for inputs
|
|
285
|
+
let withBlock = ` workspaces: "${workspaces}"`
|
|
286
|
+
if (skipFrontend) {
|
|
287
|
+
withBlock += `\n skip-frontend: true`
|
|
288
|
+
}
|
|
289
|
+
if (skipMigrationLint) {
|
|
290
|
+
withBlock += `\n skip-migration-lint: true`
|
|
291
|
+
}
|
|
292
|
+
|
|
265
293
|
const workflowPath = join(workflowDir, 'quality.yml')
|
|
266
294
|
if (!existsSync(workflowPath) || options.force) {
|
|
267
|
-
const workflowContent = `name: Quality
|
|
295
|
+
const workflowContent = `name: PR Quality Gate
|
|
268
296
|
|
|
269
297
|
on:
|
|
270
298
|
push:
|
|
271
|
-
branches: [main
|
|
299
|
+
branches: [main]
|
|
300
|
+
paths-ignore:
|
|
301
|
+
- '*.md'
|
|
302
|
+
- 'docs/**'
|
|
303
|
+
- '.ralph/**'
|
|
304
|
+
- 'LICENSE'
|
|
272
305
|
pull_request:
|
|
273
|
-
branches: [main
|
|
306
|
+
branches: [main]
|
|
307
|
+
paths-ignore:
|
|
308
|
+
- '*.md'
|
|
309
|
+
- 'docs/**'
|
|
310
|
+
- '.ralph/**'
|
|
311
|
+
- 'LICENSE'
|
|
312
|
+
|
|
313
|
+
concurrency:
|
|
314
|
+
group: quality-\${{ github.ref }}
|
|
315
|
+
cancel-in-progress: true
|
|
274
316
|
|
|
275
317
|
jobs:
|
|
276
318
|
quality:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
steps:
|
|
281
|
-
- name: Checkout code
|
|
282
|
-
uses: actions/checkout@v4
|
|
283
|
-
|
|
284
|
-
- name: Setup Node.js
|
|
285
|
-
uses: actions/setup-node@v4
|
|
286
|
-
with:
|
|
287
|
-
node-version: '20'
|
|
288
|
-
cache: 'npm'
|
|
289
|
-
|
|
290
|
-
- name: Install dependencies
|
|
291
|
-
run: npm ci
|
|
292
|
-
|
|
293
|
-
- name: Run Tetra Quality Audit
|
|
294
|
-
run: npx tetra-audit --ci
|
|
295
|
-
|
|
296
|
-
- name: Upload results
|
|
297
|
-
if: always()
|
|
298
|
-
uses: actions/upload-artifact@v4
|
|
299
|
-
with:
|
|
300
|
-
name: quality-report
|
|
301
|
-
path: quality-report.json
|
|
302
|
-
retention-days: 7
|
|
319
|
+
uses: mralbertzwolle/tetra/.github/workflows/pr-quality.yml@main
|
|
320
|
+
with:
|
|
321
|
+
${withBlock}
|
|
303
322
|
`
|
|
304
323
|
writeFileSync(workflowPath, workflowContent)
|
|
305
|
-
console.log(
|
|
324
|
+
console.log(` ✅ Created .github/workflows/quality.yml (${workspaces}, frontend=${!skipFrontend}, migrations=${!skipMigrationLint})`)
|
|
306
325
|
} else {
|
|
307
326
|
console.log(' ⏭️ Workflow already exists (use --force to overwrite)')
|
|
308
327
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Test Audit — Discover test coverage gaps across a Tetra project.
|
|
5
|
+
*
|
|
6
|
+
* Scans FeatureConfigs, routes, frontend pages, and test files to find:
|
|
7
|
+
* - Features without CRUD tests
|
|
8
|
+
* - API endpoints without auth wall tests
|
|
9
|
+
* - Frontend pages without e2e specs
|
|
10
|
+
* - CI pipeline gaps
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* tetra-test-audit # Audit current project
|
|
14
|
+
* tetra-test-audit --path /path/to/project # Audit specific project
|
|
15
|
+
* tetra-test-audit --json # JSON output for CI
|
|
16
|
+
* tetra-test-audit --ci # GitHub Actions annotations
|
|
17
|
+
* tetra-test-audit --strict # Fail on any gap
|
|
18
|
+
*
|
|
19
|
+
* Exit codes:
|
|
20
|
+
* 0 = all critical checks pass (or no gaps in --strict mode)
|
|
21
|
+
* 1 = gaps found
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { program } from 'commander'
|
|
25
|
+
import chalk from 'chalk'
|
|
26
|
+
import {
|
|
27
|
+
runTestCoverageAudit,
|
|
28
|
+
formatReport,
|
|
29
|
+
formatReportJSON,
|
|
30
|
+
formatCIAnnotations,
|
|
31
|
+
} from '../lib/audits/test-coverage-audit.js'
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.name('tetra-test-audit')
|
|
35
|
+
.description('Audit test coverage gaps across a Tetra project')
|
|
36
|
+
.version('1.0.0')
|
|
37
|
+
.option('--path <dir>', 'Project root directory (default: cwd)')
|
|
38
|
+
.option('--json', 'JSON output for CI')
|
|
39
|
+
.option('--ci', 'GitHub Actions annotations for failures')
|
|
40
|
+
.option('--strict', 'Fail on any gap (default: only fail on critical gaps like missing auth tests)')
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
try {
|
|
43
|
+
const projectRoot = options.path || process.cwd()
|
|
44
|
+
|
|
45
|
+
if (!options.json) {
|
|
46
|
+
console.log(chalk.gray('\n Scanning project...'))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const report = await runTestCoverageAudit(projectRoot)
|
|
50
|
+
|
|
51
|
+
// Output
|
|
52
|
+
if (options.json) {
|
|
53
|
+
console.log(formatReportJSON(report))
|
|
54
|
+
} else {
|
|
55
|
+
console.log(formatReport(report, chalk))
|
|
56
|
+
|
|
57
|
+
if (options.ci) {
|
|
58
|
+
const annotations = formatCIAnnotations(report)
|
|
59
|
+
if (annotations) {
|
|
60
|
+
console.log(annotations)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Exit code
|
|
66
|
+
const { summary } = report
|
|
67
|
+
if (options.strict) {
|
|
68
|
+
// Strict: fail on any gap
|
|
69
|
+
process.exit(summary.totalGaps > 0 ? 1 : 0)
|
|
70
|
+
} else {
|
|
71
|
+
// Default: only fail on critical gaps (missing auth wall tests)
|
|
72
|
+
process.exit(summary.criticalGaps > 0 ? 1 : 0)
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
76
|
+
if (!options.json) {
|
|
77
|
+
console.error(chalk.gray(` ${err.stack}`))
|
|
78
|
+
}
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
program.parse()
|