elasticdash-test 0.1.18 → 0.1.19-alpha-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 +167 -0
- package/dist/ci/api-client.d.ts +23 -0
- package/dist/ci/api-client.d.ts.map +1 -0
- package/dist/ci/api-client.js +64 -0
- package/dist/ci/api-client.js.map +1 -0
- package/dist/ci/executor.d.ts +13 -0
- package/dist/ci/executor.d.ts.map +1 -0
- package/dist/ci/executor.js +539 -0
- package/dist/ci/executor.js.map +1 -0
- package/dist/ci/git-info.d.ts +13 -0
- package/dist/ci/git-info.d.ts.map +1 -0
- package/dist/ci/git-info.js +52 -0
- package/dist/ci/git-info.js.map +1 -0
- package/dist/ci/index.d.ts +6 -0
- package/dist/ci/index.d.ts.map +1 -0
- package/dist/ci/index.js +4 -0
- package/dist/ci/index.js.map +1 -0
- package/dist/ci/runner.d.ts +3 -0
- package/dist/ci/runner.d.ts.map +1 -0
- package/dist/ci/runner.js +178 -0
- package/dist/ci/runner.js.map +1 -0
- package/dist/ci/types.d.ts +108 -0
- package/dist/ci/types.d.ts.map +1 -0
- package/dist/ci/types.js +3 -0
- package/dist/ci/types.js.map +1 -0
- package/dist/cli.js +40 -0
- package/dist/cli.js.map +1 -1
- package/dist/dashboard-server.d.ts.map +1 -1
- package/dist/dashboard-server.js +37 -3
- package/dist/dashboard-server.js.map +1 -1
- package/dist/index.cjs +951 -123
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/portal-server.d.ts.map +1 -1
- package/dist/portal-server.js +15 -1
- package/dist/portal-server.js.map +1 -1
- package/dist/telemetry-batcher.d.ts.map +1 -1
- package/dist/telemetry-batcher.js +5 -1
- package/dist/telemetry-batcher.js.map +1 -1
- package/package.json +1 -1
- package/src/ci/api-client.ts +87 -0
- package/src/ci/executor.ts +668 -0
- package/src/ci/git-info.ts +66 -0
- package/src/ci/index.ts +5 -0
- package/src/ci/runner.ts +198 -0
- package/src/ci/types.ts +115 -0
- package/src/cli.ts +55 -0
- package/src/dashboard-server.ts +37 -3
- package/src/index.ts +7 -0
- package/src/portal-server.ts +15 -1
- package/src/telemetry-batcher.ts +5 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ─── CI Environment Auto-Detection ──────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface GitInfo {
|
|
4
|
+
branch?: string
|
|
5
|
+
commit?: string
|
|
6
|
+
commitMessage?: string
|
|
7
|
+
prNumber?: number
|
|
8
|
+
prUrl?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Auto-detect git info from CI environment variables.
|
|
13
|
+
* Supports GitHub Actions, GitLab CI, and generic CI.
|
|
14
|
+
*/
|
|
15
|
+
export function detectGitInfo(): GitInfo {
|
|
16
|
+
const env = process.env
|
|
17
|
+
|
|
18
|
+
// GitHub Actions
|
|
19
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
20
|
+
const prNumber = env.GITHUB_EVENT_NAME === 'pull_request'
|
|
21
|
+
? parseInt(env.GITHUB_REF?.match(/refs\/pull\/(\d+)/)?.[1] ?? '', 10) || undefined
|
|
22
|
+
: undefined
|
|
23
|
+
|
|
24
|
+
const repo = env.GITHUB_REPOSITORY // e.g. "owner/repo"
|
|
25
|
+
const prUrl = prNumber && repo
|
|
26
|
+
? `https://github.com/${repo}/pull/${prNumber}`
|
|
27
|
+
: undefined
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
|
|
31
|
+
commit: env.GITHUB_SHA,
|
|
32
|
+
commitMessage: env.GITHUB_COMMIT_MESSAGE,
|
|
33
|
+
prNumber,
|
|
34
|
+
prUrl,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GitLab CI
|
|
39
|
+
if (env.GITLAB_CI === 'true') {
|
|
40
|
+
const prNumber = env.CI_MERGE_REQUEST_IID
|
|
41
|
+
? parseInt(env.CI_MERGE_REQUEST_IID, 10) || undefined
|
|
42
|
+
: undefined
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
branch: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || env.CI_COMMIT_BRANCH,
|
|
46
|
+
commit: env.CI_COMMIT_SHA,
|
|
47
|
+
commitMessage: env.CI_COMMIT_MESSAGE,
|
|
48
|
+
prNumber,
|
|
49
|
+
prUrl: env.CI_MERGE_REQUEST_PROJECT_URL && prNumber
|
|
50
|
+
? `${env.CI_MERGE_REQUEST_PROJECT_URL}/-/merge_requests/${prNumber}`
|
|
51
|
+
: undefined,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Generic CI (CircleCI, Travis, Bitbucket Pipelines, etc.)
|
|
56
|
+
return {
|
|
57
|
+
branch: env.CIRCLE_BRANCH || env.TRAVIS_BRANCH || env.BITBUCKET_BRANCH || env.CI_BRANCH,
|
|
58
|
+
commit: env.CIRCLE_SHA1 || env.TRAVIS_COMMIT || env.BITBUCKET_COMMIT || env.CI_COMMIT_SHA,
|
|
59
|
+
commitMessage: env.CI_COMMIT_MESSAGE,
|
|
60
|
+
prNumber: env.CIRCLE_PULL_REQUEST
|
|
61
|
+
? parseInt(env.CIRCLE_PULL_REQUEST.split('/').pop() ?? '', 10) || undefined
|
|
62
|
+
: env.TRAVIS_PULL_REQUEST && env.TRAVIS_PULL_REQUEST !== 'false'
|
|
63
|
+
? parseInt(env.TRAVIS_PULL_REQUEST, 10) || undefined
|
|
64
|
+
: undefined,
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/ci/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { runCI } from './runner.js'
|
|
2
|
+
export { fetchTestGroups, submitTestRun, createBatch } from './api-client.js'
|
|
3
|
+
export { detectGitInfo } from './git-info.js'
|
|
4
|
+
export type { CIRunConfig, CIRunSummary, CITestResult, CISingleRunResult, CIExpectationResult } from './types.js'
|
|
5
|
+
export type { GitInfo } from './git-info.js'
|
package/src/ci/runner.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { fetchTestGroups, submitTestRun, createBatch } from './api-client.js'
|
|
3
|
+
import { executeTest } from './executor.js'
|
|
4
|
+
import { detectGitInfo } from './git-info.js'
|
|
5
|
+
import type { CIRunConfig, CIRunSummary, CITestResult } from './types.js'
|
|
6
|
+
|
|
7
|
+
// ─── CI Runner ───────────────────────────────────────────────
|
|
8
|
+
// Orchestrates the full CI/CD test flow:
|
|
9
|
+
// 1. Fetch test groups from backend
|
|
10
|
+
// 2. Execute each test
|
|
11
|
+
// 3. Submit results
|
|
12
|
+
// 4. Create batch
|
|
13
|
+
// 5. Print summary
|
|
14
|
+
|
|
15
|
+
export async function runCI(config: CIRunConfig): Promise<CIRunSummary> {
|
|
16
|
+
const { serverUrl, apiKey } = config
|
|
17
|
+
const cwd = process.cwd()
|
|
18
|
+
|
|
19
|
+
// Merge explicit git info with auto-detected CI env
|
|
20
|
+
const detected = detectGitInfo()
|
|
21
|
+
const gitBranch = config.gitBranch || detected.branch
|
|
22
|
+
const gitCommit = config.gitCommit || detected.commit
|
|
23
|
+
const gitCommitMessage = config.gitCommitMessage || detected.commitMessage
|
|
24
|
+
const gitPrNumber = config.gitPrNumber || detected.prNumber
|
|
25
|
+
const gitPrUrl = config.gitPrUrl || detected.prUrl
|
|
26
|
+
const triggeredBy = config.triggeredBy || 'ci'
|
|
27
|
+
|
|
28
|
+
const overallStart = Date.now()
|
|
29
|
+
|
|
30
|
+
// Step 1: Fetch test groups
|
|
31
|
+
console.log(chalk.cyan('\n[elasticdash ci] Fetching test groups...'))
|
|
32
|
+
|
|
33
|
+
const testGroups = await fetchTestGroups(serverUrl, apiKey, {
|
|
34
|
+
workflowName: config.workflowName,
|
|
35
|
+
tags: config.tags,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if (testGroups.length === 0) {
|
|
39
|
+
console.log(chalk.yellow('[elasticdash ci] No active test groups found.'))
|
|
40
|
+
return {
|
|
41
|
+
total: 0, passed: 0, failed: 0, skipped: 0,
|
|
42
|
+
durationMs: Date.now() - overallStart,
|
|
43
|
+
batchId: null,
|
|
44
|
+
results: [],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const totalTests = testGroups.reduce((sum, g) => sum + g.tests.length, 0)
|
|
49
|
+
console.log(chalk.cyan(`[elasticdash ci] Found ${testGroups.length} test group(s), ${totalTests} test(s) total.\n`))
|
|
50
|
+
|
|
51
|
+
// Step 2 & 3: Execute and submit
|
|
52
|
+
const allResults: CITestResult[] = []
|
|
53
|
+
const runIds: number[] = []
|
|
54
|
+
|
|
55
|
+
for (const group of testGroups) {
|
|
56
|
+
console.log(chalk.white.bold(` ${group.name}`) + chalk.gray(` (${group.tests.length} tests)`))
|
|
57
|
+
|
|
58
|
+
for (const test of group.tests) {
|
|
59
|
+
const testLabel = test.name || `${test.test_type}:${test.target_step_name || 'unnamed'}`
|
|
60
|
+
process.stdout.write(chalk.gray(` ${testLabel} ... `))
|
|
61
|
+
|
|
62
|
+
const testStart = Date.now()
|
|
63
|
+
let testResult: CITestResult
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const execution = await executeTest(test, cwd)
|
|
67
|
+
|
|
68
|
+
// Submit result to backend
|
|
69
|
+
const payload = {
|
|
70
|
+
testGroupTestId: test.id,
|
|
71
|
+
triggeredBy,
|
|
72
|
+
passed: execution.passed,
|
|
73
|
+
summary: execution.passed ? 'Passed' : 'Failed',
|
|
74
|
+
gitBranch, gitCommit, gitCommitMessage, gitPrNumber, gitPrUrl,
|
|
75
|
+
singleRuns: execution.singleRuns,
|
|
76
|
+
expectationResults: execution.expectationResults,
|
|
77
|
+
startedAt: new Date(testStart).toISOString(),
|
|
78
|
+
completedAt: new Date().toISOString(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let runId: number | null = null
|
|
82
|
+
try {
|
|
83
|
+
const submitted = await submitTestRun(serverUrl, apiKey, group.id, payload)
|
|
84
|
+
runId = submitted.id
|
|
85
|
+
runIds.push(runId)
|
|
86
|
+
} catch (submitErr) {
|
|
87
|
+
// Non-fatal: log but don't fail the test
|
|
88
|
+
console.error(chalk.yellow(`\n [warn] Failed to submit result: ${submitErr instanceof Error ? submitErr.message : String(submitErr)}`))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
testResult = {
|
|
92
|
+
testGroupId: group.id,
|
|
93
|
+
testGroupName: group.name,
|
|
94
|
+
testId: test.id,
|
|
95
|
+
testName: test.name,
|
|
96
|
+
testType: test.test_type,
|
|
97
|
+
passed: execution.passed,
|
|
98
|
+
runId,
|
|
99
|
+
singleRuns: execution.singleRuns,
|
|
100
|
+
expectationResults: execution.expectationResults,
|
|
101
|
+
durationMs: execution.durationMs,
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
testResult = {
|
|
105
|
+
testGroupId: group.id,
|
|
106
|
+
testGroupName: group.name,
|
|
107
|
+
testId: test.id,
|
|
108
|
+
testName: test.name,
|
|
109
|
+
testType: test.test_type,
|
|
110
|
+
passed: false,
|
|
111
|
+
runId: null,
|
|
112
|
+
singleRuns: [],
|
|
113
|
+
expectationResults: [],
|
|
114
|
+
error: err instanceof Error ? err.message : String(err),
|
|
115
|
+
durationMs: Date.now() - testStart,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
allResults.push(testResult)
|
|
120
|
+
|
|
121
|
+
// Print result
|
|
122
|
+
const durationStr = chalk.gray(`(${testResult.durationMs}ms)`)
|
|
123
|
+
if (testResult.passed) {
|
|
124
|
+
console.log(chalk.green('PASS') + ` ${durationStr}`)
|
|
125
|
+
} else {
|
|
126
|
+
console.log(chalk.red('FAIL') + ` ${durationStr}`)
|
|
127
|
+
if (testResult.error) {
|
|
128
|
+
console.log(chalk.red(` ${testResult.error}`))
|
|
129
|
+
}
|
|
130
|
+
// Show failed expectations
|
|
131
|
+
for (const exp of testResult.expectationResults) {
|
|
132
|
+
if (!exp.passed) {
|
|
133
|
+
console.log(chalk.red(` [${exp.type}] ${exp.detail || 'failed'}`))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log() // blank line between groups
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Step 4: Create batch
|
|
143
|
+
let batchId: number | null = null
|
|
144
|
+
if (runIds.length > 0) {
|
|
145
|
+
try {
|
|
146
|
+
const passedCount = allResults.filter((r) => r.passed).length
|
|
147
|
+
const allPassed = passedCount === allResults.length
|
|
148
|
+
|
|
149
|
+
const batch = await createBatch(serverUrl, apiKey, {
|
|
150
|
+
testGroupRunIds: runIds,
|
|
151
|
+
status: allPassed ? 'success' : 'failed',
|
|
152
|
+
passed: allPassed,
|
|
153
|
+
summary: `CI: ${passedCount}/${allResults.length} passed`,
|
|
154
|
+
gitBranch, gitCommit,
|
|
155
|
+
startedAt: new Date(overallStart).toISOString(),
|
|
156
|
+
completedAt: new Date().toISOString(),
|
|
157
|
+
})
|
|
158
|
+
batchId = batch.id
|
|
159
|
+
} catch {
|
|
160
|
+
// Non-fatal
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Step 5: Print summary
|
|
165
|
+
const passed = allResults.filter((r) => r.passed).length
|
|
166
|
+
const failed = allResults.filter((r) => !r.passed).length
|
|
167
|
+
const durationMs = Date.now() - overallStart
|
|
168
|
+
|
|
169
|
+
console.log(chalk.white.bold('─'.repeat(50)))
|
|
170
|
+
console.log(chalk.white.bold('Summary'))
|
|
171
|
+
console.log(chalk.white.bold('─'.repeat(50)))
|
|
172
|
+
console.log(` Total: ${allResults.length}`)
|
|
173
|
+
console.log(` ${chalk.green(`Passed: ${passed}`)}`)
|
|
174
|
+
if (failed > 0) {
|
|
175
|
+
console.log(` ${chalk.red(`Failed: ${failed}`)}`)
|
|
176
|
+
}
|
|
177
|
+
console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`)
|
|
178
|
+
if (batchId) {
|
|
179
|
+
console.log(` Batch ID: ${batchId}`)
|
|
180
|
+
}
|
|
181
|
+
console.log(chalk.white.bold('─'.repeat(50)))
|
|
182
|
+
|
|
183
|
+
if (failed > 0) {
|
|
184
|
+
console.log(chalk.red(`\n[elasticdash ci] ${failed} test(s) failed.\n`))
|
|
185
|
+
} else {
|
|
186
|
+
console.log(chalk.green(`\n[elasticdash ci] All tests passed.\n`))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
total: allResults.length,
|
|
191
|
+
passed,
|
|
192
|
+
failed,
|
|
193
|
+
skipped: 0,
|
|
194
|
+
durationMs,
|
|
195
|
+
batchId,
|
|
196
|
+
results: allResults,
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/ci/types.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ─── CI Runner Types ─────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface CIRunConfig {
|
|
4
|
+
serverUrl: string
|
|
5
|
+
apiKey: string
|
|
6
|
+
workflowName?: string
|
|
7
|
+
tags?: string[]
|
|
8
|
+
triggeredBy?: 'ci' | 'api'
|
|
9
|
+
gitBranch?: string
|
|
10
|
+
gitCommit?: string
|
|
11
|
+
gitCommitMessage?: string
|
|
12
|
+
gitPrNumber?: number
|
|
13
|
+
gitPrUrl?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CITestResult {
|
|
17
|
+
testGroupId: number
|
|
18
|
+
testGroupName: string
|
|
19
|
+
testId: number
|
|
20
|
+
testName: string | null
|
|
21
|
+
testType: string
|
|
22
|
+
passed: boolean
|
|
23
|
+
runId: number | null
|
|
24
|
+
singleRuns: CISingleRunResult[]
|
|
25
|
+
expectationResults: CIExpectationResult[]
|
|
26
|
+
error?: string
|
|
27
|
+
durationMs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CISingleRunResult {
|
|
31
|
+
runIndex: number
|
|
32
|
+
passed: boolean
|
|
33
|
+
durationMs: number
|
|
34
|
+
inputTokens: number
|
|
35
|
+
outputTokens: number
|
|
36
|
+
totalTokens: number
|
|
37
|
+
output: unknown
|
|
38
|
+
trace: unknown
|
|
39
|
+
error?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CIExpectationResult {
|
|
43
|
+
expectationId: number
|
|
44
|
+
type: string
|
|
45
|
+
passed: boolean
|
|
46
|
+
detail?: string
|
|
47
|
+
perRun?: Record<number, { passed: boolean; detail?: string }>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CIRunSummary {
|
|
51
|
+
total: number
|
|
52
|
+
passed: number
|
|
53
|
+
failed: number
|
|
54
|
+
skipped: number
|
|
55
|
+
durationMs: number
|
|
56
|
+
batchId: number | null
|
|
57
|
+
results: CITestResult[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── API Response Types ─────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface APITestGroup {
|
|
63
|
+
id: number
|
|
64
|
+
name: string
|
|
65
|
+
description: string | null
|
|
66
|
+
project_id: number
|
|
67
|
+
workflow_name: string
|
|
68
|
+
trace_file: unknown
|
|
69
|
+
status: string
|
|
70
|
+
tags: string[]
|
|
71
|
+
tests: APITestGroupTest[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface APITestGroupTest {
|
|
75
|
+
id: number
|
|
76
|
+
test_group_id: number
|
|
77
|
+
name: string | null
|
|
78
|
+
description: string | null
|
|
79
|
+
test_type: 'single-step' | 'full-flow'
|
|
80
|
+
target_step_index: number | null
|
|
81
|
+
target_step_type: string | null
|
|
82
|
+
target_step_name: string | null
|
|
83
|
+
mock_input: unknown
|
|
84
|
+
workflow_input: unknown
|
|
85
|
+
frozen_events: unknown[]
|
|
86
|
+
tool_mocks: Record<string, unknown>
|
|
87
|
+
prompt_mocks: Record<string, string>
|
|
88
|
+
run_count: number
|
|
89
|
+
pass_threshold: string
|
|
90
|
+
timeout_ms: number
|
|
91
|
+
sort_order: number
|
|
92
|
+
expectations: APIExpectation[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface APIExpectation {
|
|
96
|
+
id: number
|
|
97
|
+
test_group_test_id: number
|
|
98
|
+
type: string
|
|
99
|
+
judge_prompt: string | null
|
|
100
|
+
judge_model: string | null
|
|
101
|
+
judge_provider: string | null
|
|
102
|
+
judge_score_threshold: number | null
|
|
103
|
+
max_total_tokens: number | null
|
|
104
|
+
max_tokens_per_run: number | null
|
|
105
|
+
max_duration_ms: number | null
|
|
106
|
+
max_total_duration_ms: number | null
|
|
107
|
+
contains_text: string | null
|
|
108
|
+
not_contains_text: string | null
|
|
109
|
+
case_insensitive: boolean
|
|
110
|
+
json_schema: unknown
|
|
111
|
+
similarity_threshold: number | null
|
|
112
|
+
required_tools: string[]
|
|
113
|
+
forbidden_tools: string[]
|
|
114
|
+
tool_call_rules: unknown
|
|
115
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -346,6 +346,61 @@ async function bootstrap(): Promise<void> {
|
|
|
346
346
|
process.once('SIGTERM', cleanup)
|
|
347
347
|
})
|
|
348
348
|
|
|
349
|
+
// elasticdash ci
|
|
350
|
+
program
|
|
351
|
+
.command('ci')
|
|
352
|
+
.description('Run CI/CD tests — fetch test groups from backend, execute tests, submit results')
|
|
353
|
+
.option('--server <url>', 'ElasticDash backend API URL', process.env.ELASTICDASH_API_URL)
|
|
354
|
+
.option('--api-key <key>', 'Project API key', process.env.ELASTICDASH_API_KEY)
|
|
355
|
+
.option('--workflow <name>', 'Filter test groups by workflow name')
|
|
356
|
+
.option('--tags <tags>', 'Filter test groups by tags (comma-separated)')
|
|
357
|
+
.option('--triggered-by <source>', 'Trigger source', 'ci')
|
|
358
|
+
.option('--git-branch <branch>', 'Git branch name')
|
|
359
|
+
.option('--git-commit <sha>', 'Git commit SHA')
|
|
360
|
+
.option('--git-commit-message <msg>', 'Git commit message')
|
|
361
|
+
.option('--git-pr-number <number>', 'Pull request number', (v) => Number(v))
|
|
362
|
+
.option('--git-pr-url <url>', 'Pull request URL')
|
|
363
|
+
.action(async (options: {
|
|
364
|
+
server?: string
|
|
365
|
+
apiKey?: string
|
|
366
|
+
workflow?: string
|
|
367
|
+
tags?: string
|
|
368
|
+
triggeredBy?: string
|
|
369
|
+
gitBranch?: string
|
|
370
|
+
gitCommit?: string
|
|
371
|
+
gitCommitMessage?: string
|
|
372
|
+
gitPrNumber?: number
|
|
373
|
+
gitPrUrl?: string
|
|
374
|
+
}) => {
|
|
375
|
+
const serverUrl = options.server
|
|
376
|
+
if (!serverUrl) {
|
|
377
|
+
console.error('[elasticdash] Error: --server or ELASTICDASH_API_URL is required')
|
|
378
|
+
process.exit(1)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const apiKey = options.apiKey
|
|
382
|
+
if (!apiKey) {
|
|
383
|
+
console.error('[elasticdash] Error: --api-key or ELASTICDASH_API_KEY is required')
|
|
384
|
+
process.exit(1)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const { runCI } = await import('./ci/runner.js')
|
|
388
|
+
const summary = await runCI({
|
|
389
|
+
serverUrl,
|
|
390
|
+
apiKey,
|
|
391
|
+
workflowName: options.workflow,
|
|
392
|
+
tags: options.tags ? options.tags.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
|
393
|
+
triggeredBy: (options.triggeredBy as 'ci' | 'api') || 'ci',
|
|
394
|
+
gitBranch: options.gitBranch,
|
|
395
|
+
gitCommit: options.gitCommit,
|
|
396
|
+
gitCommitMessage: options.gitCommitMessage,
|
|
397
|
+
gitPrNumber: options.gitPrNumber,
|
|
398
|
+
gitPrUrl: options.gitPrUrl,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
process.exit(summary.failed > 0 ? 1 : 0)
|
|
402
|
+
})
|
|
403
|
+
|
|
349
404
|
// elasticdash portal
|
|
350
405
|
program
|
|
351
406
|
.command('portal')
|
package/src/dashboard-server.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
5
|
import { pathToFileURL, fileURLToPath } from 'url'
|
|
6
|
-
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { randomUUID, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
7
7
|
import { callProviderLLM } from './matchers/index.js'
|
|
8
8
|
import { startTraceSession } from './trace-adapter/context.js'
|
|
9
9
|
import type { WorkflowTrace, WorkflowEvent } from './capture/event.js'
|
|
@@ -144,18 +144,52 @@ interface ValidateWorkflowResult {
|
|
|
144
144
|
error?: string
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// ─── Snapshot Encryption (opt-in via ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY) ────
|
|
148
|
+
|
|
149
|
+
const SNAPSHOT_ENCRYPTION_KEY = process.env.ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY
|
|
150
|
+
? Buffer.from(process.env.ELASTICDASH_SNAPSHOT_ENCRYPTION_KEY, 'hex')
|
|
151
|
+
: null
|
|
152
|
+
|
|
153
|
+
function encryptSnapshot(data: string): string {
|
|
154
|
+
if (!SNAPSHOT_ENCRYPTION_KEY || SNAPSHOT_ENCRYPTION_KEY.length !== 32) return data
|
|
155
|
+
const iv = randomBytes(16)
|
|
156
|
+
const cipher = createCipheriv('aes-256-gcm', SNAPSHOT_ENCRYPTION_KEY, iv)
|
|
157
|
+
const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()])
|
|
158
|
+
const authTag = cipher.getAuthTag()
|
|
159
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function decryptSnapshot(data: string): string {
|
|
163
|
+
if (!SNAPSHOT_ENCRYPTION_KEY || SNAPSHOT_ENCRYPTION_KEY.length !== 32) return data
|
|
164
|
+
const parts = data.split(':')
|
|
165
|
+
if (parts.length !== 3) return data // not encrypted, return as-is
|
|
166
|
+
const [ivHex, authTagHex, encryptedHex] = parts
|
|
167
|
+
try {
|
|
168
|
+
const iv = Buffer.from(ivHex, 'hex')
|
|
169
|
+
const authTag = Buffer.from(authTagHex, 'hex')
|
|
170
|
+
const encrypted = Buffer.from(encryptedHex, 'hex')
|
|
171
|
+
const decipher = createDecipheriv('aes-256-gcm', SNAPSHOT_ENCRYPTION_KEY, iv)
|
|
172
|
+
decipher.setAuthTag(authTag)
|
|
173
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
|
174
|
+
} catch {
|
|
175
|
+
return data // decryption failed, return raw (may be unencrypted legacy data)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
function saveSnapshot(cwd: string, workflowTrace: WorkflowTrace): string {
|
|
148
180
|
const dir = path.join(cwd, '.temp', 'snapshots')
|
|
149
181
|
mkdirSync(dir, { recursive: true })
|
|
150
182
|
const id = workflowTrace.traceId
|
|
151
|
-
|
|
183
|
+
const content = encryptSnapshot(JSON.stringify(workflowTrace))
|
|
184
|
+
writeFileSync(path.join(dir, `${id}.json`), content, 'utf8')
|
|
152
185
|
return id
|
|
153
186
|
}
|
|
154
187
|
|
|
155
188
|
function loadSnapshot(cwd: string, snapshotId: string): WorkflowTrace | null {
|
|
156
189
|
try {
|
|
157
190
|
const file = path.join(cwd, '.temp', 'snapshots', `${snapshotId}.json`)
|
|
158
|
-
|
|
191
|
+
const raw = readFileSync(file, 'utf8')
|
|
192
|
+
return JSON.parse(decryptSnapshot(raw)) as WorkflowTrace
|
|
159
193
|
} catch {
|
|
160
194
|
return null
|
|
161
195
|
}
|
package/src/index.ts
CHANGED
|
@@ -104,6 +104,13 @@ export { startPortalServer } from './portal-server.js'
|
|
|
104
104
|
export { executePortalTask } from './portal-executor.js'
|
|
105
105
|
export type { PortalTask, PortalTaskResult, PortalServerOptions, PortalServerHandle, PortalStatus } from './types/portal.js'
|
|
106
106
|
|
|
107
|
+
// CI runner (programmatic API)
|
|
108
|
+
export { runCI } from './ci/runner.js'
|
|
109
|
+
export { fetchTestGroups, submitTestRun, createBatch } from './ci/api-client.js'
|
|
110
|
+
export { detectGitInfo } from './ci/git-info.js'
|
|
111
|
+
export type { CIRunConfig, CIRunSummary, CITestResult, CISingleRunResult, CIExpectationResult } from './ci/types.js'
|
|
112
|
+
export type { GitInfo } from './ci/git-info.js'
|
|
113
|
+
|
|
107
114
|
// ─── Eager auto-init ────────────────────────────────────────
|
|
108
115
|
// When ELASTICDASH_API_KEY is set, automatically initialise observability mode
|
|
109
116
|
// at import time so the socket connection is established immediately (e.g. for
|
package/src/portal-server.ts
CHANGED
|
@@ -180,6 +180,13 @@ export async function startPortalServer(options: PortalServerOptions): Promise<P
|
|
|
180
180
|
if (isAllowedOrigin(req, allowedHosts)) {
|
|
181
181
|
// If API key is configured but not provided, still reject
|
|
182
182
|
if (apiKey) {
|
|
183
|
+
console.warn(JSON.stringify({
|
|
184
|
+
event: 'auth.failure',
|
|
185
|
+
reason: 'invalid_api_key',
|
|
186
|
+
ip: req.ip || req.headers['x-forwarded-for'] || 'unknown',
|
|
187
|
+
userAgent: req.headers['user-agent'] || 'unknown',
|
|
188
|
+
timestamp: new Date().toISOString(),
|
|
189
|
+
}))
|
|
183
190
|
res.status(401).json({ ok: false, error: 'unauthorized — valid API key required' })
|
|
184
191
|
return
|
|
185
192
|
}
|
|
@@ -188,7 +195,14 @@ export async function startPortalServer(options: PortalServerOptions): Promise<P
|
|
|
188
195
|
|
|
189
196
|
// Origin not in allowlist and no valid API key
|
|
190
197
|
const origin = req.headers.origin ?? req.headers.referer ?? req.ip ?? 'unknown'
|
|
191
|
-
console.warn(
|
|
198
|
+
console.warn(JSON.stringify({
|
|
199
|
+
event: 'auth.failure',
|
|
200
|
+
reason: 'origin_not_allowed',
|
|
201
|
+
origin,
|
|
202
|
+
ip: req.ip || req.headers['x-forwarded-for'] || 'unknown',
|
|
203
|
+
userAgent: req.headers['user-agent'] || 'unknown',
|
|
204
|
+
timestamp: new Date().toISOString(),
|
|
205
|
+
}))
|
|
192
206
|
res.status(403).json({ ok: false, error: 'forbidden — origin not in allowlist' })
|
|
193
207
|
}
|
|
194
208
|
|
package/src/telemetry-batcher.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Readable } from 'node:stream'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import type { WorkflowEvent } from './capture/event.js'
|
|
3
4
|
import { getOriginalFetch } from './interceptors/http.js'
|
|
4
5
|
import { debugLog } from './utils/debug.js'
|
|
@@ -95,7 +96,10 @@ export class TelemetryBatcher {
|
|
|
95
96
|
}
|
|
96
97
|
const maxRetries = 3
|
|
97
98
|
const url = `${this.serverUrl}/api/observability/events`
|
|
98
|
-
const headers: Record<string, string> = {
|
|
99
|
+
const headers: Record<string, string> = {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
'X-Correlation-ID': randomUUID(),
|
|
102
|
+
}
|
|
99
103
|
if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`
|
|
100
104
|
|
|
101
105
|
try {
|