Skip to content

Commit 6c8d5d2

Browse files
chore: migrate Kibana functional tests to use YAML test definition format (#565)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent f0af977 commit 6c8d5d2

614 files changed

Lines changed: 18112 additions & 104 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.buildkite/run-es-tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ EOF
106106
export ELASTIC_CLI_CONFIG_FILE="$CI_CONFIG_FILE"
107107

108108
echo "--- Generating functional test scripts"
109-
npx tsx codegen/functional/index.ts --tests-dir elasticsearch-clients-tests/tests
109+
npx tsx codegen/functional/es.ts --tests-dir elasticsearch-clients-tests/tests
110110

111111
echo "+++ Running ES functional tests"
112112
npm run test:functional:es

.buildkite/run-kb-tests-runner.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,6 @@ EOF
129129
export ELASTIC_CLI_CONFIG_FILE="/tmp/elastic-rc.yml"
130130

131131
echo "+++ Running KB functional tests"
132-
npm run test:functional:kb
132+
# this setup only runs against stack Kibana
133+
env ELASTIC_ENVIRONMENT=stack \
134+
npm run test:functional:kb

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ megalinter-reports/
1717

1818
# Generated functional test scripts
1919
test/functional/es/
20+
test/functional/kb/generated/
2021

2122
# docs-builder local build output
2223
docs/.artifacts

AGENTS.md

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -186,20 +186,6 @@ When constructing URLs, sending credentials, or making HTTP requests:
186186

187187
10. **Review upstream command names for UX.** Names sourced directly from `@elastic/schemas` (e.g. `list-deployments`) are precise but verbose. Add short aliases where unambiguous so users can discover commands intuitively.
188188

189-
## Spec-Kit Workflow
190-
191-
Uses [spec-kit](https://github.com/github/spec-kit) for AI-assisted feature development.
192-
193-
| Path | Purpose |
194-
|------|---------|
195-
| `.specify/specs/` | Feature specifications |
196-
| `.specify/plans/` | Implementation plans |
197-
| `.specify/tasks/` | Task definitions |
198-
| `.specify/memory/` | Long-lived context (e.g. `constitution.md`) |
199-
| `.specify/templates/` | Markdown templates |
200-
| `.specify/scripts/` | Helper scripts |
201-
| `.specify/hooks.yml` | CI/automation hooks |
202-
203189
## Conventional Commits
204190

205191
All commit messages and PR titles MUST follow [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/). PR titles are validated in CI.
@@ -250,28 +236,6 @@ feat(cli)!: rename --output to --format
250236
BREAKING CHANGE: --output is removed; use --format instead.
251237
```
252238

253-
### Release-Please Integration
254-
255-
[release-please](https://github.com/googleapis/release-please) automates versioning from commit messages via squash-merge.
256-
257-
To override a merged commit message, add to the PR body:
258-
259-
```
260-
BEGIN_COMMIT_OVERRIDE
261-
feat(cli): correct description
262-
263-
fix(config): secondary fix
264-
END_COMMIT_OVERRIDE
265-
```
266-
267-
To force a specific version, use the `Release-As` trailer:
268-
269-
```
270-
chore: release 3.0.0
271-
272-
Release-As: 3.0.0
273-
```
274-
275239
### Common Mistakes
276240

277241
- Types and scopes must be lowercase: `feat`, not `Feat`; `feat(cli)`, not `feat(CLI)`.

codegen/functional/generator.ts

Lines changed: 129 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import type { EsApiDefinition } from '../../src/es/types.ts'
6+
import type { ApiActionDef } from './types.ts'
77
import {
88
YamlFloat,
99
type TestFile, type Step, type DoStep, type SetStep, type MatchStep,
1010
type IsTrueStep, type IsFalseStep, type LengthStep,
11-
type GtStep, type GteStep, type LtStep, type LteStep, type ContainsStep
11+
type GtStep, type GteStep, type LtStep, type LteStep, type ContainsStep,
12+
type WriteNdjsonTempStep
1213
} from './types.ts'
1314
import { buildActionMap, mapAction } from './mapper.ts'
1415
import type { MappedAction } from './mapper.ts'
@@ -40,10 +41,22 @@ export class UnmappedBodyKeyError extends Error {
4041
/**
4142
* Generate a bash test script from a parsed YAML test file.
4243
*/
44+
export interface GenerateOptions {
45+
/** Leading CLI args identifying the client (default ["stack", "es"]). */
46+
clientArgs?: string[]
47+
/** Preamble lines defining the `$ELASTIC` invocation and `$RESPONSE` (default: the `elastic` binary). */
48+
preamble?: string[]
49+
}
50+
51+
const DEFAULT_PREAMBLE = ['exec < /dev/null', 'ELASTIC="elastic --json"', 'RESPONSE=""']
52+
4353
export function generateScript (
4454
testFile: TestFile,
45-
definitions: EsApiDefinition[]
55+
definitions: ApiActionDef[],
56+
opts: GenerateOptions = {}
4657
): GenerateResult {
58+
const clientArgs = opts.clientArgs ?? ['stack', 'es']
59+
const preamble = opts.preamble ?? DEFAULT_PREAMBLE
4760
const actionMap = buildActionMap(definitions)
4861
const skippedActions: string[] = []
4962
const lines: string[] = []
@@ -52,18 +65,27 @@ export function generateScript (
5265
lines.push(`# Generated from ${testFile.sourceFile}`)
5366
lines.push('set -euo pipefail')
5467
lines.push('')
55-
lines.push('exec < /dev/null')
56-
lines.push('ELASTIC="elastic --json"')
57-
lines.push('RESPONSE=""')
68+
for (const line of preamble) lines.push(line)
5869
lines.push('')
5970

6071
if (testFile.teardown.length > 0) {
61-
lines.push('teardown() {')
62-
const teardownStart = lines.length
63-
renderSteps(testFile.teardown, actionMap, lines, skippedActions, ' ')
64-
if (!hasExecutableLine(lines.slice(teardownStart))) {
65-
lines.push(' :')
72+
const teardownBody: string[] = []
73+
renderSteps(testFile.teardown, actionMap, clientArgs, teardownBody, skippedActions, ' ')
74+
if (!hasExecutableLine(teardownBody)) {
75+
teardownBody.push(' :')
6676
}
77+
// The teardown runs via `trap teardown EXIT`, so it fires even when a
78+
// setup/test `do` fails before the `set` that assigns a stash var. Under
79+
// `set -u` an unassigned reference would abort teardown with "unbound
80+
// variable", masking the real failure. Initialize any stash var the
81+
// teardown references (excluding preamble-defined vars) to empty first.
82+
// Scoped to teardown only; main-body refs still fail loud as real bugs.
83+
const preambleVars = new Set(preamble.flatMap(declaredVar))
84+
for (const v of collectVarRefs(teardownBody)) {
85+
if (!preambleVars.has(v)) lines.push(`${v}=""`)
86+
}
87+
lines.push('teardown() {')
88+
lines.push(...teardownBody)
6789
lines.push('}')
6890
lines.push('trap teardown EXIT')
6991
lines.push('')
@@ -76,13 +98,13 @@ export function generateScript (
7698

7799
if (testFile.setup.length > 0) {
78100
lines.push('# --- Setup ---')
79-
hadSkippedDo = renderSteps(testFile.setup, actionMap, lines, skippedActions, '')
101+
hadSkippedDo = renderSteps(testFile.setup, actionMap, clientArgs, lines, skippedActions, '')
80102
lines.push('')
81103
}
82104

83105
for (const section of testFile.tests) {
84106
lines.push(`# --- Test: ${section.name} ---`)
85-
renderSteps(section.steps, actionMap, lines, skippedActions, '', hadSkippedDo)
107+
renderSteps(section.steps, actionMap, clientArgs, lines, skippedActions, '', hadSkippedDo)
86108
lines.push('')
87109
}
88110

@@ -102,10 +124,24 @@ export function generateScript (
102124
}
103125
}
104126

127+
/** A generated script plus the `requires` metadata used for runtime filtering. */
128+
export interface RunnerScript {
129+
path: string
130+
serverless?: boolean
131+
/** true = runs on stack, false = excluded, null/undefined = not specified */
132+
stack?: boolean | null
133+
}
134+
105135
/**
106136
* Generate the run.sh runner script that executes all generated test scripts.
137+
*
138+
* Scripts may be passed as bare paths or as {@link RunnerScript} objects. When
139+
* `requires` metadata is present, the runner reads `ELASTIC_ENVIRONMENT`
140+
* ("serverless" | "stack") to run only scripts whose matching `requires` field is `true`.
107141
*/
108-
export function generateRunner (scriptPaths: string[]): string {
142+
export function generateRunner (scripts: Array<string | RunnerScript>): string {
143+
const normalized: RunnerScript[] = scripts.map((s) =>
144+
typeof s === 'string' ? { path: s } : s)
109145
const lines: string[] = []
110146
lines.push('#!/bin/bash')
111147
lines.push('# Runner for generated functional tests')
@@ -115,17 +151,34 @@ export function generateRunner (scriptPaths: string[]): string {
115151
lines.push('PASSED=0')
116152
lines.push('FAILED=0')
117153
lines.push('ERRORS=""')
154+
lines.push('BAIL=0')
155+
lines.push('for arg in "$@"; do')
156+
lines.push(' case "$arg" in')
157+
lines.push(' --bail) BAIL=1 ;;')
158+
lines.push(' esac')
159+
lines.push('done')
160+
lines.push('')
161+
lines.push('should_run () {')
162+
lines.push(' # args: <serverless> <stack>')
163+
lines.push(' [ -z "${ELASTIC_ENVIRONMENT:-}" ] && return 0')
164+
lines.push(' [ "${ELASTIC_ENVIRONMENT}" = serverless ] && [ "$1" = true ] && return 0')
165+
lines.push(' [ "${ELASTIC_ENVIRONMENT}" = stack ] && [ "$2" = true ] && return 0')
166+
lines.push(' return 1')
167+
lines.push('}')
118168
lines.push('')
119169

120-
for (const p of scriptPaths) {
121-
lines.push(`if OUTPUT=$(bash "$SCRIPT_DIR/${p}" 2>&1); then`)
122-
lines.push(' PASSED=$((PASSED + 1))')
123-
lines.push(` echo "PASS: ${p}"`)
124-
lines.push('else')
125-
lines.push(' FAILED=$((FAILED + 1))')
126-
lines.push(` ERRORS="$ERRORS\\n FAIL: ${p}"`)
127-
lines.push(` echo "FAIL: ${p}"`)
128-
lines.push(' echo "$OUTPUT" | tail -5')
170+
for (const { path: p, serverless, stack } of normalized) {
171+
lines.push(`if should_run ${serverless === true ? 'true' : 'false'} ${stack === true ? 'true' : 'false'}; then`)
172+
lines.push(` if OUTPUT=$(bash "$SCRIPT_DIR/${p}" 2>&1); then`)
173+
lines.push(' PASSED=$((PASSED + 1))')
174+
lines.push(` echo "PASS: ${p}"`)
175+
lines.push(' else')
176+
lines.push(' FAILED=$((FAILED + 1))')
177+
lines.push(` ERRORS="$ERRORS\\n FAIL: ${p}"`)
178+
lines.push(` echo "FAIL: ${p}"`)
179+
lines.push(' echo "$OUTPUT" | tail -5')
180+
lines.push(' if [ "$BAIL" -eq 1 ]; then exit 1; fi')
181+
lines.push(' fi')
129182
lines.push('fi')
130183
lines.push('')
131184
}
@@ -158,6 +211,35 @@ function hasExecutableLine (lines: string[]): boolean {
158211
})
159212
}
160213

214+
/**
215+
* Extract the variable name declared by a preamble line (e.g. `RESPONSE=""`
216+
* -> `RESPONSE`), or nothing if the line is not an assignment.
217+
*/
218+
function declaredVar (line: string): string[] {
219+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=/)
220+
return m?.[1] != null ? [m[1]] : []
221+
}
222+
223+
/**
224+
* Collect distinct bash variable names referenced (`$VAR` / `${VAR}`) across
225+
* the given lines, in first-seen order. Assignment targets like `VAR=$(...)`
226+
* are not matched (the `$` there belongs to `$(`).
227+
*/
228+
function collectVarRefs (lines: string[]): string[] {
229+
const seen = new Set<string>()
230+
const out: string[] = []
231+
for (const line of lines) {
232+
for (const m of line.matchAll(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g)) {
233+
const v = m[1]
234+
if (v != null && !seen.has(v)) {
235+
seen.add(v)
236+
out.push(v)
237+
}
238+
}
239+
}
240+
return out
241+
}
242+
161243
/**
162244
* Renders a sequence of test steps into bash lines.
163245
* @param initialHadSkippedDo - if true, assume prior steps in an earlier
@@ -168,7 +250,8 @@ function hasExecutableLine (lines: string[]): boolean {
168250
*/
169251
function renderSteps (
170252
steps: Step[],
171-
actionMap: Map<string, EsApiDefinition>,
253+
actionMap: Map<string, ApiActionDef>,
254+
clientArgs: string[],
172255
lines: string[],
173256
skippedActions: string[],
174257
indent: string,
@@ -196,7 +279,7 @@ function renderSteps (
196279
}
197280
// Only pass allowFailure from the setup→test propagation, not from
198281
// prior skipped steps within the same section (those are unrelated).
199-
const result = renderDo(step, actionMap, lines, skippedActions, indent, initialHadSkippedDo)
282+
const result = renderDo(step, actionMap, clientArgs, lines, skippedActions, indent, initialHadSkippedDo)
200283
if (result === 'skipped') {
201284
responseFromLastDo = false
202285
hadSkippedDo = true
@@ -218,6 +301,9 @@ function renderSteps (
218301
unsetVars.add(varName.toUpperCase().replace(/[^A-Z0-9]/g, '_'))
219302
}
220303
}
304+
if (step.kind === 'write_ndjson_temp') {
305+
unsetVars.add(step.varName.toUpperCase().replace(/[^A-Z0-9]/g, '_'))
306+
}
221307
lines.push(`${indent}# SKIPPED: ${step.kind} assertion follows skipped do-step`)
222308
continue
223309
}
@@ -230,6 +316,10 @@ function renderSteps (
230316
unsetVars.delete(varName.toUpperCase().replace(/[^A-Z0-9]/g, '_'))
231317
}
232318
break
319+
case 'write_ndjson_temp':
320+
renderWriteNdjsonTemp(step, lines, indent)
321+
unsetVars.delete(step.varName.toUpperCase().replace(/[^A-Z0-9]/g, '_'))
322+
break
233323
case 'match':
234324
renderMatch(step, lines, indent)
235325
break
@@ -265,7 +355,8 @@ function renderSteps (
265355
*/
266356
function renderDo (
267357
step: DoStep,
268-
actionMap: Map<string, EsApiDefinition>,
358+
actionMap: Map<string, ApiActionDef>,
359+
clientArgs: string[],
269360
lines: string[],
270361
skippedActions: string[],
271362
indent: string,
@@ -280,7 +371,7 @@ function renderDo (
280371
lines.push(`${indent}# NOTE: headers not supported by CLI (${Object.keys(step.headers).join(', ')})`)
281372
}
282373

283-
const mapped = mapAction(step.action, step.params, actionMap)
374+
const mapped = mapAction(step.action, step.params, actionMap, clientArgs)
284375
if (mapped == null) {
285376
skippedActions.push(step.action)
286377
lines.push(`${indent}# SKIPPED: action "${step.action}" not registered in CLI`)
@@ -427,6 +518,17 @@ function renderSet (step: SetStep, lines: string[], indent: string): void {
427518
}
428519
}
429520

521+
function renderWriteNdjsonTemp (step: WriteNdjsonTempStep, lines: string[], indent: string): void {
522+
const bashVar = step.varName.toUpperCase().replace(/[^A-Z0-9]/g, '_')
523+
// The Kibana client decodes an NDJSON export response into a JSON array; jq
524+
// reconstitutes one NDJSON line per element so the import step can pass the
525+
// temp file via --file. Kibana rejects any file extension other than
526+
// .ndjson, and mktemp yields a random suffix, so place the file in a temp
527+
// dir with an explicit .ndjson name.
528+
lines.push(`${indent}${bashVar}=$(mktemp -d)/export.ndjson`)
529+
lines.push(`${indent}echo "$RESPONSE" | jq -c '.[]' > "$${bashVar}"`)
530+
}
531+
430532
function renderMatch (step: MatchStep, lines: string[], indent: string): void {
431533
for (const [path, expected] of Object.entries(step.assertions)) {
432534
renderMatchValue(path, expected, lines, indent)

0 commit comments

Comments
 (0)