Skip to content

Commit

Permalink
Lift up composition of test runner
Browse files Browse the repository at this point in the history
Signed-off-by: Thomas Farr <[email protected]>
  • Loading branch information
Xtansia committed Jun 5, 2024
1 parent 5ebceb4 commit 88cc4cb
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 262 deletions.
31 changes: 22 additions & 9 deletions tools/src/OpenSearchHttpClient.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

import { Option } from '@commander-js/extra-typings'
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import * as https from 'node:https'
import { sleep } from '../helpers'

const DEFAULT_URL = 'https://localhost:9200'
const DEFAULT_USER = 'admin'
const DEFAULT_INSECURE = false

export const OPENSEARCH_URL_OPTION = new Option('--opensearch-url <url>', 'URL at which the OpenSearch cluster is accessible')
.default('https://localhost:9200')
.default(DEFAULT_URL)
.env('OPENSEARCH_URL')

export const OPENSEARCH_USERNAME_OPTION = new Option('--opensearch-username <username>', 'username to use when authenticating with OpenSearch')
.default('admin')
.default(DEFAULT_USER)
.env('OPENSEARCH_USERNAME')

export const OPENSEARCH_PASSWORD_OPTION = new Option('--opensearch-password <password>', 'password to use when authenticating with OpenSearch')
.env('OPENSEARCH_PASSWORD')

export const OPENSEARCH_INSECURE_OPTION = new Option('--opensearch-insecure', 'disable SSL/TLS certificate verification when connecting to OpenSearch')
.default(false)
.default(DEFAULT_INSECURE)

export interface OpenSearchHttpClientOptions {
url: string
url?: string
username?: string
password?: string
insecure: boolean
insecure?: boolean
}

export type OpenSearchHttpClientCliOptions = { [K in keyof OpenSearchHttpClientOptions as `opensearch${Capitalize<K>}`]: OpenSearchHttpClientOptions[K] }
Expand Down Expand Up @@ -56,16 +69,16 @@ export interface OpenSearchInfo {
export class OpenSearchHttpClient {
private readonly _axios: AxiosInstance

constructor (opts: OpenSearchHttpClientOptions) {
constructor (opts?: OpenSearchHttpClientOptions) {
this._axios = axios.create({
baseURL: opts.url,
auth: opts.username !== undefined && opts.password !== undefined
baseURL: opts?.url ?? DEFAULT_URL,
auth: opts?.username !== undefined && opts.password !== undefined
? {
username: opts.username,
password: opts.password
}
: undefined,
httpsAgent: new https.Agent({ rejectUnauthorized: !opts.insecure })
httpsAgent: new https.Agent({ rejectUnauthorized: !(opts?.insecure ?? DEFAULT_INSECURE) })
})
}

Expand Down
104 changes: 104 additions & 0 deletions tools/src/tester/ResultLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

import { type ChapterEvaluation, type Evaluation, Result, type StoryEvaluation } from './types/eval.types'
import { overall_result } from './helpers'
import * as ansi from './Ansi'

export interface ResultLogger {
log: (evaluation: StoryEvaluation) => void
}

export class NoOpResultLogger implements ResultLogger {
log (_: StoryEvaluation): void { }
}

export class ConsoleResultLogger implements ResultLogger {
private readonly _tab_width: number
private readonly _verbose: boolean

constructor (tab_width: number = 4, verbose: boolean = false) {
this._tab_width = tab_width
this._verbose = verbose
}

log (evaluation: StoryEvaluation): void {
this.#log_story(evaluation)
this.#log_chapters(evaluation.prologues ?? [], 'PROLOGUES')
this.#log_chapters(evaluation.chapters ?? [], 'CHAPTERS')
this.#log_chapters(evaluation.epilogues ?? [], 'EPILOGUES')
console.log('\n')
}

#log_story ({ result, full_path, description, display_path }: StoryEvaluation): void {
this.#log_evaluation({ result, message: full_path }, ansi.cyan(ansi.b(description ?? display_path)))
}

#log_chapters (evaluations: ChapterEvaluation[], title: string): void {
if (evaluations.length === 0) return
const result = overall_result(evaluations.map(e => e.overall))
if (!this._verbose && (result === Result.SKIPPED || result === Result.PASSED)) return
this.#log_evaluation({ result }, title, this._tab_width)
for (const evaluation of evaluations) this.#log_chapter(evaluation)
}

#log_chapter (chapter: ChapterEvaluation): void {
this.#log_evaluation(chapter.overall, ansi.i(chapter.title), this._tab_width * 2)
this.#log_parameters(chapter.request?.parameters ?? {})
this.#log_request_body(chapter.request?.request_body)
this.#log_status(chapter.response?.status)
this.#log_payload(chapter.response?.payload)
}

#log_parameters (parameters: Record<string, Evaluation>): void {
if (Object.keys(parameters).length === 0) return
const result = overall_result(Object.values(parameters))
this.#log_evaluation({ result }, 'PARAMETERS', this._tab_width * 3)
for (const [name, evaluation] of Object.entries(parameters)) {
this.#log_evaluation(evaluation, name, this._tab_width * 4)
}
}

