From a75c0c05136bc3f2fbc9fb0e6a47d241d54bc166 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 9 Apr 2026 16:25:41 -0400 Subject: [PATCH 1/2] es codegen functional tests --- codegen/functional/mapper.ts | 83 +++++++++++++++++++ codegen/functional/parser.ts | 151 +++++++++++++++++++++++++++++++++++ codegen/functional/types.ts | 81 +++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 codegen/functional/mapper.ts create mode 100644 codegen/functional/parser.ts create mode 100644 codegen/functional/types.ts diff --git a/codegen/functional/mapper.ts b/codegen/functional/mapper.ts new file mode 100644 index 00000000..20fcaa9c --- /dev/null +++ b/codegen/functional/mapper.ts @@ -0,0 +1,83 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { EsApiDefinition } from '../../src/es/types.ts' +import { resolveInput } from '../../src/es/types.ts' +import { extractSchemaArgs } from '../../src/lib/schema-args.ts' +import type { SchemaArgDefinition } from '../../src/lib/schema-args.ts' + +/** + * Result of mapping a YAML dot-notation action to a CLI command. + * Contains the CLI arguments needed to invoke the command. + */ +export interface MappedAction { + /** CLI args: ['es', namespace?, name, ...flags] */ + cliArgs: string[] + /** true if the action accepts a request body */ + hasBody: boolean +} + +/** + * Builds a lookup from YAML dot-notation action names to EsApiDefinitions. + * + * YAML uses `namespace.name` (e.g. "indices.create") or just `name` (e.g. "get"). + * Definitions with `namespace` are keyed as `namespace.name`. + * Definitions without `namespace` are keyed as just `name`. + */ +export function buildActionMap (definitions: EsApiDefinition[]): Map { + const map = new Map() + for (const def of definitions) { + const key = def.namespace != null ? `${def.namespace}.${def.name}` : def.name + map.set(key, def) + } + return map +} + +/** + * Maps a YAML test action to CLI arguments. + * + * @param action - dot-notation action name (e.g. "indices.create", "get") + * @param params - YAML action parameters (path + query params, excluding body) + * @param actionMap - lookup map from buildActionMap + * @returns MappedAction with CLI args, or null if the action isn't registered + */ +export function mapAction ( + action: string, + params: Record, + actionMap: Map +): MappedAction | null { + const def = actionMap.get(action) + if (def == null) return null + + const args: string[] = ['es'] + if (def.namespace != null) args.push(def.namespace) + args.push(def.name) + + const schemaArgs = def.input != null + ? extractSchemaArgs(resolveInput(def.input)) + : [] + + const bodyFields = new Set( + schemaArgs.filter((a) => a.foundIn === 'body').map((a) => a.schemaKey) + ) + + const argsByKey = new Map() + for (const arg of schemaArgs) { + argsByKey.set(arg.schemaKey, arg) + } + + for (const [key, value] of Object.entries(params)) { + if (bodyFields.has(key)) continue + if (key === 'ignore') continue + + const argDef = argsByKey.get(key) + const flag = argDef != null ? argDef.cliFlag : key.replace(/_/g, '-') + + args.push(`--${flag}`, String(value)) + } + + const hasBody = schemaArgs.some((a) => a.foundIn === 'body') + return { cliArgs: args, hasBody } +} diff --git a/codegen/functional/parser.ts b/codegen/functional/parser.ts new file mode 100644 index 00000000..f089fd6d --- /dev/null +++ b/codegen/functional/parser.ts @@ -0,0 +1,151 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseAllDocuments } from 'yaml' +import type { + TestFile, Requires, TestSection, Step, + DoStep, SetStep, MatchStep, IsTrueStep, IsFalseStep, LengthStep +} from './types.ts' + +const RESERVED_KEYS = new Set(['requires', 'setup', 'teardown']) + +/** + * Parse a YAML test file from elasticsearch-clients-tests into a typed AST. + * + * Each file is a multi-document YAML stream separated by `---`. + * Documents are: requires, optional setup, optional teardown, then named test sections. + */ +export function parseTestFile (yamlContent: string, sourceFile: string): TestFile { + const docs = parseAllDocuments(yamlContent) + const sections: Record[] = docs + .map((doc) => doc.toJSON() as Record | null) + .filter((v): v is Record => v != null) + + let requires: Requires = { serverless: false, stack: false } + let setup: Step[] = [] + let teardown: Step[] = [] + const tests: TestSection[] = [] + + for (const section of sections) { + if ('requires' in section) { + const r = section.requires as Record + requires = { + serverless: r.serverless === true, + stack: r.stack === true + } + continue + } + + if ('setup' in section) { + setup = parseSteps(section.setup as unknown[]) + continue + } + + if ('teardown' in section) { + teardown = parseSteps(section.teardown as unknown[]) + continue + } + + // remaining keys are named test sections + for (const [name, steps] of Object.entries(section)) { + if (RESERVED_KEYS.has(name)) continue + tests.push({ + name, + steps: parseSteps(steps as unknown[]) + }) + } + } + + return { sourceFile, requires, setup, teardown, tests } +} + +/** + * Returns true if the test file targets serverless. + */ +export function isServerless (file: TestFile): boolean { + return file.requires.serverless +} + +// --------------------------------------------------------------------------- +// Step parsing +// --------------------------------------------------------------------------- + +function parseSteps (raw: unknown[]): Step[] { + const steps: Step[] = [] + for (const item of raw) { + if (item == null || typeof item !== 'object') continue + const obj = item as Record + + if ('do' in obj) { + steps.push(parseDo(obj.do as Record)) + } else if ('set' in obj) { + steps.push(parseSet(obj.set as Record)) + } else if ('match' in obj) { + steps.push(parseMatch(obj.match as Record)) + } else if ('is_true' in obj) { + steps.push(parseIsTrue(obj.is_true)) + } else if ('is_false' in obj) { + steps.push(parseIsFalse(obj.is_false)) + } else if ('length' in obj) { + steps.push(parseLength(obj.length as Record)) + } + } + return steps +} + +function parseDo (raw: Record): DoStep { + let catchValue: string | undefined + if ('catch' in raw) { + catchValue = String(raw.catch) + } + + // The action key is the first key that isn't "catch" + let action = '' + let actionValue: Record = {} + for (const [key, val] of Object.entries(raw)) { + if (key === 'catch') continue + action = key + actionValue = (val != null && typeof val === 'object' && !Array.isArray(val)) + ? val as Record + : {} + break + } + + // Separate body from other params + const { body, ...params } = actionValue + + const step: DoStep = { kind: 'do', action, params } + if (body !== undefined) step.body = body + if (catchValue !== undefined) step.catch = catchValue + return step +} + +function parseSet (raw: Record): SetStep { + const assignments: Record = {} + for (const [responsePath, varName] of Object.entries(raw)) { + assignments[responsePath] = String(varName) + } + return { kind: 'set', assignments } +} + +function parseMatch (raw: Record): MatchStep { + return { kind: 'match', assertions: { ...raw } } +} + +function parseIsTrue (raw: unknown): IsTrueStep { + return { kind: 'is_true', field: String(raw ?? '') } +} + +function parseIsFalse (raw: unknown): IsFalseStep { + return { kind: 'is_false', field: String(raw ?? '') } +} + +function parseLength (raw: Record): LengthStep { + const assertions: Record = {} + for (const [path, len] of Object.entries(raw)) { + assertions[path] = Number(len) + } + return { kind: 'length', assertions } +} diff --git a/codegen/functional/types.ts b/codegen/functional/types.ts new file mode 100644 index 00000000..d7fcb499 --- /dev/null +++ b/codegen/functional/types.ts @@ -0,0 +1,81 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// --------------------------------------------------------------------------- +// Parsed AST for elasticsearch-clients-tests YAML files +// --------------------------------------------------------------------------- + +/** Top-level structure of a parsed YAML test file. */ +export interface TestFile { + /** Source file path (relative to tests dir) */ + sourceFile: string + requires: Requires + setup: Step[] + teardown: Step[] + tests: TestSection[] +} + +export interface Requires { + serverless: boolean + stack: boolean +} + +/** A named test section (e.g. "get", "Basic bulk operation"). */ +export interface TestSection { + name: string + steps: Step[] +} + +// --------------------------------------------------------------------------- +// Steps — ordered operations within a test section / setup / teardown +// --------------------------------------------------------------------------- + +export type Step = + | DoStep + | SetStep + | MatchStep + | IsTrueStep + | IsFalseStep + | LengthStep + +export interface DoStep { + kind: 'do' + /** dot-notation action name (e.g. "indices.create", "get", "bulk") */ + action: string + /** parameters for the action (everything except "body" and "catch") */ + params: Record + /** request body, if present */ + body?: unknown + /** expected error type — when present the action is expected to fail */ + catch?: string +} + +export interface SetStep { + kind: 'set' + /** Maps response path -> variable name (e.g. { "_id": "id" }) */ + assignments: Record +} + +export interface MatchStep { + kind: 'match' + /** Maps response path -> expected value */ + assertions: Record +} + +export interface IsTrueStep { + kind: 'is_true' + field: string +} + +export interface IsFalseStep { + kind: 'is_false' + field: string +} + +export interface LengthStep { + kind: 'length' + /** Maps response path -> expected length */ + assertions: Record +} From 4d1254c4e458479dc5ef15436bb367287048a59a Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 9 Apr 2026 17:20:47 -0400 Subject: [PATCH 2/2] es codegen functional tests --- codegen/functional/parser.ts | 42 +++++++++++++++++++++++++++++--- codegen/functional/types.ts | 47 +++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/codegen/functional/parser.ts b/codegen/functional/parser.ts index f089fd6d..dc1af46e 100644 --- a/codegen/functional/parser.ts +++ b/codegen/functional/parser.ts @@ -6,7 +6,8 @@ import { parseAllDocuments } from 'yaml' import type { TestFile, Requires, TestSection, Step, - DoStep, SetStep, MatchStep, IsTrueStep, IsFalseStep, LengthStep + DoStep, SetStep, MatchStep, IsTrueStep, IsFalseStep, LengthStep, + GtStep, GteStep, LtStep, LteStep, ContainsStep } from './types.ts' const RESERVED_KEYS = new Set(['requires', 'setup', 'teardown']) @@ -90,22 +91,40 @@ function parseSteps (raw: unknown[]): Step[] { steps.push(parseIsFalse(obj.is_false)) } else if ('length' in obj) { steps.push(parseLength(obj.length as Record)) + } else if ('gt' in obj) { + steps.push(parseComparison('gt', obj.gt as Record)) + } else if ('gte' in obj) { + steps.push(parseComparison('gte', obj.gte as Record)) + } else if ('lt' in obj) { + steps.push(parseComparison('lt', obj.lt as Record)) + } else if ('lte' in obj) { + steps.push(parseComparison('lte', obj.lte as Record)) + } else if ('contains' in obj) { + steps.push(parseContains(obj.contains as Record)) + } else if ('skip' in obj) { + steps.push({ kind: 'skip' }) } } return steps } +/** Keys in a `do` block that are metadata, not the API action. */ +const DO_META_KEYS = new Set(['catch', 'headers', 'ignore']) + function parseDo (raw: Record): DoStep { let catchValue: string | undefined if ('catch' in raw) { catchValue = String(raw.catch) } - // The action key is the first key that isn't "catch" + const headers = raw.headers as Record | undefined + const ignore = raw.ignore as number | number[] | undefined + + // The action key is the first key that isn't metadata let action = '' let actionValue: Record = {} for (const [key, val] of Object.entries(raw)) { - if (key === 'catch') continue + if (DO_META_KEYS.has(key)) continue action = key actionValue = (val != null && typeof val === 'object' && !Array.isArray(val)) ? val as Record @@ -113,12 +132,15 @@ function parseDo (raw: Record): DoStep { break } - // Separate body from other params const { body, ...params } = actionValue const step: DoStep = { kind: 'do', action, params } if (body !== undefined) step.body = body if (catchValue !== undefined) step.catch = catchValue + if (headers !== undefined) step.headers = headers + if (ignore !== undefined) { + step.ignore = Array.isArray(ignore) ? ignore : [ignore] + } return step } @@ -149,3 +171,15 @@ function parseLength (raw: Record): LengthStep { } return { kind: 'length', assertions } } + +function parseComparison (kind: 'gt' | 'gte' | 'lt' | 'lte', raw: Record): GtStep | GteStep | LtStep | LteStep { + const assertions: Record = {} + for (const [path, val] of Object.entries(raw)) { + assertions[path] = Number(val) + } + return { kind, assertions } +} + +function parseContains (raw: Record): ContainsStep { + return { kind: 'contains', assertions: { ...raw } } +} diff --git a/codegen/functional/types.ts b/codegen/functional/types.ts index d7fcb499..259e93e2 100644 --- a/codegen/functional/types.ts +++ b/codegen/functional/types.ts @@ -39,17 +39,27 @@ export type Step = | IsTrueStep | IsFalseStep | LengthStep + | GtStep + | GteStep + | LtStep + | LteStep + | ContainsStep + | SkipStep export interface DoStep { kind: 'do' /** dot-notation action name (e.g. "indices.create", "get", "bulk") */ action: string - /** parameters for the action (everything except "body" and "catch") */ + /** parameters for the action (everything except "body", "catch", "headers", "ignore") */ params: Record /** request body, if present */ body?: unknown /** expected error type — when present the action is expected to fail */ catch?: string + /** custom HTTP headers for this request */ + headers?: Record + /** HTTP status codes to ignore (e.g. [404] for teardown cleanup) */ + ignore?: number[] } export interface SetStep { @@ -79,3 +89,38 @@ export interface LengthStep { /** Maps response path -> expected length */ assertions: Record } + +export interface GtStep { + kind: 'gt' + /** Maps response path -> value that response must be greater than */ + assertions: Record +} + +export interface GteStep { + kind: 'gte' + /** Maps response path -> value that response must be greater than or equal to */ + assertions: Record +} + +export interface LtStep { + kind: 'lt' + /** Maps response path -> value that response must be less than */ + assertions: Record +} + +export interface LteStep { + kind: 'lte' + /** Maps response path -> value that response must be less than or equal to */ + assertions: Record +} + +export interface ContainsStep { + kind: 'contains' + /** Maps response path -> value that the array must contain */ + assertions: Record +} + +/** No-op — the skip action is parsed but does not produce output. */ +export interface SkipStep { + kind: 'skip' +}