diff --git a/.github/shared/package.json b/.github/shared/package.json index 085f537a7621..2ad32e0e0d16 100644 --- a/.github/shared/package.json +++ b/.github/shared/package.json @@ -6,8 +6,10 @@ "./array": "./src/array.js", "./changed-files": "./src/changed-files.js", "./equality": "./src/equality.js", + "./error-reporting": "./src/error-reporting.js", "./exec": "./src/exec.js", "./logger": "./src/logger.js", + "./path": "./src/path.js", "./readme": "./src/readme.js", "./sdk-types": "./src/sdk-types.js", "./sleep": "./src/sleep.js", diff --git a/.github/shared/src/error-reporting.js b/.github/shared/src/error-reporting.js new file mode 100644 index 000000000000..71a71a9eab9d --- /dev/null +++ b/.github/shared/src/error-reporting.js @@ -0,0 +1,33 @@ +// @ts-check +import * as fs from "fs"; + +/** + * Set the summary of the github step summary for a job. This feature is intended for formatted markdown, + * which can be used to display the results of a job in a more readable format. + * + * Format your results as a markdown table and go to town! + * @param {string} content + * @returns {void} + */ +export function setSummary(content) { + if (!process.env.GITHUB_STEP_SUMMARY) { + console.log("GITHUB_STEP_SUMMARY is not set. Skipping summary update."); + return; + } + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + + fs.writeFileSync(summaryFile, content); +} + +/** + * This function is used to ask the github agent to annotate a file in a github PR with an error message. + * @param {string} repoPath + * @param {string} message + * @param {number} line + * @param {number} col + * @returns {void} + */ +export function annotateFileError(repoPath, message, line, col) { + const errorLine = `::error file=${repoPath},line=${line},col=${col}::${message}`; + console.log(errorLine); +} diff --git a/.github/shared/src/path.js b/.github/shared/src/path.js new file mode 100644 index 000000000000..35382461bfad --- /dev/null +++ b/.github/shared/src/path.js @@ -0,0 +1,13 @@ +// @ts-check + +import { resolve, sep } from "path"; + +/** + * + * @param {string} path + * @param {string} folder + * @returns {boolean} True if path contains the named folder + */ +export function includesFolder(path, folder) { + return resolve(path).includes(sep + folder + sep); +} diff --git a/.github/shared/src/swagger.js b/.github/shared/src/swagger.js index 7843d6959f94..da2bdfb509d2 100644 --- a/.github/shared/src/swagger.js +++ b/.github/shared/src/swagger.js @@ -4,6 +4,7 @@ import $RefParser, { ResolverError } from "@apidevtools/json-schema-ref-parser"; import { readFile } from "fs/promises"; import { dirname, relative, resolve } from "path"; import { mapAsync } from "./array.js"; +import { includesFolder } from "./path.js"; import { SpecModelError } from "./spec-model-error.js"; /** @@ -58,6 +59,15 @@ export class Swagger { * @returns {Promise>} */ async getRefs() { + const allRefs = await this.#getRefs(); + + // filter out any paths that are examples + const filtered = new Map([...allRefs].filter(([path]) => !example(path))); + + return filtered; + } + + async #getRefs() { if (!this.#refs) { let schema; try { @@ -82,8 +92,6 @@ export class Swagger { const refPaths = schema .paths("file") - // Exclude examples - .filter((p) => !example(p)) // Exclude ourself .filter((p) => resolve(p) !== resolve(this.#path)); @@ -101,6 +109,18 @@ export class Swagger { return this.#refs; } + /** + * @returns {Promise>} + */ + async getExamples() { + const allRefs = await this.#getRefs(); + + // filter out any paths that are examples + const filtered = new Map([...allRefs].filter(([path]) => example(path))); + + return filtered; + } + /** * @returns {string} absolute path */ @@ -151,7 +171,9 @@ export class Swagger { */ function example(file) { // Folder name "examples" should match case for consistency across specs - return typeof file === "string" && json(file) && file.includes("/examples/"); + return ( + typeof file === "string" && json(file) && includesFolder(file, "examples") + ); } /** diff --git a/.github/shared/test/error-reporting.test.js b/.github/shared/test/error-reporting.test.js new file mode 100644 index 000000000000..358ab28d5cfd --- /dev/null +++ b/.github/shared/test/error-reporting.test.js @@ -0,0 +1,53 @@ +// @ts-check + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { setSummary, annotateFileError } from "../src/error-reporting.js"; +import fs from "fs/promises"; + +describe("ErrorReporting", () => { + let logSpy; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + // ensure that on test runs GITHUB_STEP_SUMMARY is not set in my current env by default + // this gives us a clean slate for each test + delete process.env.GITHUB_STEP_SUMMARY; + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it("should warn when GITHUB_STEP_SUMMARY is unset", () => { + setSummary("hello"); + expect(logSpy).toHaveBeenCalledWith( + "GITHUB_STEP_SUMMARY is not set. Skipping summary update.", + ); + }); + + it("should write to the summary file when GITHUB_STEP_SUMMARY is set", async () => { + process.env.GITHUB_STEP_SUMMARY = `${__dirname}/tmp-summary.md`; + + await fs.rm(process.env.GITHUB_STEP_SUMMARY, { force: true }); + + setSummary("# Title"); + + expect(logSpy).not.toHaveBeenCalledWith( + "GITHUB_STEP_SUMMARY is not set. Skipping summary update.", + ); + + const content = await fs.readFile(process.env.GITHUB_STEP_SUMMARY, "utf-8"); + + // cleanup after the test so nothing is left behi + await fs.rm(process.env.GITHUB_STEP_SUMMARY, { force: true }); + + expect(content).toBe("# Title"); + }); + + it("should emit a GitHub-style error annotation", () => { + annotateFileError("src/foo.js", "Something broke", 42, 7); + expect(logSpy).toHaveBeenCalledWith( + "::error file=src/foo.js,line=42,col=7::Something broke", + ); + }); +}); diff --git a/.github/workflows/oav-runner-tests.yaml b/.github/workflows/oav-runner-tests.yaml new file mode 100644 index 000000000000..8e3a8e9e91a0 --- /dev/null +++ b/.github/workflows/oav-runner-tests.yaml @@ -0,0 +1,30 @@ +name: OAV Runner - Tests + +on: + push: + branches: + - main + pull_request: + paths: + - package-lock.json + - package.json + - tsconfig.json + - .github/shared + - .github/workflows/_reusable-eng-tools-test.yaml + - .github/workflows/oav-runner-test.yaml + - eng/tools/package.json + - eng/tools/tsconfig.json + - eng/tools/oav-runner/** + workflow_dispatch: + +permissions: + contents: read + +jobs: + oavrunnertests: + name: Check OAV Runner + uses: ./.github/workflows/_reusable-eng-tools-test.yaml + with: + package: oav-runner + lint: false + prettier: false diff --git a/.github/workflows/swagger-modelvalidation-code.yaml b/.github/workflows/swagger-modelvalidation-code.yaml new file mode 100644 index 000000000000..83185d637e9e --- /dev/null +++ b/.github/workflows/swagger-modelvalidation-code.yaml @@ -0,0 +1,25 @@ +name: "[TEST-IGNORE] Swagger ModelValidation" + +on: pull_request + +permissions: + contents: read + +jobs: + oav: + name: "[TEST-IGNORE] Swagger ModelValidation" + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup Node and install deps + uses: ./.github/actions/setup-node-install-deps + + - name: Swagger Model Validation + run: | + npm exec --no -- oav-runner examples + diff --git a/.github/workflows/swagger-modelvalidation-status.yaml b/.github/workflows/swagger-modelvalidation-status.yaml new file mode 100644 index 000000000000..318c6cb59151 --- /dev/null +++ b/.github/workflows/swagger-modelvalidation-status.yaml @@ -0,0 +1,35 @@ +name: "[TEST-IGNORE] Swagger ModelValidation - Set Status" + +on: + # Must run on pull_request_target instead of pull_request, since the latter cannot trigger on + # labels from bot accounts in fork PRs. pull_request_target is also more similar to the other + # trigger "workflow_run" -- they are both privileged and run in the target branch and repo -- + # which simplifies implementation. + pull_request_target: + types: + # Run workflow on default types, to update status as quickly as possible. + - opened + - synchronize + - reopened + # Depends on labels, so must re-evaluate whenever a relevant label is manually added or removed. + - labeled + - unlabeled + workflow_run: + workflows: ["\\[TEST-IGNORE\\] Swagger ModelValidation"] + types: [completed] + +permissions: + actions: read + contents: read + issues: read + pull-requests: read + statuses: write + +jobs: + example-validation-status: + name: Set Example Validation Status + uses: ./.github/workflows/_reusable-set-check-status.yml + with: + monitored_workflow_name: "[TEST-IGNORE] Swagger ModelValidation" + required_check_name: "[TEST-IGNORE] Swagger ModelValidation" + overriding_label: "Approved-ModelValidation" diff --git a/.github/workflows/swagger-semanticvalidation-code.yaml b/.github/workflows/swagger-semanticvalidation-code.yaml new file mode 100644 index 000000000000..8bdde81b473c --- /dev/null +++ b/.github/workflows/swagger-semanticvalidation-code.yaml @@ -0,0 +1,25 @@ +name: "[TEST-IGNORE] Swagger SemanticValidation" + +on: pull_request + +permissions: + contents: read + +jobs: + oav: + name: "[TEST-IGNORE] Swagger SemanticValidation" + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup Node and install deps + uses: ./.github/actions/setup-node-install-deps + + - name: Swagger Semantic Validation + run: | + npm exec --no -- oav-runner specs + diff --git a/.github/workflows/swagger-semanticvalidation-status.yaml b/.github/workflows/swagger-semanticvalidation-status.yaml new file mode 100644 index 000000000000..ca524b728d2e --- /dev/null +++ b/.github/workflows/swagger-semanticvalidation-status.yaml @@ -0,0 +1,35 @@ +name: "[TEST-IGNORE] Swagger ModelValidation - Set Status" + +on: + # Must run on pull_request_target instead of pull_request, since the latter cannot trigger on + # labels from bot accounts in fork PRs. pull_request_target is also more similar to the other + # trigger "workflow_run" -- they are both privileged and run in the target branch and repo -- + # which simplifies implementation. + pull_request_target: + types: + # Run workflow on default types, to update status as quickly as possible. + - opened + - synchronize + - reopened + # Depends on labels, so must re-evaluate whenever a relevant label is manually added or removed. + - labeled + - unlabeled + workflow_run: + workflows: ["\\[TEST-IGNORE\\] Swagger SemanticValidation"] + types: [completed] + +permissions: + actions: read + contents: read + issues: read + pull-requests: read + statuses: write + +jobs: + spec-validation-status: + name: Set SemanticValidation Status + uses: ./.github/workflows/_reusable-set-check-status.yml + with: + monitored_workflow_name: "[TEST-IGNORE] Swagger SemanticValidation" + required_check_name: "[TEST-IGNORE] Swagger SemanticValidation" + overriding_label: "Approved-SemanticValidation" diff --git a/eng/tools/oav-runner/README.md b/eng/tools/oav-runner/README.md new file mode 100644 index 000000000000..c690f9fcaf33 --- /dev/null +++ b/eng/tools/oav-runner/README.md @@ -0,0 +1,12 @@ +# `oav-runner` + +This is a simple wrapper script around the `oav` tool. It utilizes shared js code code modules from `.github/shared` to +determine a list of swagger specs that should be processed, processes them, then outputs necessary detailed run +information. + +## Invocation shortcuts + +``` +cd +npm ci && npm exec --no -- oav-runner <"specs"/"examples"> +``` \ No newline at end of file diff --git a/eng/tools/oav-runner/cmd/oav-runner.js b/eng/tools/oav-runner/cmd/oav-runner.js new file mode 100755 index 000000000000..abdd7016b6cf --- /dev/null +++ b/eng/tools/oav-runner/cmd/oav-runner.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from "../dist/src/cli.js"; + +await main(); diff --git a/eng/tools/oav-runner/package.json b/eng/tools/oav-runner/package.json new file mode 100644 index 000000000000..68f30fbf6090 --- /dev/null +++ b/eng/tools/oav-runner/package.json @@ -0,0 +1,31 @@ +{ + "name": "@azure-tools/oav-runner", + "private": true, + "type": "module", + "main": "dist/src/main.js", + "bin": { + "oav-runner": "cmd/oav-runner.js" + }, + "scripts": { + "build": "tsc --build", + "test": "vitest", + "test:ci": "vitest run --coverage --reporter=verbose", + "prettier": "prettier \"**/*.js\" --check", + "prettier:write": "prettier \"**/*.js\" --write" + }, + "dependencies": { + "@azure-tools/specs-shared": "file:../../../.github/shared", + "js-yaml": "^4.1.0", + "oav": "3.5.1", + "simple-git": "^3.27.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "~5.8.2", + "vitest": "^3.0.7", + "prettier": "~3.5.3" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/eng/tools/oav-runner/src/cli.ts b/eng/tools/oav-runner/src/cli.ts new file mode 100644 index 000000000000..5291102025cd --- /dev/null +++ b/eng/tools/oav-runner/src/cli.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { checkSpecs, checkExamples } from "./runner.js"; +import { + outputAnnotatedErrors, + outputErrorSummary, + outputSuccessSummary, + ReportableOavError, +} from "./formatting.js"; + +import { parseArgs, ParseArgsConfig } from "node:util"; + +export async function main() { + const config: ParseArgsConfig = { + options: { + targetDirectory: { + type: "string", + short: "d", + multiple: false, + default: process.cwd(), + }, + }, + allowPositionals: true, + }; + + const { values: opts, positionals } = parseArgs(config); + // this option has a default value of process.cwd(), so we can assume it is always defined + // just need to resolve that here to make ts aware of it + const targetDirectory = opts.targetDirectory as string; + + // first positional is runType + const [runType] = positionals; + + if (runType !== "specs" && runType !== "examples") { + console.error("Error: must be either 'specs' or 'examples'."); + process.exit(1); + } + + console.log( + `Running oav-runner against ${runType} within ${targetDirectory}.`, + ); + + let exitCode = 0; + let scannedSwaggerFiles: string[] = []; + let errorList: ReportableOavError[] = []; + let reportName = ""; + + if (runType === "specs") { + [exitCode, scannedSwaggerFiles, errorList] = + await checkSpecs(targetDirectory); + reportName = "Swagger SemanticValidation"; + } else if (runType === "examples") { + [exitCode, scannedSwaggerFiles, errorList] = + await checkExamples(targetDirectory); + reportName = "Swagger ModelValidation"; + } + + if (errorList.length > 0) { + // print the errors so that they will annotate the files on github UI interface + outputAnnotatedErrors(errorList); + + // print the errors in a summary report that we can later output to + outputErrorSummary(errorList, reportName); + } else { + outputSuccessSummary(scannedSwaggerFiles, reportName); + } + + process.exit(exitCode); +} diff --git a/eng/tools/oav-runner/src/formatting.ts b/eng/tools/oav-runner/src/formatting.ts new file mode 100644 index 000000000000..147677e56739 --- /dev/null +++ b/eng/tools/oav-runner/src/formatting.ts @@ -0,0 +1,110 @@ +import { + annotateFileError, + setSummary, +} from "@azure-tools/specs-shared/error-reporting"; + +export interface ReportableOavError { + message: string; + file: string; + errorCode?: string; + line?: number; + column?: number; +} + +export function outputAnnotatedErrors(errors: ReportableOavError[]) { + errors.forEach((error) => { + let msg: string = `${error.message}`; + + if (error.errorCode) { + msg = `${error.errorCode}: ${msg}`; + } + + // we only attempt an in-place annotation if we have the line and column associated with the error + // otherwise we just depend upon the summary report to show the error + if (error.line && error.column) { + annotateFileError(error.file, msg, error.line, error.column); + } + }); +} + +export function outputSuccessSummary( + swaggerFiles: string[], + reportName: string, +) { + let builtLines: string[] = []; + + builtLines.push(`## All specifications passed ${reportName}`); + builtLines.push("| File | Status |"); + builtLines.push("| --- | --- |"); + for (const swaggerFile of swaggerFiles) { + builtLines.push(`| ${swaggerFile} | ✅ |`); + } + + const summaryResult = builtLines.join("\n"); + + if (process.env.GITHUB_STEP_SUMMARY) { + setSummary(summaryResult); + } else { + console.log(summaryResult); + } +} + +export function outputErrorSummary( + errors: ReportableOavError[], + reportName: string, +) { + let builtLines: string[] = []; + let checkName: string = ""; + + builtLines.push(`## Error Summary - ${reportName}`); + + // just mapping the report names we want to migrate to the old names here, so we don't have to pull it through everywhere when we want to change it + if (reportName === "Swagger SemanticValidation") { + checkName = "validate-spec"; + } + else if (reportName === "Swagger ModelValidation") { + checkName = "validate-example"; + } + + builtLines.push( + `⚠️ This check is testing a new version of '${reportName}'. ⚠️`, + ); + builtLines.push( + "Failures are expected, and should be completely ignored by spec authors and reviewers.", + ); + builtLines.push( + `Meaningful results for this PR are in required check '${reportName}'.`, + ); + builtLines.push("| File | Line#Column | Code | Message |"); + builtLines.push("| --- | --- | --- | --- |"); + + // sort the errors by file name then by error code + errors.sort((a, b) => { + const nameCompare = a.file.localeCompare(b.file); + if (nameCompare !== 0) { + return nameCompare; + } + return (a.errorCode || "").localeCompare(b.errorCode || ""); + }); + + errors.forEach((error) => { + const fmtLineCol = + error.line && error.column ? `${error.line}#${error.column}` : "N/A"; + builtLines.push( + `| ${error.file} | ${fmtLineCol} | ${error.errorCode} | ${error.message} |`, + ); + }); + + builtLines.push("\n"); + builtLines.push( + `> [!IMPORTANT]\n> Repro any individual file's worth of errors by invoking \`npx oav ${checkName} \` from the root of the rest-api-specs repo.`, + ); + + const summaryResult = builtLines.join("\n"); + + if (process.env.GITHUB_STEP_SUMMARY) { + setSummary(summaryResult); + } else { + console.log(summaryResult); + } +} diff --git a/eng/tools/oav-runner/src/runner.ts b/eng/tools/oav-runner/src/runner.ts new file mode 100644 index 000000000000..0e1e4b719f1c --- /dev/null +++ b/eng/tools/oav-runner/src/runner.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +import * as oav from "oav"; +import * as path from "path"; +import * as fs from "fs"; + +import { Swagger } from "@azure-tools/specs-shared/swagger"; +import { includesFolder } from "@azure-tools/specs-shared/path"; +import { + getChangedFiles, +} from "@azure-tools/specs-shared/changed-files"; //getChangedFiles, +import { ReportableOavError } from "./formatting.js"; + +export async function checkExamples( + rootDirectory: string, +): Promise<[number, string[], ReportableOavError[]]> { + let errors: ReportableOavError[] = []; + + const changedFiles = await getChangedFiles({ + cwd: rootDirectory, + }); + + const swaggerFiles = await processFilesToSpecificationList( + rootDirectory, + changedFiles, + ); + + for (const swaggerFile of swaggerFiles) { + try { + const errorResults = await oav.validateExamples(swaggerFile, undefined); + + for (const error of errorResults || []) { + errors.push({ + message: error.message, + errorCode: error.code, + file: error.exampleUrl, + line: error.examplePosition?.line, + column: error.examplePosition?.column, + } as ReportableOavError); + } + } catch (e) { + if (e instanceof Error) { + console.log( + `Error validating examples for ${swaggerFile}: ${e.message}`, + ); + errors.push({ + message: e.message, + file: swaggerFile, + } as ReportableOavError); + } else { + console.log( + `Error validating examples for ${swaggerFile}: ${e}`, + ); + errors.push({ + message: `Unhandled error validating ${swaggerFile}: ${e}`, + file: swaggerFile, + } as ReportableOavError); + } + } + } + + if (errors.length > 0) { + return [1, swaggerFiles, errors]; + } + return [0, swaggerFiles, []]; +} + +export async function checkSpecs( + rootDirectory: string, +): Promise<[number, string[], ReportableOavError[]]> { + let errors: ReportableOavError[] = []; + + const changedFiles = await getChangedFiles({ + cwd: rootDirectory, + }); + + const swaggerFiles = await processFilesToSpecificationList( + rootDirectory, + changedFiles, + ); + + for (const swaggerFile of swaggerFiles) { + try { + const errorResults = await oav.validateSpec(swaggerFile, undefined); + if (errorResults.validateSpec && errorResults.validateSpec.errors) { + for (const error of errorResults.validateSpec.errors) { + errors.push({ + message: error.message, + errorCode: error.code, + file: swaggerFile, + line: error.position?.line, + column: error.position?.column, + } as ReportableOavError); + } + } + } catch (e) { + if (e instanceof Error) { + console.log(`Error validating ${swaggerFile}: ${e.message}`); + errors.push({ + message: e.message, + file: swaggerFile, + } as ReportableOavError); + } else { + console.log(`Error validating ${swaggerFile}: ${e}`); + errors.push({ + message: `Unhandled error validating ${swaggerFile}: ${e}`, + file: swaggerFile, + } as ReportableOavError); + } + } + } + + if (errors.length > 0) { + return [1, swaggerFiles, errors]; + } + return [0, swaggerFiles, []]; +} + +async function getFiles( + rootDirectory: string, + directory: string, +): Promise { + const target = path.join(rootDirectory, directory); + const items = await fs.promises.readdir(target, { + withFileTypes: true, + }); + + + return items + .filter((d) => d.isFile() && d.name.endsWith(".json")) + .map((d) => path.join(target, d.name)) + .map((d) => d.replace(/^.*?(specification[\/\\].*)$/, "$1")) + .filter((d) => d.includes("specification" + path.sep)); +} + +function example(file: string): boolean { + return ( + typeof file === "string" && + file.toLowerCase().endsWith(".json") && + includesFolder(file, "examples") + ); +} + +function swagger(file: string): boolean { + return ( + typeof file === "string" && + file.toLowerCase().endsWith(".json") && + (includesFolder(file, "data-plane") || includesFolder(file, "resource-manager")) && + includesFolder(file, "specification") && + !includesFolder(file, "examples") + ) +} + +export async function processFilesToSpecificationList( + rootDirectory: string, + files: string[], +): Promise { + const cachedSwaggerSpecs = new Map(); + const resultFiles: string[] = []; + const additionalSwaggerFiles: string[] = []; + + // files from get-changed-files are relative to the root of the repo, + // though that context is passed into this from cli arguments. + for (const file of files) { + const absoluteFilePath = path.join(rootDirectory, file); + + // if the file is an example, we need to find the swagger file that references it + if (example(file)) { + /* + examples exist in the same directory as the swagger file that references them: + + path/to/swagger/2024-01-01/examples/example.json <-- this is an example file path + path/to/swagger/2024-01-01/swagger.json <-- we need to identify this file if it references the example + path/to/swagger/2024-01-01/swagger2.json <-- and do nothing with this one + */ + const swaggerDir = path.dirname(path.dirname(file)); + + const visibleSwaggerFiles = await getFiles(rootDirectory, swaggerDir); + + for (const swaggerFile of visibleSwaggerFiles) { + if (!cachedSwaggerSpecs.has(swaggerFile)) { + const swaggerModel = new Swagger( + path.join(rootDirectory, swaggerFile), + ); + const exampleSwaggers = await swaggerModel.getExamples(); + const examples = [...exampleSwaggers].map((e) => e[1].path); + cachedSwaggerSpecs.set(swaggerFile, examples); + } + const referencedExamples = cachedSwaggerSpecs.get(swaggerFile); + + // the resolved files are absolute paths, so to compare them to the file we're looking at, we need + // to use the absolute path version of the example file. + if (referencedExamples?.indexOf(absoluteFilePath) !== -1) { + // unfortunately, we get lists of files in posix format from get-changed-files. because of this, when are are grabbing a + // resolved swagger file, we need to ensure we are using the posix version of the path as well. If we do not do this, + // if we change an example and a spec, we will end up resolving the changed spec twice, one with the posix path (from changed-files) + // and one with the windows path (resolved from the swagger model which we pulled refs from to determine which example belonged to which swagger) + additionalSwaggerFiles.push(swaggerFile.replace(/\\/g, "/")); + } + } + } + + // finally handle our base case where the file we're examining is itself a swagger file + if (swagger(file) && fs.existsSync(absoluteFilePath)) { + resultFiles.push(file); + } + } + + // combine and make the results unique + return Array.from(new Set([...resultFiles, ...additionalSwaggerFiles])); +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceA/resource-manager/service.A/readme.md b/eng/tools/oav-runner/test/fixtures/specification/serviceA/resource-manager/service.A/readme.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceA/resource-manager/service.A/stable/2025-06-01/serviceAspec.json b/eng/tools/oav-runner/test/fixtures/specification/serviceA/resource-manager/service.A/stable/2025-06-01/serviceAspec.json new file mode 100644 index 000000000000..13b5261fe80e --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceA/resource-manager/service.A/stable/2025-06-01/serviceAspec.json @@ -0,0 +1,35 @@ +{ + "swagger": "2.0", + "info": { + "title": "Service A", + "version": "1.0.0" + }, + "paths": { + "/c": { + "get": { + "summary": "Get A", + "responses": { + "200": { + "description": "Successful response", + "schema": { + "$ref": "#/definitions/C" + } + } + } + } + } + }, + "definitions": { + "C": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/readme.md b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/readme.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/CreateResource.json b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/CreateResource.json new file mode 100644 index 000000000000..e1388a21c597 --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/CreateResource.json @@ -0,0 +1,27 @@ +{ + "parameters": { + "api-version": "2025-06-01", + "resource": { + "name": "New Resource", + "properties": { + "description": "A new resource created via the API", + "tags": ["test", "new", "sample"] + } + } + }, + "responses": { + "201": { + "body": { + "id": "resource-456", + "name": "New Resource", + "type": "ServiceB/Resource", + "properties": { + "description": "A new resource created via the API", + "tags": ["test", "new", "sample"], + "status": "Provisioning", + "createdAt": "2025-06-02T10:30:00Z" + } + } + } + } +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/DeleteResource.json b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/DeleteResource.json new file mode 100644 index 000000000000..2d92d40d1c19 --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/DeleteResource.json @@ -0,0 +1,9 @@ +{ + "parameters": { + "api-version": "2025-06-01", + "resourceId": "resource-123" + }, + "responses": { + "204": {} + } +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetResource.json b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetResource.json new file mode 100644 index 000000000000..9187a08b00a6 --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetResource.json @@ -0,0 +1,20 @@ +{ + "parameters": { + "api-version": "2025-06-01", + "resourceId": "resource-123" + }, + "responses": { + "200": { + "body": { + "id": "resource-123", + "name": "Example Resource", + "type": "ServiceB/Resource", + "properties": { + "status": "Active", + "createdAt": "2025-05-30T15:30:45Z", + "lastModifiedAt": "2025-06-01T09:15:22Z" + } + } + } + } +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetRoot.json b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetRoot.json new file mode 100644 index 000000000000..c0dcb5926703 --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetRoot.json @@ -0,0 +1,14 @@ +{ + "parameters": { + "api-version": "2025-06-01" + }, + "responses": { + "200": { + "body": { + "status": "OK", + "message": "Service is running", + "version": "1.0.0" + } + } + } +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/ListResources.json b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/ListResources.json new file mode 100644 index 000000000000..d0d3cfbc0a5d --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/ListResources.json @@ -0,0 +1,34 @@ +{ + "parameters": { + "api-version": "2025-06-01", + "$skip": 0, + "$top": 10 + }, + "responses": { + "200": { + "body": { + "value": [ + { + "id": "resource-123", + "name": "Example Resource", + "type": "ServiceB/Resource", + "properties": { + "status": "Active", + "createdAt": "2025-05-30T15:30:45Z" + } + }, + { + "id": "resource-456", + "name": "New Resource", + "type": "ServiceB/Resource", + "properties": { + "status": "Provisioning", + "createdAt": "2025-06-02T10:30:00Z" + } + } + ], + "nextLink": "https://service.b/api/resources?api-version=2025-06-01&$skip=10&$top=10" + } + } + } +} diff --git a/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json new file mode 100644 index 000000000000..6f4b03b90926 --- /dev/null +++ b/eng/tools/oav-runner/test/fixtures/specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json @@ -0,0 +1,436 @@ +{ + "swagger": "2.0", + "info": { + "title": "Service B", + "version": "1.0.0", + "description": "API for Service B data plane operations" + }, + "host": "service.b", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/": { + "get": { + "tags": [ + "Status" + ], + "summary": "Get Service Status", + "description": "Returns the current status of the service.", + "operationId": "Service_GetStatus", + "parameters": [ + { + "$ref": "#/parameters/ApiVersionParameter" + } + ], + "responses": { + "200": { + "description": "OK - Returns service status information.", + "schema": { + "$ref": "#/definitions/ServiceStatus" + } + }, + "default": { + "description": "Error response", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-ms-examples": { + "Get service status": { + "$ref": "./examples/GetRoot.json" + } + } + } + }, + "/resources": { + "get": { + "tags": [ + "Resources" + ], + "summary": "List Resources", + "description": "Lists all resources in the service.", + "operationId": "Resources_List", + "parameters": [ + { + "$ref": "#/parameters/ApiVersionParameter" + }, + { + "name": "$skip", + "in": "query", + "description": "Skip the first n items", + "type": "integer", + "default": 0, + "minimum": 0 + }, + { + "name": "$top", + "in": "query", + "description": "Return only the first n items", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100 + } + ], + "responses": { + "200": { + "description": "OK - Returns a list of resources", + "schema": { + "$ref": "#/definitions/ResourceList" + } + }, + "default": { + "description": "Error response", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-ms-examples": { + "List resources": { + "$ref": "./examples/ListResources.json" + } + }, + "x-ms-pageable": { + "nextLinkName": "nextLink" + } + }, + "post": { + "tags": [ + "Resources" + ], + "summary": "Create a Resource", + "description": "Creates a new resource in the service.", + "operationId": "Resources_Create", + "parameters": [ + { + "$ref": "#/parameters/ApiVersionParameter" + }, + { + "name": "resource", + "in": "body", + "description": "Resource to create", + "required": true, + "schema": { + "$ref": "#/definitions/ResourceCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created - Returns the created resource", + "schema": { + "$ref": "#/definitions/Resource" + } + }, + "default": { + "description": "Error response", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-ms-examples": { + "Create resource": { + "$ref": "./examples/CreateResource.json" + } + } + } + }, + "/resources/{resourceId}": { + "get": { + "tags": [ + "Resources" + ], + "summary": "Get Resource", + "description": "Gets a specific resource by its ID.", + "operationId": "Resources_Get", + "parameters": [ + { + "$ref": "#/parameters/ApiVersionParameter" + }, + { + "name": "resourceId", + "in": "path", + "description": "ID of the resource to retrieve", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK - Returns the requested resource", + "schema": { + "$ref": "#/definitions/Resource" + } + }, + "404": { + "description": "Not Found - The resource does not exist", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "default": { + "description": "Error response", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-ms-examples": { + "Get resource": { + "$ref": "./examples/GetResource.json" + } + } + }, + "delete": { + "tags": [ + "Resources" + ], + "summary": "Delete Resource", + "description": "Deletes a specific resource by its ID.", + "operationId": "Resources_Delete", + "parameters": [ + { + "$ref": "#/parameters/ApiVersionParameter" + }, + { + "name": "resourceId", + "in": "path", + "description": "ID of the resource to delete", + "required": true, + "type": "string" + } + ], + "responses": { + "204": { + "description": "No Content - The resource was successfully deleted" + }, + "404": { + "description": "Not Found - The resource does not exist", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "default": { + "description": "Error response", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-ms-examples": { + "Delete resource": { + "$ref": "./examples/DeleteResource.json" + } + } + } + } + }, + "definitions": { + "ServiceStatus": { + "description": "Represents the status of the service", + "type": "object", + "properties": { + "status": { + "description": "The status of the service", + "type": "string", + "enum": [ + "OK", + "Degraded", + "Unavailable" + ], + "x-ms-enum": { + "name": "ServiceStatusEnum", + "modelAsString": true + } + }, + "message": { + "description": "A message providing details about the service status", + "type": "string" + }, + "version": { + "description": "The version of the service", + "type": "string" + } + } + }, + "Resource": { + "description": "Represents a resource in Service B", + "type": "object", + "properties": { + "id": { + "description": "The unique identifier of the resource", + "type": "string", + "readOnly": true + }, + "name": { + "description": "The name of the resource", + "type": "string" + }, + "type": { + "description": "The type of the resource", + "type": "string", + "readOnly": true + }, + "properties": { + "description": "The properties of the resource", + "type": "object", + "properties": { + "description": { + "description": "Description of the resource", + "type": "string" + }, + "status": { + "description": "The status of the resource", + "type": "string", + "enum": [ + "Active", + "Inactive", + "Provisioning", + "Failed" + ], + "x-ms-enum": { + "name": "ResourceStatusEnum", + "modelAsString": true + } + }, + "tags": { + "description": "Tags associated with the resource", + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "description": "The timestamp when the resource was created", + "type": "string", + "format": "date-time", + "readOnly": true + }, + "lastModifiedAt": { + "description": "The timestamp when the resource was last modified", + "type": "string", + "format": "date-time", + "readOnly": true + } + } + } + } + }, + "ResourceCreateRequest": { + "description": "Request body for creating a resource", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "The name of the resource", + "type": "string" + }, + "properties": { + "description": "The properties of the resource", + "type": "object", + "properties": { + "description": { + "description": "Description of the resource", + "type": "string" + }, + "tags": { + "description": "Tags associated with the resource", + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "ResourceList": { + "description": "A paged list of resources", + "type": "object", + "properties": { + "value": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, + "nextLink": { + "description": "The URL to get the next set of results, if there are any", + "type": "string" + } + } + }, + "ErrorResponse": { + "description": "Error response", + "type": "object", + "properties": { + "error": { + "description": "The error details", + "type": "object", + "properties": { + "code": { + "description": "Error code", + "type": "string" + }, + "message": { + "description": "Error message", + "type": "string" + }, + "target": { + "description": "Error target", + "type": "string" + }, + "details": { + "description": "Error details", + "type": "array", + "items": { + "$ref": "#/definitions/ErrorDetail" + } + } + } + } + } + }, + "ErrorDetail": { + "description": "Error detail", + "type": "object", + "properties": { + "code": { + "description": "Error code", + "type": "string" + }, + "message": { + "description": "Error message", + "type": "string" + }, + "target": { + "description": "Error target", + "type": "string" + } + } + } + }, + "parameters": { + "ApiVersionParameter": { + "name": "api-version", + "in": "query", + "description": "The API version to use for this operation", + "required": true, + "type": "string", + "default": "2025-06-01" + } + } +} diff --git a/eng/tools/oav-runner/test/runner.test.ts b/eng/tools/oav-runner/test/runner.test.ts new file mode 100644 index 000000000000..a8ae8ad01c4d --- /dev/null +++ b/eng/tools/oav-runner/test/runner.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { processFilesToSpecificationList } from "../src/runner.js"; +import path from "path"; + +const ROOT = path.resolve(__dirname, "..", "test", "fixtures"); + +describe("file processing", () => { + it("should process a basic set of files and return a list of swagger files only", async () => { + const changedFiles = [ + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json", + "specification/serviceB/data-plane/service.B/readme.md", + ]; + const expected = [ + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json", + ]; + + const result = await processFilesToSpecificationList(ROOT, changedFiles); + expect(result).toEqual(expected); + }); + + it("should process a larger set of files and return a list of expected resolved swagger files", async () => { + const changedFiles = [ + "specification/serviceA/resource-manager/service.A/stable/2025-06-01/serviceAspec.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/CreateResource.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/DeleteResource.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetResource.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/GetRoot.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/ListResources.json", + ]; + const expected = [ + "specification/serviceA/resource-manager/service.A/stable/2025-06-01/serviceAspec.json", + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json", + ]; + + const result = await processFilesToSpecificationList(ROOT, changedFiles); + expect(result).toEqual(expected); + }); + + it("should process the correct swagger file given only changed example files", async () => { + const changedFiles = [ + "specification/serviceB/data-plane/service.B/stable/2025-06-01/examples/CreateResource.json", + ]; + const expected = [ + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json" + ]; + + const result = await processFilesToSpecificationList(ROOT, changedFiles); + expect(result).toEqual(expected); + }); + + it("should process the correct swagger file given only changed readme file", async () => { + const changedFiles = ["specification/serviceB/data-plane/service.B/readme.md"]; + const expected: string[] = []; + + const result = await processFilesToSpecificationList(ROOT, changedFiles); + expect(result).toEqual(expected); + }); + + it("should handle deleted files without error", async () => { + const changedFiles = [ + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json", + // non-existent file. Should not throw and quietly omit + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspecDeleted.json", + ]; + const expected = [ + "specification/serviceB/data-plane/service.B/stable/2025-06-01/serviceBspec.json", + ]; + + const result = await processFilesToSpecificationList(ROOT, changedFiles); + expect(result).toEqual(expected); + }); +}); diff --git a/eng/tools/oav-runner/tsconfig.json b/eng/tools/oav-runner/tsconfig.json new file mode 100644 index 000000000000..1dc269602e90 --- /dev/null +++ b/eng/tools/oav-runner/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "allowJs": true, + }, + "include": [ + "*.ts", + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/eng/tools/package.json b/eng/tools/package.json index 3fe1225aee41..2c9d10c230ed 100644 --- a/eng/tools/package.json +++ b/eng/tools/package.json @@ -1,14 +1,15 @@ { "name": "azure-rest-api-specs-eng-tools", "devDependencies": { + "@azure-tools/lint-diff": "file:lint-diff", + "@azure-tools/oav-runner": "file:oav-runner", + "@azure-tools/sdk-suppressions": "file:sdk-suppressions", + "@azure-tools/spec-gen-sdk-runner": "file:spec-gen-sdk-runner", "@azure-tools/suppressions": "file:suppressions", "@azure-tools/tsp-client-tests": "file:tsp-client-tests", + "@azure-tools/typespec-migration-validation": "file:typespec-migration-validation", "@azure-tools/typespec-requirement": "file:typespec-requirement", - "@azure-tools/typespec-validation": "file:typespec-validation", - "@azure-tools/sdk-suppressions": "file:sdk-suppressions", - "@azure-tools/spec-gen-sdk-runner": "file:spec-gen-sdk-runner", - "@azure-tools/lint-diff": "file:lint-diff", - "@azure-tools/typespec-migration-validation": "file:typespec-migration-validation" + "@azure-tools/typespec-validation": "file:typespec-validation" }, "scripts": { "build": "tsc --build", diff --git a/eng/tools/tsconfig.json b/eng/tools/tsconfig.json index 7e39f0f15010..cbf2159b758f 100644 --- a/eng/tools/tsconfig.json +++ b/eng/tools/tsconfig.json @@ -3,23 +3,40 @@ "compilerOptions": { "target": "es2024", "module": "NodeNext", - // override "importHelpers:true" in root tsconfig.json "importHelpers": false, - // required to use project references "composite": true, }, // Compile nothing at this level "files": [], "references": [ - { "path": "./suppressions" }, - { "path": "./tsp-client-tests" }, - { "path": "./typespec-requirement" }, - { "path": "./typespec-validation" }, - { "path": "./sdk-suppressions" }, - { "path": "./spec-gen-sdk-runner"}, - { "path": "./lint-diff" }, - { "path": "./typespec-migration-validation"} + { + "path": "./lint-diff" + }, + { + "path": "./oav-runner" + }, + { + "path": "./sdk-suppressions" + }, + { + "path": "./spec-gen-sdk-runner" + }, + { + "path": "./suppressions" + }, + { + "path": "./tsp-client-tests" + }, + { + "path": "./typespec-migration-validation" + }, + { + "path": "./typespec-requirement" + }, + { + "path": "./typespec-validation" + }, ] } diff --git a/package-lock.json b/package-lock.json index 1dcdcf4414df..a0ed41e7ad97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -297,6 +297,7 @@ "hasInstallScript": true, "devDependencies": { "@azure-tools/lint-diff": "file:lint-diff", + "@azure-tools/oav-runner": "file:oav-runner", "@azure-tools/sdk-suppressions": "file:sdk-suppressions", "@azure-tools/spec-gen-sdk-runner": "file:spec-gen-sdk-runner", "@azure-tools/suppressions": "file:suppressions", @@ -350,6 +351,64 @@ "node": ">=14.17" } }, + "eng/tools/node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "eng/tools/node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "eng/tools/node_modules/@azure/core-util": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.7.0.tgz", + "integrity": "sha512-Zq2i3QO6k9DA8vnm29mYM4G8IE9u1mhF1GUabVEqPNX8Lj833gdxQ2NAFxt2BZsfAL+e9cT8SyVN7dFVJ/Hf0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "eng/tools/node_modules/@azure/logger": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.4.tgz", + "integrity": "sha512-ustrPY8MryhloQj7OWGe+HrYx+aoiOxzbXTtgblbV3xwCqpzUK36phH3XNHQKj3EPonyFUuDTfR3qFhTEAuZEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "eng/tools/node_modules/@types/node": { "version": "18.19.97", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.97.tgz", @@ -605,6 +664,26 @@ "node": ">=20" } }, + "eng/tools/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "eng/tools/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "eng/tools/node_modules/emoji-regex": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", @@ -612,6 +691,20 @@ "dev": true, "license": "MIT" }, + "eng/tools/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "eng/tools/node_modules/ignore": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", @@ -629,6 +722,19 @@ "dev": true, "license": "MIT" }, + "eng/tools/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "eng/tools/node_modules/minimatch": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", @@ -645,6 +751,257 @@ "url": "https://github.com/sponsors/isaacs" } }, + "eng/tools/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "eng/tools/node_modules/oav": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/oav/-/oav-3.5.1.tgz", + "integrity": "sha512-SWY+b9nk9fWoNsh8NvCP5XU/xZ2TffJ7Nna28GJxOsSgSUqPezZo6ehKoxVxBUUAYo4Sgz/phJBcjkNXYC2zpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3", + "@autorest/schemas": "^1.3.4", + "@azure-tools/openapi-tools-common": "^1.2.2", + "@azure/core-http": "^3.0.1", + "@azure/core-util": "~1.7.0", + "@azure/logger": "~1.0.4", + "@azure/ms-rest-js": "^2.7.0", + "@azure/openapi-markdown": "^0.9.4", + "@ts-common/commonmark-to-markdown": "^2.0.2", + "ajv": "^6.12.6", + "commonmark": "^0.29.3", + "deepdash": "^5.3.2", + "difflib": "^0.2.4", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^10.1.0", + "glob": "^7.2.3", + "humanize-duration": "^3.27.2", + "inversify": "^5.1.1", + "js-yaml": "^4.1.0", + "json-merge-patch": "^1.0.2", + "json-pointer": "^0.6.2", + "json-schema-traverse": "^0.4.1", + "jsonpath-plus": "^10.0.0", + "junit-report-builder": "^3.0.0", + "lodash": "^4.17.21", + "md5-file": "^5.0.0", + "mkdirp": "^1.0.4", + "moment": "^2.29.3", + "mustache": "^4.2.0", + "newman": "^6.0.0", + "path-to-regexp": "^6.2.1", + "postman-collection": "^4.1.7", + "reflect-metadata": "^0.1.13", + "toposort": "^2.0.2", + "uuid": "^3.4.0", + "winston": "^3.3.4", + "yargs": "^15.4.1", + "z-schema": "^5.0.3" + }, + "bin": { + "oav": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "eng/tools/node_modules/oav/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "eng/tools/node_modules/oav/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "eng/tools/node_modules/oav/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "eng/tools/node_modules/oav/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "eng/tools/node_modules/oav/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "eng/tools/node_modules/oav/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "eng/tools/node_modules/oav/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "eng/tools/node_modules/oav/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "eng/tools/node_modules/oav/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "eng/tools/node_modules/oav/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "eng/tools/node_modules/oav/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "eng/tools/node_modules/oav/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "eng/tools/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "eng/tools/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "eng/tools/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -686,6 +1043,17 @@ "dev": true, "license": "MIT" }, + "eng/tools/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, "eng/tools/node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -878,6 +1246,45 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "eng/tools/oav-runner": { + "name": "@azure-tools/oav-runner", + "dev": true, + "dependencies": { + "@azure-tools/specs-shared": "file:../../../.github/shared", + "js-yaml": "^4.1.0", + "oav": "3.5.1", + "simple-git": "^3.27.0" + }, + "bin": { + "oav-runner": "cmd/oav-runner.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "prettier": "~3.5.3", + "typescript": "~5.8.2", + "vitest": "^3.0.7" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "eng/tools/oav-runner/node_modules/@types/node": { + "version": "20.17.57", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz", + "integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "eng/tools/oav-runner/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, "eng/tools/sdk-suppressions": { "name": "@azure-tools/sdk-suppressions", "version": "1.0.0", @@ -1374,6 +1781,10 @@ "resolved": "eng/tools/lint-diff", "link": true }, + "node_modules/@azure-tools/oav-runner": { + "resolved": "eng/tools/oav-runner", + "link": true + }, "node_modules/@azure-tools/openapi": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@azure-tools/openapi/-/openapi-3.6.1.tgz",