#log_request_body (evaluation: Evaluation | undefined): void {
if (evaluation == null) return
this.#log_evaluation(evaluation, 'REQUEST BODY', this._tab_width * 3)
}

#log_status (evaluation: Evaluation | undefined): void {
if (evaluation == null) return
this.#log_evaluation(evaluation, 'RESPONSE STATUS', this._tab_width * 3)
}

#log_payload (evaluation: Evaluation | undefined): void {
if (evaluation == null) return
this.#log_evaluation(evaluation, 'RESPONSE PAYLOAD', this._tab_width * 3)
}

#log_evaluation (evaluation: Evaluation, title: string, prefix: number = 0): void {
const result = ansi.padding(this.#result(evaluation.result), 0, prefix)
const message = evaluation.message != null ? `${ansi.gray('(' + evaluation.message + ')')}` : ''
console.log(`${result} ${title} ${message}`)
if (evaluation.error && this._verbose) {
console.log('-'.repeat(100))
console.error(evaluation.error)
console.log('-'.repeat(100))
}
}

#result (r: Result): string {
const text = ansi.padding(r, 7)
switch (r) {
case Result.PASSED: return ansi.green(text)
case Result.SKIPPED: return ansi.yellow(text)
case Result.FAILED: return ansi.magenta(text)
case Result.ERROR: return ansi.red(text)
default: return ansi.gray(text)
}
}
}
101 changes: 0 additions & 101 deletions tools/src/tester/ResultsDisplay.ts

This file was deleted.

