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.
Files changed (53) hide show
  1. package/README.md +167 -0
  2. package/dist/ci/api-client.d.ts +23 -0
  3. package/dist/ci/api-client.d.ts.map +1 -0
  4. package/dist/ci/api-client.js +64 -0
  5. package/dist/ci/api-client.js.map +1 -0
  6. package/dist/ci/executor.d.ts +13 -0
  7. package/dist/ci/executor.d.ts.map +1 -0
  8. package/dist/ci/executor.js +539 -0
  9. package/dist/ci/executor.js.map +1 -0
  10. package/dist/ci/git-info.d.ts +13 -0
  11. package/dist/ci/git-info.d.ts.map +1 -0
  12. package/dist/ci/git-info.js +52 -0
  13. package/dist/ci/git-info.js.map +1 -0
  14. package/dist/ci/index.d.ts +6 -0
  15. package/dist/ci/index.d.ts.map +1 -0
  16. package/dist/ci/index.js +4 -0
  17. package/dist/ci/index.js.map +1 -0
  18. package/dist/ci/runner.d.ts +3 -0
  19. package/dist/ci/runner.d.ts.map +1 -0
  20. package/dist/ci/runner.js +178 -0
  21. package/dist/ci/runner.js.map +1 -0
  22. package/dist/ci/types.d.ts +108 -0
  23. package/dist/ci/types.d.ts.map +1 -0
  24. package/dist/ci/types.js +3 -0
  25. package/dist/ci/types.js.map +1 -0
  26. package/dist/cli.js +40 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/dashboard-server.d.ts.map +1 -1
  29. package/dist/dashboard-server.js +37 -3
  30. package/dist/dashboard-server.js.map +1 -1
  31. package/dist/index.cjs +951 -123
  32. package/dist/index.d.ts +5 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/portal-server.d.ts.map +1 -1
  37. package/dist/portal-server.js +15 -1
  38. package/dist/portal-server.js.map +1 -1
  39. package/dist/telemetry-batcher.d.ts.map +1 -1
  40. package/dist/telemetry-batcher.js +5 -1
  41. package/dist/telemetry-batcher.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/ci/api-client.ts +87 -0
  44. package/src/ci/executor.ts +668 -0
  45. package/src/ci/git-info.ts +66 -0
  46. package/src/ci/index.ts +5 -0
  47. package/src/ci/runner.ts +198 -0
  48. package/src/ci/types.ts +115 -0
  49. package/src/cli.ts +55 -0
  50. package/src/dashboard-server.ts +37 -3
  51. package/src/index.ts +7 -0
  52. package/src/portal-server.ts +15 -1
  53. 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
+ }
@@ -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'
@@ -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
+ }
@@ -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')
@@ -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
- writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(workflowTrace), 'utf8')
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
- return JSON.parse(readFileSync(file, 'utf8')) as WorkflowTrace
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
@@ -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(`[elasticdash portal] Blocked request from disallowed origin: ${origin}`)
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
 
@@ -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> = { 'Content-Type': 'application/json' }
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 {