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'
77import {
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'
1314import { buildActionMap , mapAction } from './mapper.ts'
1415import 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+
4353export 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 - Z 0 - 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 - Z 0 - 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 */
169251function 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 - Z 0 - 9 ] / g, '_' ) )
219302 }
220303 }
304+ if ( step . kind === 'write_ndjson_temp' ) {
305+ unsetVars . add ( step . varName . toUpperCase ( ) . replace ( / [ ^ A - Z 0 - 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 - Z 0 - 9 ] / g, '_' ) )
231317 }
232318 break
319+ case 'write_ndjson_temp' :
320+ renderWriteNdjsonTemp ( step , lines , indent )
321+ unsetVars . delete ( step . varName . toUpperCase ( ) . replace ( / [ ^ A - Z 0 - 9 ] / g, '_' ) )
322+ break
233323 case 'match' :
234324 renderMatch ( step , lines , indent )
235325 break
@@ -265,7 +355,8 @@ function renderSteps (
265355 */
266356function 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 - Z 0 - 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+
430532function 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