20 changes: 9 additions & 11 deletions tools/src/tester/StoryEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,13 @@ export interface StoryFile {
export default class StoryEvaluator {
private readonly _chapter_reader: ChapterReader
private readonly _chapter_evaluator: ChapterEvaluator
private readonly _dry_run: boolean

constructor (chapter_reader: ChapterReader, chapter_evaluator: ChapterEvaluator, dry_run: boolean) {
constructor (chapter_reader: ChapterReader, chapter_evaluator: ChapterEvaluator) {
this._chapter_reader = chapter_reader
this._chapter_evaluator = chapter_evaluator
this._dry_run = dry_run
}

async evaluate ({ story, display_path, full_path }: StoryFile): Promise<StoryEvaluation> {
async evaluate ({ story, display_path, full_path }: StoryFile, dry_run: boolean = false): Promise<StoryEvaluation> {
if (story.skip) {
return {
result: Result.SKIPPED,
Expand All @@ -40,9 +38,9 @@ export default class StoryEvaluator {
chapters: []
}
}
const { evaluations: prologues, has_errors: prologue_errors } = await this.#evaluate_supplemental_chapters(story.prologues ?? [])
const chapters = await this.#evaluate_chapters(story.chapters, prologue_errors)
const { evaluations: epilogues } = await this.#evaluate_supplemental_chapters(story.epilogues ?? [])
const { evaluations: prologues, has_errors: prologue_errors } = await this.#evaluate_supplemental_chapters(story.prologues ?? [], dry_run)
const chapters = await this.#evaluate_chapters(story.chapters, prologue_errors, dry_run)
const { evaluations: epilogues } = await this.#evaluate_supplemental_chapters(story.epilogues ?? [], dry_run)
return {
display_path,
full_path,
Expand All @@ -54,10 +52,10 @@ export default class StoryEvaluator {
}
}

async #evaluate_chapters (chapters: Chapter[], has_errors: boolean): Promise<ChapterEvaluation[]> {
async #evaluate_chapters (chapters: Chapter[], has_errors: boolean, dry_run: boolean): Promise<ChapterEvaluation[]> {
const evaluations: ChapterEvaluation[] = []
for (const chapter of chapters) {
if (this._dry_run) {
if (dry_run) {
const title = chapter.synopsis || `${chapter.method} ${chapter.path}`
evaluations.push({ title, overall: { result: Result.SKIPPED, message: 'Dry Run', error: undefined } })
} else {
Expand All @@ -69,12 +67,12 @@ export default class StoryEvaluator {
return evaluations
}

async #evaluate_supplemental_chapters (chapters: SupplementalChapter[]): Promise<{ evaluations: ChapterEvaluation[], has_errors: boolean }> {
async #evaluate_supplemental_chapters (chapters: SupplementalChapter[], dry_run: boolean): Promise<{ evaluations: ChapterEvaluation[], has_errors: boolean }> {
let has_errors = false
const evaluations: ChapterEvaluation[] = []
for (const chapter of chapters) {
const title = `${chapter.method} ${chapter.path}`
if (this._dry_run) {
if (dry_run) {
evaluations.push({ title, overall: { result: Result.SKIPPED, message: 'Dry Run', error: undefined } })
} else {
const response = await this._chapter_reader.read(chapter)
Expand Down
66 changes: 66 additions & 0 deletions tools/src/tester/TestRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

import type StoryEvaluator from './StoryEvaluator'
import { type StoryFile } from './StoryEvaluator'
import fs from 'fs'
import { type Story } from './types/story.types'
import { read_yaml } from '../../helpers'
import { Result, type StoryEvaluation } from './types/eval.types'
import { type ResultLogger } from './ResultLogger'
import { basename, resolve } from 'path'

export default class TestRunner {
private readonly _story_evaluator: StoryEvaluator
private readonly _result_logger: ResultLogger

constructor (story_evaluator: StoryEvaluator, result_logger: ResultLogger) {
this._story_evaluator = story_evaluator
this._result_logger = result_logger
}

async run (story_path: string, dry_run: boolean = false): Promise<{ evaluations: StoryEvaluation[], failed: boolean }> {
let failed = false
const story_files = this.#sort_story_files(this.#collect_story_files(resolve(story_path), '', ''))
const evaluations: StoryEvaluation[] = []
for (const story_file of story_files) {
const evaluation = await this._story_evaluator.evaluate(story_file, dry_run)
evaluations.push(evaluation)
this._result_logger.log(evaluation)
if ([Result.ERROR, Result.FAILED].includes(evaluation.result)) failed = true
}
return { evaluations, failed }
}

#collect_story_files (folder: string, file: string, prefix: string): StoryFile[] {
const path = file === '' ? folder : `${folder}/${file}`
const next_prefix = prefix === '' ? file : `${prefix}/${file}`
if (fs.statSync(path).isFile()) {
const story: Story = read_yaml(path)
return [{
display_path: next_prefix === '' ? basename(path) : next_prefix,
full_path: path,
story
}]
} else {
return fs.readdirSync(path).flatMap(next_file => {
return this.#collect_story_files(path, next_file, next_prefix)
})
}
}

#sort_story_files (story_files: StoryFile[]): StoryFile[] {
return story_files.sort(({ display_path: a }, { display_path: b }) => {
const a_depth = a.split('/').length
const b_depth = b.split('/').length
if (a_depth !== b_depth) return a_depth - b_depth
return a.localeCompare(b)
})
}
}
Loading

0 comments on commit 88cc4cb

Please sign in to comment.