diff --git a/packages/typespec-ts/src/framework/hooks/binder.ts b/packages/typespec-ts/src/framework/hooks/binder.ts index 140e2ddfbf..2c000fd02b 100644 --- a/packages/typespec-ts/src/framework/hooks/binder.ts +++ b/packages/typespec-ts/src/framework/hooks/binder.ts @@ -41,7 +41,7 @@ export interface Binder { sourceFile: SourceFile ): string; resolveReference(refkey: unknown): string; - resolveAllReferences(sourceRoot: string): void; + resolveAllReferences(sourceRoot: string, testRoot?: string): void; } const PLACEHOLDER_PREFIX = "__PLACEHOLDER_"; @@ -226,7 +226,7 @@ class BinderImp implements Binder { /** * Applies all tracked imports to their respective source files. */ - resolveAllReferences(sourceRoot: string): void { + resolveAllReferences(sourceRoot: string, testRoot?: string): void { this.project.getSourceFiles().map((file) => { this.resolveDeclarationReferences(file); this.resolveDependencyReferences(file); @@ -242,7 +242,7 @@ class BinderImp implements Binder { } }); - this.cleanUnreferencedHelpers(sourceRoot); + this.cleanUnreferencedHelpers(sourceRoot, testRoot); } private resolveDependencyReferences(file: SourceFile) { @@ -302,7 +302,7 @@ class BinderImp implements Binder { this.references.get(refkey)!.add(sourceFile); } - private cleanUnreferencedHelpers(sourceRoot: string) { + private cleanUnreferencedHelpers(sourceRoot: string, testRoot?: string) { const usedHelperFiles = new Set(); for (const helper of this.staticHelpers.values()) { const sourceFile = helper[SourceFileSymbol]; @@ -319,6 +319,7 @@ class BinderImp implements Binder { } } + // delete unused helper files this.project //normalizae the final path to adapt to different systems .getSourceFiles( @@ -326,6 +327,16 @@ class BinderImp implements Binder { ) .filter((helperFile) => !usedHelperFiles.has(helperFile)) .forEach((helperFile) => helperFile.delete()); + if (!testRoot) { + return; + } + this.project + //normalizae the final path to adapt to different systems + .getSourceFiles( + normalizePath(path.join(testRoot, "test/generated/util/**/*.ts")) + ) + .filter((helperFile) => !usedHelperFiles.has(helperFile)) + .forEach((helperFile) => helperFile.delete()); } } diff --git a/packages/typespec-ts/src/framework/load-static-helpers.ts b/packages/typespec-ts/src/framework/load-static-helpers.ts index 11fda31b68..6c96327e3c 100644 --- a/packages/typespec-ts/src/framework/load-static-helpers.ts +++ b/packages/typespec-ts/src/framework/load-static-helpers.ts @@ -37,78 +37,100 @@ export function isStaticHelperMetadata( export type StaticHelpers = Record; -const DEFAULT_STATIC_HELPERS_PATH = "static/static-helpers"; +const DEFAULT_SOURCES_STATIC_HELPERS_PATH = "static/static-helpers"; +const DEFAULT_SOURCES_TESTING_HELPERS_PATH = "static/test-helpers"; export interface LoadStaticHelpersOptions extends Partial { helpersAssetDirectory?: string; sourcesDir?: string; + rootDir?: string; program?: Program; } +interface FileMetadata { + source: string; + target: string; +} + export async function loadStaticHelpers( project: Project, helpers: StaticHelpers, options: LoadStaticHelpersOptions = {} ): Promise> { - const sourcesDir = options.sourcesDir ?? ""; const helpersMap = new Map(); + // Load static helpers used in sources code const defaultStaticHelpersPath = path.join( resolveProjectRoot(), - DEFAULT_STATIC_HELPERS_PATH + DEFAULT_SOURCES_STATIC_HELPERS_PATH ); - const files = await traverseDirectory( + const filesInSources = await traverseDirectory( options.helpersAssetDirectory ?? defaultStaticHelpersPath, options.program ); + await loadFiles(filesInSources, options.sourcesDir ?? ""); + // Load static helpers used in testing code + const defaultTestingHelpersPath = path.join( + resolveProjectRoot(), + DEFAULT_SOURCES_TESTING_HELPERS_PATH + ); + const filesInTestings = await traverseDirectory( + defaultTestingHelpersPath, + options.program, + [], + "", + "test/generated/util" + ); + await loadFiles(filesInTestings, options.rootDir ?? ""); + return assertAllHelpersLoadedPresent(helpersMap); - for (const file of files) { - const targetPath = path.join(sourcesDir, file.target); - const contents = await readFile(file.source, "utf-8"); - const addedFile = project.createSourceFile(targetPath, contents, { - overwrite: true - }); - addedFile.getImportDeclarations().map((i) => { - if (!isAzurePackage({ options: options.options })) { - if ( - i - .getModuleSpecifier() - .getFullText() - .includes("@azure/core-rest-pipeline") - ) { - i.setModuleSpecifier("@typespec/ts-http-runtime"); + async function loadFiles(files: FileMetadata[], generateDir: string) { + for (const file of files) { + const targetPath = path.join(generateDir, file.target); + const contents = await readFile(file.source, "utf-8"); + const addedFile = project.createSourceFile(targetPath, contents, { + overwrite: true + }); + addedFile.getImportDeclarations().map((i) => { + if (!isAzurePackage({ options: options.options })) { + if ( + i + .getModuleSpecifier() + .getFullText() + .includes("@azure/core-rest-pipeline") + ) { + i.setModuleSpecifier("@typespec/ts-http-runtime"); + } + if ( + i + .getModuleSpecifier() + .getFullText() + .includes("@azure-rest/core-client") + ) { + i.setModuleSpecifier("@typespec/ts-http-runtime"); + } } - if ( - i - .getModuleSpecifier() - .getFullText() - .includes("@azure-rest/core-client") - ) { - i.setModuleSpecifier("@typespec/ts-http-runtime"); + }); + + for (const entry of Object.values(helpers)) { + if (!addedFile.getFilePath().endsWith(entry.location)) { + continue; } - } - }); - for (const entry of Object.values(helpers)) { - if (!addedFile.getFilePath().endsWith(entry.location)) { - continue; - } + const declaration = getDeclarationByMetadata(addedFile, entry); + if (!declaration) { + throw new Error( + `Declaration ${ + entry.name + } not found in file ${addedFile.getFilePath()}\n This is an Emitter bug, make sure that the map of static helpers passed to loadStaticHelpers matches what is in the file.` + ); + } - const declaration = getDeclarationByMetadata(addedFile, entry); - if (!declaration) { - throw new Error( - `Declaration ${ - entry.name - } not found in file ${addedFile.getFilePath()}\n This is an Emitter bug, make sure that the map of static helpers passed to loadStaticHelpers matches what is in the file.` - ); + entry[SourceFileSymbol] = addedFile; + helpersMap.set(refkey(entry), entry); } - - entry[SourceFileSymbol] = addedFile; - helpersMap.set(refkey(entry), entry); } } - - return assertAllHelpersLoadedPresent(helpersMap); } function assertAllHelpersLoadedPresent( @@ -162,12 +184,12 @@ function getDeclarationByMetadata( } } -const _targetStaticHelpersBaseDir = "static-helpers"; async function traverseDirectory( directory: string, program?: Program, result: { source: string; target: string }[] = [], - relativePath: string = "" + relativePath: string = "", + targetBaseDir: string = "static-helpers" ): Promise<{ source: string; target: string }[]> { try { const files = await readdir(directory); @@ -182,18 +204,15 @@ async function traverseDirectory( filePath, program, result, - path.join(relativePath, file) + path.join(relativePath, file), + targetBaseDir ); } else if ( fileStat.isFile() && !file.endsWith(".d.ts") && file.endsWith(".ts") ) { - const target = path.join( - _targetStaticHelpersBaseDir, - relativePath, - file - ); + const target = path.join(targetBaseDir, relativePath, file); result.push({ source: filePath, target }); } }) diff --git a/packages/typespec-ts/src/index.ts b/packages/typespec-ts/src/index.ts index 3fdd677c62..f01696b95f 100644 --- a/packages/typespec-ts/src/index.ts +++ b/packages/typespec-ts/src/index.ts @@ -6,13 +6,15 @@ import { AzureCoreDependencies, AzureIdentityDependencies, AzurePollingDependencies, - DefaultCoreDependencies + DefaultCoreDependencies, + AzureTestDependencies } from "./modular/external-dependencies.js"; import { clearDirectory } from "./utils/fileSystemUtils.js"; import { EmitContext, Program } from "@typespec/compiler"; import { GenerationDirDetail, SdkContext } from "./utils/interfaces.js"; import { CloudSettingHelpers, + CreateRecorderHelpers, MultipartHelpers, PagingHelpers, PollingHelpers, @@ -95,6 +97,7 @@ import { provideSdkTypes } from "./framework/hooks/sdkTypes.js"; import { transformRLCModel } from "./transform/transform.js"; import { transformRLCOptions } from "./transform/transfromRLCOptions.js"; import { emitSamples } from "./modular/emitSamples.js"; +import { emitTests } from "./modular/emitTests.js"; import { generateCrossLanguageDefinitionFile } from "./utils/crossLanguageDef.js"; export * from "./lib.js"; @@ -132,10 +135,12 @@ export async function $onEmit(context: EmitContext) { ...PollingHelpers, ...UrlTemplateHelpers, ...MultipartHelpers, - ...CloudSettingHelpers + ...CloudSettingHelpers, + ...CreateRecorderHelpers }, { sourcesDir: dpgContext.generationPathDetail?.modularSourcesDir, + rootDir: dpgContext.generationPathDetail?.rootDir, options: rlcOptions, program } @@ -144,7 +149,8 @@ export async function $onEmit(context: EmitContext) { ? { ...AzurePollingDependencies, ...AzureCoreDependencies, - ...AzureIdentityDependencies + ...AzureIdentityDependencies, + ...AzureTestDependencies } : { ...DefaultCoreDependencies }; const binder = provideBinder(outputProject, { @@ -348,7 +354,15 @@ export async function $onEmit(context: EmitContext) { } } - binder.resolveAllReferences(modularSourcesRoot); + // Enable modular test generation when explicitly set to true + if (emitterOptions["experimental-generate-test-files"] === true) { + await emitTests(dpgContext); + } + + binder.resolveAllReferences( + modularSourcesRoot, + dpgContext.generationPathDetail?.rootDir ?? "" + ); if (program.compilerOptions.noEmit || program.hasError()) { return; } diff --git a/packages/typespec-ts/src/lib.ts b/packages/typespec-ts/src/lib.ts index 20a61a0bdb..9682a594f9 100644 --- a/packages/typespec-ts/src/lib.ts +++ b/packages/typespec-ts/src/lib.ts @@ -70,6 +70,7 @@ export interface EmitterOptions { "default-value-object"?: boolean; //TODO should remove this after finish the release tool test "should-use-pnpm-dep"?: boolean; + "experimental-generate-test-files"?: boolean; } export const RLCOptionsSchema: JSONSchemaType = { @@ -335,6 +336,11 @@ export const RLCOptionsSchema: JSONSchemaType = { type: "boolean", nullable: true, description: "Internal option for test." + }, + "experimental-generate-test-files": { + type: "boolean", + nullable: true, + description: "Whether to generate test files for the client." } }, required: [] diff --git a/packages/typespec-ts/src/modular/emitSamples.ts b/packages/typespec-ts/src/modular/emitSamples.ts index acb7e6994f..56708eb365 100644 --- a/packages/typespec-ts/src/modular/emitSamples.ts +++ b/packages/typespec-ts/src/modular/emitSamples.ts @@ -3,134 +3,58 @@ import { FunctionDeclarationStructure, SourceFile } from "ts-morph"; -import { resolveReference } from "../framework/reference.js"; import { SdkContext } from "../utils/interfaces.js"; -import { - SdkClientType, - SdkHttpOperationExample, - SdkHttpParameterExampleValue, - SdkServiceOperation, - SdkExampleValue, - SdkClientInitializationType -} from "@azure-tools/typespec-client-generator-core"; -import { - isAzurePackage, - NameType, - normalizeName -} from "@azure-tools/rlc-common"; -import { useContext } from "../contextManager.js"; -import { join } from "path"; -import { AzureIdentityDependencies } from "../modular/external-dependencies.js"; -import { reportDiagnostic } from "../index.js"; -import { NoTarget } from "@typespec/compiler"; -import { - buildPropertyNameMapper, - isSpreadBodyParameter -} from "./helpers/typeHelpers.js"; +import { NameType, normalizeName } from "@azure-tools/rlc-common"; import { getClassicalClientName } from "./helpers/namingHelpers.js"; +import { ServiceOperation } from "../utils/operationUtil.js"; import { - hasKeyCredential, - hasTokenCredential -} from "../utils/credentialUtils.js"; -import { - getMethodHierarchiesMap, - ServiceOperation -} from "../utils/operationUtil.js"; -import { getSubscriptionId } from "../transform/transfromRLCOptions.js"; -import { getClientParametersDeclaration } from "./helpers/clientHelpers.js"; -import { getOperationFunction } from "./helpers/operationHelpers.js"; - -/** - * Interfaces for samples generations - */ -interface ExampleValue { - name: string; - value: string; - isOptional: boolean; - onClient: boolean; -} + buildParameterValueMap, + prepareCommonParameters, + escapeSpecialCharToSpace, + getDescriptiveName, + ClientEmitOptions, + iterateClientsAndMethods, + generateMethodCall, + createSourceFile +} from "./helpers/exampleValueHelpers.js"; -interface EmitSampleOptions { - topLevelClient: SdkClientType; - generatedFiles: SourceFile[]; - classicalMethodPrefix?: string; - subFolder?: string; - hierarchies?: string[]; // Add hierarchies to track operation path -} /** * Helpers to emit samples */ export function emitSamples(dpgContext: SdkContext): SourceFile[] { - const generatedFiles: SourceFile[] = []; - const clients = dpgContext.sdkPackage.clients; - for (const client of dpgContext.sdkPackage.clients) { - emitClientSamples(dpgContext, client, { - topLevelClient: client, - generatedFiles, - subFolder: - clients.length > 1 - ? normalizeName(getClassicalClientName(client), NameType.File) - : undefined - }); - } - return generatedFiles; -} - -function emitClientSamples( - dpgContext: SdkContext, - client: SdkClientType, - options: EmitSampleOptions -) { - const methodMap = getMethodHierarchiesMap(dpgContext, client); - for (const [prefixKey, operations] of methodMap) { - const hierarchies = prefixKey ? prefixKey.split("/") : []; - const prefix = hierarchies - .map((name) => { - return normalizeName(name, NameType.Property); - }) - .join("."); - for (const op of operations) { - emitMethodSamples(dpgContext, op, { - ...options, - classicalMethodPrefix: prefix, - hierarchies: hierarchies - }); - } - } + return iterateClientsAndMethods(dpgContext, emitMethodSamples); } function emitMethodSamples( dpgContext: SdkContext, method: ServiceOperation, - options: EmitSampleOptions + options: ClientEmitOptions ): SourceFile | undefined { const examples = method.operation.examples ?? []; if (examples.length === 0) { return; } - const project = useContext("outputProject"); + const operationPrefix = `${options.classicalMethodPrefix ?? ""} ${ method.oriName ?? method.name }`; - const sampleFolder = join( - dpgContext.generationPathDetail?.rootDir ?? "", - "samples-dev", - options.subFolder ?? "" - ); const fileName = normalizeName(`${operationPrefix} Sample`, NameType.File); - const sourceFile = project.createSourceFile( - join(sampleFolder, `${fileName}.ts`), - "", - { - overwrite: true - } + const sourceFile = createSourceFile( + dpgContext, + method, + options, + "sample", + fileName ); + const exampleFunctions = []; + const clientName = getClassicalClientName(options.client); + // TODO: remove hard-coded for package if (dpgContext.rlcOptions?.packageDetails?.name) { sourceFile.addImportDeclaration({ moduleSpecifier: dpgContext.rlcOptions?.packageDetails?.name, - namedImports: [getClassicalClientName(options.topLevelClient)] + namedImports: [clientName] }); } @@ -140,85 +64,44 @@ function emitMethodSamples( escapeSpecialCharToSpace(example.name), NameType.Method ); - const exampleFunctionType = { - name: exampleName, - returnType: "Promise", - body: exampleFunctionBody - }; - const parameterMap: Record = - buildParameterValueMap(example); - const parameters = prepareExampleParameters( + + const parameterMap = buildParameterValueMap(example); + const parameters = prepareCommonParameters( dpgContext, method, parameterMap, - options.topLevelClient + options.client, + false // isForTest = false for samples ); - // prepare client-level parameters - const clientParamValues = parameters.filter((p) => p.onClient); - const clientParams: string[] = clientParamValues - .filter((p) => !p.isOptional) - .map((param) => { - exampleFunctionBody.push(`const ${param.name} = ${param.value};`); - return param.name; - }); - const optionalClientParams = clientParamValues - .filter((p) => p.isOptional) + + const { methodCall, clientParams, clientParamDefs } = generateMethodCall( + method, + parameters, + options, + dpgContext + ); + + // Add client parameter definitions + clientParamDefs.forEach((def) => exampleFunctionBody.push(def)); + + // Handle optional client parameters + const optionalClientParams = parameters + .filter((p) => p.onClient && p.isOptional) .map((param) => `${param.name}: ${param.value}`); + if (optionalClientParams.length > 0) { exampleFunctionBody.push( `const clientOptions = {${optionalClientParams.join(", ")}};` ); clientParams.push("clientOptions"); } - exampleFunctionBody.push( - `const client = new ${getClassicalClientName( - options.topLevelClient - )}(${clientParams.join(", ")});` - ); - // prepare operation-level parameters - // Get the actual function signature parameter order - const operationFunction = getOperationFunction( - dpgContext, - [options.hierarchies ?? [], method], - "Client" + exampleFunctionBody.push( + `const client = new ${clientName}(${clientParams.join(", ")});` ); - // Extract parameter names from the function signature (excluding context and options) - const signatureParamNames = - operationFunction.parameters - ?.filter( - (p) => - p.name !== "context" && - !p.type?.toString().includes("OptionalParams") - ) - .map((p) => p.name) ?? []; - - const methodParamValues = parameters.filter((p) => !p.onClient); - - // Create a map for quick lookup of parameter values by name - const paramValueMap = new Map(methodParamValues.map((p) => [p.name, p])); - - // Reorder methodParamValues according to the signature order - const orderedRequiredParams = signatureParamNames - .map((name) => paramValueMap.get(name)) - .filter((p): p is ExampleValue => p !== undefined && !p.isOptional); - - const methodParams = orderedRequiredParams.map((p) => `${p.value}`); - - const optionalParams = methodParamValues - .filter((p) => p.isOptional) - .map((param) => `${param.name}: ${param.value}`); - if (optionalParams.length > 0) { - methodParams.push(`{${optionalParams.join(", ")}}`); - } - const prefix = options.classicalMethodPrefix - ? `${options.classicalMethodPrefix}.` - : ""; + // Handle method execution based on type const isPaging = method.kind === "paging"; - const methodCall = `client.${prefix}${normalizeName(method.oriName ?? method.name, NameType.Property)}(${methodParams.join( - ", " - )})`; if (isPaging) { exampleFunctionBody.push(`const resArray = new Array();`); exampleFunctionBody.push( @@ -234,23 +117,21 @@ function emitMethodSamples( } // Create a function declaration structure - const description = - method.doc ?? `execute ${method.oriName ?? method.name}`; - const normalizedDescription = - description.charAt(0).toLowerCase() + description.slice(1); + const description = getDescriptiveName(method, example.name, "sample"); const functionDeclaration: FunctionDeclarationStructure = { - returnType: exampleFunctionType.returnType, + returnType: "Promise", kind: StructureKind.Function, isAsync: true, - name: exampleFunctionType.name, - statements: exampleFunctionType.body, + name: exampleName, + statements: exampleFunctionBody, docs: [ - `This sample demonstrates how to ${normalizedDescription}\n\n@summary ${normalizedDescription}\nx-ms-original-file: ${example.filePath}` + `This sample demonstrates how to ${description}\n\n@summary ${description}\nx-ms-original-file: ${example.filePath}` ] }; sourceFile.addFunction(functionDeclaration); - exampleFunctions.push(exampleFunctionType.name); + exampleFunctions.push(exampleName); } + // Add statements referencing the tracked declarations const functions = exampleFunctions.map((f) => `await ${f}();`).join("\n"); sourceFile.addStatements(` @@ -259,338 +140,7 @@ function emitMethodSamples( } main().catch(console.error);`); + options.generatedFiles.push(sourceFile); return sourceFile; } - -function buildParameterValueMap(example: SdkHttpOperationExample) { - const parameterMap: Record = {}; - example.parameters.forEach( - (param) => (parameterMap[param.parameter.serializedName] = param) - ); - return parameterMap; -} - -function prepareExampleValue( - name: string, - value: SdkExampleValue | string, - isOptional?: boolean, - onClient?: boolean -): ExampleValue { - return { - name: normalizeName(name, NameType.Parameter), - value: typeof value === "string" ? value : getParameterValue(value), - isOptional: Boolean(isOptional), - onClient: Boolean(onClient) - }; -} - -function prepareExampleParameters( - dpgContext: SdkContext, - method: ServiceOperation, - parameterMap: Record, - topLevelClient: SdkClientType -): ExampleValue[] { - // TODO: blocked by TCGC issue: https://github.com/Azure/typespec-azure/issues/1419 - // refine this to support generic client-level parameters once resolved - const result: ExampleValue[] = []; - const clientParams = getClientParametersDeclaration( - topLevelClient, - dpgContext, - { - onClientOnly: true - } - ); - - for (const param of clientParams) { - if (param.name === "options" || param.name === "credential") { - continue; - } - - const exampleValue: ExampleValue = { - name: param.name === "endpointParam" ? "endpoint" : param.name, - value: getEnvironmentVariableName( - param.name, - getClassicalClientName(topLevelClient) - ), - isOptional: Boolean(param.hasQuestionToken), - onClient: true - }; - - result.push(exampleValue); - } - const credentialExampleValue = getCredentialExampleValue( - dpgContext, - topLevelClient.clientInitialization - ); - if (credentialExampleValue) { - result.push(credentialExampleValue); - } - - let subscriptionIdValue = `"00000000-0000-0000-0000-00000000000"`; - // required parameters - for (const param of method.operation.parameters) { - if ( - param.optional === true || - param.type.kind === "constant" || - param.clientDefaultValue - ) { - continue; - } - - const exampleValue = parameterMap[param.serializedName]; - if (!exampleValue || !exampleValue.value) { - // report diagnostic if required parameter is missing - reportDiagnostic(dpgContext.program, { - code: "required-sample-parameter", - format: { - exampleName: method.oriName ?? method.name, - paramName: param.name - }, - target: NoTarget - }); - continue; - } - - if ( - param.name.toLowerCase() === "subscriptionid" && - dpgContext.arm && - exampleValue - ) { - subscriptionIdValue = getParameterValue(exampleValue.value); - continue; - } - result.push( - prepareExampleValue( - exampleValue.parameter.name, - exampleValue.value, - param.optional, - param.onClient - ) - ); - } - // add subscriptionId for ARM clients if ARM clients need it - if (dpgContext.arm && getSubscriptionId(dpgContext)) { - result.push( - prepareExampleValue("subscriptionId", subscriptionIdValue, false, true) - ); - } - // required/optional body parameters - const bodyParam = method.operation.bodyParam; - const bodySerializeName = bodyParam?.serializedName; - const bodyExample = parameterMap[bodySerializeName ?? ""]; - if (bodySerializeName && bodyExample && bodyExample.value) { - if ( - isSpreadBodyParameter(bodyParam) && - bodyParam.type.kind === "model" && - bodyExample.value.kind === "model" - ) { - for (const prop of bodyParam.type.properties) { - const propExample = bodyExample.value.value[prop.name]; - if (!propExample) { - continue; - } - result.push( - prepareExampleValue( - prop.name, - propExample, - prop.optional, - prop.onClient - ) - ); - } - } else { - result.push( - prepareExampleValue( - bodyParam.name, - bodyExample.value, - bodyParam.optional, - bodyParam.onClient - ) - ); - } - } - // optional parameters - method.operation.parameters - .filter( - (param) => - param.optional === true && - parameterMap[param.serializedName] && - !param.clientDefaultValue - ) - .map((param) => parameterMap[param.serializedName]!) - .forEach((param) => { - result.push( - prepareExampleValue( - param.parameter.name, - param.value, - true, - param.parameter.onClient - ) - ); - }); - - return result; -} - -function getCredentialExampleValue( - dpgContext: SdkContext, - initialization: SdkClientInitializationType -): ExampleValue | undefined { - const keyCredential = hasKeyCredential(initialization), - tokenCredential = hasTokenCredential(initialization); - const defaultSetting = { - isOptional: false, - onClient: true, - name: "credential" - }; - if (keyCredential || tokenCredential) { - if (isAzurePackage({ options: dpgContext.rlcOptions })) { - // Support DefaultAzureCredential for Azure packages - return { - ...defaultSetting, - value: `new ${resolveReference( - AzureIdentityDependencies.DefaultAzureCredential - )}()` - }; - } else if (keyCredential) { - // Support ApiKeyCredential for non-Azure packages - return { - ...defaultSetting, - value: `{ key: "INPUT_YOUR_KEY_HERE" }` - }; - } else if (tokenCredential) { - // Support TokenCredential for non-Azure packages - return { - ...defaultSetting, - value: `{ getToken: async () => { - return { token: "INPUT_YOUR_TOKEN_HERE", expiresOnTimestamp: now() }; } }` - }; - } - } - return undefined; -} - -function getParameterValue(value: SdkExampleValue): string { - let retValue = `{} as any`; - switch (value.kind) { - case "string": { - switch (value.type.kind) { - case "utcDateTime": - retValue = `new Date("${value.value}")`; - break; - case "bytes": { - const encode = value.type.encode ?? "base64"; - // TODO: add check for un-supported encode - retValue = `Buffer.from("${value.value}", "${encode}")`; - break; - } - default: - retValue = `"${value.value - ?.toString() - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t") - .replace(/\f/g, "\\f") - .replace(/>/g, ">") - .replace(/ 0) { - const name = mapper.get("additionalProperties") - ? "additionalPropertiesBag" - : "additionalProperties"; - values.push(`"${name}": { - ${additionalBags.join(", ")} - }`); - } - - retValue = `{${values.join(", ")}}`; - break; - } - case "array": { - const valuesArr = value.value.map(getParameterValue); - retValue = `[${valuesArr.join(", ")}]`; - break; - } - default: - break; - } - return retValue; -} - -function escapeSpecialCharToSpace(str: string) { - if (!str) { - return str; - } - return str.replace(/_|,|\.|\(|\)|'s |\[|\]/g, " ").replace(/\//g, " Or "); -} - -function getEnvironmentVariableName( - paramName: string, - clientName?: string -): string { - // Remove "Param" suffix if present - const cleanName = paramName.replace(/Param$/, ""); - - // Remove "Client" suffix from client name if present and convert to UPPER_SNAKE_CASE - let prefix = ""; - if (clientName) { - const cleanClientName = clientName.replace(/Client$/, ""); - prefix = - cleanClientName - .replace(/([A-Z])/g, "_$1") - .toUpperCase() - .replace(/^_/, "") + "_"; - } - - // Convert camelCase to UPPER_SNAKE_CASE - const envVarName = cleanName - .replace(/([A-Z])/g, "_$1") - .toUpperCase() - .replace(/^_/, ""); - - return `process.env.${prefix}${envVarName} || ""`; -} diff --git a/packages/typespec-ts/src/modular/emitTests.ts b/packages/typespec-ts/src/modular/emitTests.ts new file mode 100644 index 0000000000..0692df5bbb --- /dev/null +++ b/packages/typespec-ts/src/modular/emitTests.ts @@ -0,0 +1,221 @@ +import { SourceFile } from "ts-morph"; +import { SdkContext } from "../utils/interfaces.js"; +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { join } from "path"; +import { existsSync, rmSync } from "fs"; +import { getClassicalClientName } from "./helpers/namingHelpers.js"; +import { ServiceOperation } from "../utils/operationUtil.js"; +import { + buildParameterValueMap, + prepareCommonParameters, + getDescriptiveName, + ClientEmitOptions, + iterateClientsAndMethods, + generateMethodCall, + createSourceFile, + generateResponseAssertions +} from "./helpers/exampleValueHelpers.js"; +import { AzureTestDependencies } from "./external-dependencies.js"; +import { resolveReference } from "../framework/reference.js"; +import { CreateRecorderHelpers } from "./static-helpers-metadata.js"; + +/** + * Clean up the test/generated folder before generating new tests + */ +async function cleanupTestFolder(dpgContext: SdkContext) { + const clients = dpgContext.sdkPackage.clients; + const baseTestFolder = join( + dpgContext.generationPathDetail?.rootDir ?? "", + "test", + "generated" + ); + + // If there are multiple clients, clean up subfolders + if (clients.length > 1) { + for (const client of clients) { + const subFolder = normalizeName( + getClassicalClientName(client), + NameType.File + ); + const clientTestFolder = join(baseTestFolder, subFolder); + if (existsSync(clientTestFolder)) { + rmSync(clientTestFolder, { recursive: true, force: true }); + } + } + } else { + // Single client, clean up the entire test/generated folder + if (existsSync(baseTestFolder)) { + rmSync(baseTestFolder, { recursive: true, force: true }); + } + } +} + +/** + * Helpers to emit tests similar to samples + */ +export async function emitTests(dpgContext: SdkContext): Promise { + // Clean up the test/generated folder before generating new tests + await cleanupTestFolder(dpgContext); + + return iterateClientsAndMethods(dpgContext, emitMethodTests); +} + +function emitMethodTests( + dpgContext: SdkContext, + method: ServiceOperation, + options: ClientEmitOptions +): SourceFile | undefined { + const examples = method.operation.examples ?? []; + if (examples.length === 0) { + return; + } + + const methodPrefix = `${options.classicalMethodPrefix ?? ""} ${ + method.oriName ?? method.name + }`; + const fileName = normalizeName(`${methodPrefix} Test`, NameType.File); + const sourceFile = createSourceFile( + dpgContext, + method, + options, + "test", + fileName + ); + const clientName = getClassicalClientName(options.client); + + // Use resolveReference for test dependencies to let the binder handle imports automatically + const recorderType = resolveReference(AzureTestDependencies.Recorder); + const assertType = resolveReference(AzureTestDependencies.assert); + const beforeEachType = resolveReference(AzureTestDependencies.beforeEach); + const afterEachType = resolveReference(AzureTestDependencies.afterEach); + const itType = resolveReference(AzureTestDependencies.it); + const describeType = resolveReference(AzureTestDependencies.describe); + const createRecorderHelper = resolveReference( + CreateRecorderHelpers.createRecorder + ); + + // Import the client + sourceFile.addImportDeclaration({ + moduleSpecifier: "../../src/index.js", + namedImports: [clientName] + }); + + const testFunctions = []; + let clientParamNames: string[] = []; + let clientParameterDefs: string[] = []; + + // Create test describe block + const methodDescription = + method.doc ?? `test ${method.oriName ?? method.name}`; + let normalizedDescription = + methodDescription.charAt(0).toLowerCase() + methodDescription.slice(1); + + // Remove any trailing dots from describe block + normalizedDescription = normalizedDescription.replace(/\.$/, ""); + + // Generate test functions for each example + for (const example of examples) { + const testFunctionBody: string[] = []; + // Create a more descriptive test name based on the operation (same as samples) + const testName = getDescriptiveName(method, example.name, "test"); + const parameterMap = buildParameterValueMap(example); + const parameters = prepareCommonParameters( + dpgContext, + method, + parameterMap, + options.client, + true // isForTest = true for tests + ); + + // Prepare client-level parameters + const requiredClientParams = parameters.filter( + (p) => p.onClient && !p.isOptional + ); + clientParameterDefs = requiredClientParams.map( + (p) => `const ${p.name} = ${p.value};` + ); + clientParamNames = requiredClientParams.map((p) => p.name); + // add client options to parameters + // const clientOptions = recorder.configureClientOptions({}); + clientParamNames.push("clientOptions"); + clientParameterDefs.push( + `const clientOptions = recorder.configureClientOptions({});` + ); + + const { methodCall } = generateMethodCall(method, parameters, options); + + // Add method call based on type + const isPaging = method.kind === "paging"; + const isLRO = method.kind === "lro" || method.kind === "lropaging"; + + if (method.response.type === undefined) { + // skip response handling for void methods + testFunctionBody.push(`await ${methodCall};`); + testFunctionBody.push(`/* Test passes if no exception is thrown */`); + } else if (isPaging) { + testFunctionBody.push(`const resArray = new Array();`); + testFunctionBody.push( + `for await (const item of ${methodCall}) { resArray.push(item); }` + ); + testFunctionBody.push(`${assertType}.ok(resArray);`); + // Add response assertions for paging results + const pagingAssertions = generateResponseAssertions( + example, + "resArray", + true // isPaging = true + ); + testFunctionBody.push(...pagingAssertions); + } else if (isLRO) { + testFunctionBody.push(`const result = await ${methodCall};`); + testFunctionBody.push(`${assertType}.ok(result);`); + // Add response assertions for LRO results + const responseAssertions = generateResponseAssertions(example, "result"); + testFunctionBody.push(...responseAssertions); + } else { + testFunctionBody.push(`const result = await ${methodCall};`); + testFunctionBody.push(`${assertType}.ok(result);`); + // Add response assertions for non-paging results + const responseAssertions = generateResponseAssertions(example, "result"); + testFunctionBody.push(...responseAssertions); + } + + // Create a test function + const testFunction = { + name: testName, + body: testFunctionBody + }; + + testFunctions.push(testFunction); + } + + // Create describe block with beforeEach and afterEach + const describeBlock = ` +${describeType}("${normalizedDescription}", () => { + let recorder: ${recorderType}; + let client: ${clientName}; + + ${beforeEachType}(async function(ctx) { + recorder = await ${createRecorderHelper}(ctx); + ${clientParameterDefs.join("\n")} + client = new ${clientName}(${clientParamNames.join(", ")}); + }); + + ${afterEachType}(async function() { + await recorder.stop(); + }); + +${testFunctions + .map( + (fn) => ` + ${itType}("should ${fn.name}", async function() { + ${fn.body.join("\n ")} + }); +` + ) + .join("")} +});`; + + sourceFile.addStatements(describeBlock); + options.generatedFiles.push(sourceFile); + return sourceFile; +} diff --git a/packages/typespec-ts/src/modular/external-dependencies.ts b/packages/typespec-ts/src/modular/external-dependencies.ts index 9ae5d3d5db..d2ad25d398 100644 --- a/packages/typespec-ts/src/modular/external-dependencies.ts +++ b/packages/typespec-ts/src/modular/external-dependencies.ts @@ -206,3 +206,46 @@ export const AzureIdentityDependencies = { name: "DefaultAzureCredential" } }; + +export const AzureTestDependencies = { + Recorder: { + kind: "externalDependency", + module: "@azure-tools/test-recorder", + name: "Recorder" + }, + env: { + kind: "externalDependency", + module: "@azure-tools/test-recorder", + name: "env" + }, + createTestCredential: { + kind: "externalDependency", + module: "@azure-tools/test-credential", + name: "createTestCredential" + }, + assert: { + kind: "externalDependency", + module: "vitest", + name: "assert" + }, + beforeEach: { + kind: "externalDependency", + module: "vitest", + name: "beforeEach" + }, + afterEach: { + kind: "externalDependency", + module: "vitest", + name: "afterEach" + }, + it: { + kind: "externalDependency", + module: "vitest", + name: "it" + }, + describe: { + kind: "externalDependency", + module: "vitest", + name: "describe" + } +} as const; diff --git a/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts b/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts new file mode 100644 index 0000000000..3e12e3f3f7 --- /dev/null +++ b/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts @@ -0,0 +1,852 @@ +import { + SdkHttpOperationExample, + SdkHttpParameterExampleValue, + SdkExampleValue, + SdkClientInitializationType, + SdkClientType, + SdkServiceOperation +} from "@azure-tools/typespec-client-generator-core"; +import { + isAzurePackage, + NameType, + normalizeName +} from "@azure-tools/rlc-common"; +import { resolveReference } from "../../framework/reference.js"; +import { SdkContext } from "../../utils/interfaces.js"; +import { + AzureIdentityDependencies, + AzureTestDependencies +} from "../external-dependencies.js"; +import { + hasKeyCredential, + hasTokenCredential +} from "../../utils/credentialUtils.js"; +import { + buildPropertyNameMapper, + isSpreadBodyParameter +} from "./typeHelpers.js"; +import { getClassicalClientName } from "./namingHelpers.js"; +import { + getMethodHierarchiesMap, + ServiceOperation +} from "../../utils/operationUtil.js"; +import { getSubscriptionId } from "../../transform/transfromRLCOptions.js"; +import { reportDiagnostic } from "../../index.js"; +import { NoTarget } from "@typespec/compiler"; +import { SourceFile } from "ts-morph"; +import { useContext } from "../../contextManager.js"; +import { join } from "path"; +import { getOperationFunction } from "./operationHelpers.js"; +import { getClientParametersDeclaration } from "./clientHelpers.js"; + +/** + * Common interfaces for both samples and tests + */ +export interface CommonValue { + name: string; + value: string; + isOptional: boolean; + onClient: boolean; +} + +export interface ClientEmitOptions { + client: SdkClientType; + generatedFiles: SourceFile[]; + classicalMethodPrefix?: string; + subFolder?: string; + hierarchies?: string[]; // Add hierarchies to track operation path +} + +/** + * Build parameter value map from example + */ +export function buildParameterValueMap( + example: SdkHttpOperationExample +): Record { + const parameterMap: Record = {}; + example.parameters.forEach( + (param) => (parameterMap[param.parameter.serializedName] = param) + ); + return parameterMap; +} + +/** + * Prepare a common value for samples or tests + */ +export function prepareCommonValue( + name: string, + value: SdkExampleValue | string, + isOptional?: boolean, + onClient?: boolean +): CommonValue { + return { + name: normalizeName(name, NameType.Parameter), + value: typeof value === "string" ? value : serializeExampleValue(value), + isOptional: Boolean(isOptional), + onClient: Boolean(onClient) + }; +} + +/** + * Get credential value for samples + */ +export function getCredentialSampleValue( + dpgContext: SdkContext, + initialization: SdkClientInitializationType +): CommonValue | undefined { + const keyCredential = hasKeyCredential(initialization), + tokenCredential = hasTokenCredential(initialization); + const defaultSetting = { + isOptional: false, + onClient: true, + name: "credential" + }; + if (keyCredential || tokenCredential) { + if (isAzurePackage({ options: dpgContext.rlcOptions })) { + // Support DefaultAzureCredential for Azure packages + return { + ...defaultSetting, + value: `new ${resolveReference( + AzureIdentityDependencies.DefaultAzureCredential + )}()` + }; + } else if (keyCredential) { + // Support ApiKeyCredential for non-Azure packages + return { + ...defaultSetting, + value: `{ key: "INPUT_YOUR_KEY_HERE" }` + }; + } else if (tokenCredential) { + // Support TokenCredential for non-Azure packages + return { + ...defaultSetting, + value: `{ getToken: async () => { + return { token: "INPUT_YOUR_TOKEN_HERE", expiresOnTimestamp: now() }; } }` + }; + } + } + return undefined; +} + +/** + * Get credential value for tests + */ +export function getCredentialTestValue( + dpgContext: SdkContext, + initialization: SdkClientInitializationType +): CommonValue | undefined { + const createTestCredentialType = resolveReference( + AzureTestDependencies.createTestCredential + ); + const keyCredential = hasKeyCredential(initialization), + tokenCredential = hasTokenCredential(initialization); + const defaultSetting = { + isOptional: false, + onClient: true, + name: "credential" + }; + + if (keyCredential || tokenCredential) { + if (dpgContext.arm || hasTokenCredential(initialization)) { + // Support createTestCredential for ARM/Azure packages + return { + ...defaultSetting, + value: `${createTestCredentialType}()` + }; + } else if (keyCredential) { + // Support ApiKeyCredential for non-Azure packages + return { + ...defaultSetting, + value: `{ key: "INPUT_YOUR_KEY_HERE" } ` + }; + } else if (tokenCredential) { + // Support TokenCredential for non-Azure packages + return { + ...defaultSetting, + value: `{ + getToken: async () => { + return { token: "INPUT_YOUR_TOKEN_HERE", expiresOnTimestamp: Date.now() }; + } + } ` + }; + } + } + return undefined; +} + +/** + * Serialize example value to string representation + */ +export function serializeExampleValue(value: SdkExampleValue): string { + let retValue = `{} as any`; + switch (value.kind) { + case "string": { + switch (value.type.kind) { + case "utcDateTime": + retValue = `new Date("${value.value}")`; + break; + case "bytes": { + const encode = value.type.encode ?? "base64"; + // TODO: add check for un-supported encode + retValue = `Buffer.from("${value.value}", "${encode}")`; + break; + } + default: + retValue = `"${value.value + ?.toString() + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + .replace(/\f/g, "\\f") + .replace(/>/g, ">") + .replace(/ 0) { + const name = mapper.get("additionalProperties") + ? "additionalPropertiesBag" + : "additionalProperties"; + values.push(`"${name}": { + ${additionalBags.join(", ")} + }`); + } + + retValue = `{${values.join(", ")}}`; + break; + } + case "array": { + const valuesArr = value.value.map(serializeExampleValue); + retValue = `[${valuesArr.join(", ")}]`; + break; + } + default: + break; + } + return retValue; +} + +/** + * Escape special characters to spaces (for samples) + */ +export function escapeSpecialCharToSpace(str: string): string { + if (!str) { + return str; + } + return str.replace(/_|,|\.|\(|\)|'s |\[|\]/g, " ").replace(/\//g, " Or "); +} + +/** + * Generate descriptive names based on operation names + */ +export function getDescriptiveName( + method: { doc?: string; oriName?: string; name: string }, + exampleName: string, + type: "sample" | "test" +): string { + const description = method.doc ?? `execute ${method.oriName ?? method.name}`; + let descriptiveName = + description.charAt(0).toLowerCase() + description.slice(1); + + // Only remove trailing dots for test names to avoid redundancy + if (type === "test") { + descriptiveName = descriptiveName.replace(/\.$/, ""); + // Include the example name to ensure uniqueness for multiple test cases + const functionName = normalizeName(exampleName, NameType.Method); + return `${descriptiveName} for ${functionName}`; + } else { + // For samples, preserve the original formatting including periods + return descriptiveName; + } +} + +/** + * Common logic for preparing parameters for both samples and tests + */ +export function prepareCommonParameters( + dpgContext: SdkContext, + method: ServiceOperation, + parameterMap: Record, + topLevelClient: SdkClientType, + isForTest: boolean = false +): CommonValue[] { + const envType = resolveReference(AzureTestDependencies.env); + const result: CommonValue[] = []; + + const clientParams = getClientParametersDeclaration( + topLevelClient, + dpgContext, + { + onClientOnly: true + } + ); + + for (const param of clientParams) { + if (param.name === "options" || param.name === "credential") { + continue; + } + + const exampleValue: CommonValue = { + name: param.name === "endpointParam" ? "endpoint" : param.name, + value: getEnvironmentVariableName( + param.name, + getClassicalClientName(topLevelClient) + ), + isOptional: Boolean(param.hasQuestionToken), + onClient: true + }; + + result.push(exampleValue); + } + + // Handle credentials + const credentialValue = isForTest + ? getCredentialTestValue(dpgContext, topLevelClient.clientInitialization) + : getCredentialSampleValue(dpgContext, topLevelClient.clientInitialization); + if (credentialValue) { + result.push(credentialValue); + } + + let subscriptionIdValue = isForTest + ? `${envType}.SUBSCRIPTION_ID || ""` + : `"00000000-0000-0000-0000-00000000000"`; + + // Process required parameters + for (const param of method.operation.parameters) { + if ( + param.optional === true || + param.type.kind === "constant" || + param.clientDefaultValue + ) { + continue; + } + + const exampleValue = parameterMap[param.serializedName]; + if (!exampleValue || !exampleValue.value) { + if (isForTest && !param.optional) { + // Generate default values for required parameters without examples in tests + result.push( + prepareCommonValue( + param.name, + `"{Your ${param.name}}"`, + false, + param.onClient + ) + ); + } else if (!isForTest) { + // Report diagnostic if required parameter is missing in samples + reportDiagnostic(dpgContext.program, { + code: "required-sample-parameter", + format: { + exampleName: method.oriName ?? method.name, + paramName: param.name + }, + target: NoTarget + }); + } + continue; + } + + if ( + param.name.toLowerCase() === "subscriptionid" && + dpgContext.arm && + exampleValue + ) { + // For tests, always use env variable; for samples, use example value + subscriptionIdValue = isForTest + ? `${envType}.SUBSCRIPTION_ID || ""` + : serializeExampleValue(exampleValue.value); + continue; + } + + result.push( + prepareCommonValue( + exampleValue.parameter.name, + exampleValue.value, + param.optional, + param.onClient + ) + ); + } + + // Add subscriptionId for ARM clients if needed + if (dpgContext.arm && getSubscriptionId(dpgContext)) { + result.push( + prepareCommonValue("subscriptionId", subscriptionIdValue, false, true) + ); + } + + // Handle body parameters + const bodyParam = method.operation.bodyParam; + const bodySerializeName = bodyParam?.serializedName; + const bodyExample = parameterMap[bodySerializeName ?? ""]; + if (bodySerializeName && bodyExample && bodyExample.value) { + if ( + isSpreadBodyParameter(bodyParam) && + bodyParam.type.kind === "model" && + bodyExample.value.kind === "model" + ) { + for (const prop of bodyParam.type.properties) { + const propExample = bodyExample.value.value[prop.name]; + if (!propExample) { + continue; + } + result.push( + prepareCommonValue( + prop.name, + propExample, + prop.optional, + prop.onClient + ) + ); + } + } else { + result.push( + prepareCommonValue( + bodyParam.name, + bodyExample.value, + bodyParam.optional, + bodyParam.onClient + ) + ); + } + } + + // Handle optional parameters that have examples + method.operation.parameters + .filter( + (param) => + param.optional === true && + parameterMap[param.serializedName] && + !param.clientDefaultValue + ) + .forEach((param) => { + const exampleValue = parameterMap[param.serializedName]; + if (exampleValue && exampleValue.value) { + result.push( + prepareCommonValue( + param.name, + exampleValue.value, + true, + param.onClient + ) + ); + } + }); + + return result; +} + +/** + * Common client and method iteration logic + */ +export function iterateClientsAndMethods( + dpgContext: SdkContext, + callback: ( + dpgContext: SdkContext, + method: ServiceOperation, + options: ClientEmitOptions + ) => SourceFile | undefined +): SourceFile[] { + const generatedFiles: SourceFile[] = []; + const clients = dpgContext.sdkPackage.clients; + + for (const client of clients) { + const methodMap = getMethodHierarchiesMap(dpgContext, client); + for (const [prefixKey, methods] of methodMap) { + const hierarchies = prefixKey ? prefixKey.split("/") : []; + const prefix = hierarchies + .map((name) => { + return normalizeName(name, NameType.Property); + }) + .join("."); + for (const method of methods) { + callback(dpgContext, method, { + client, + generatedFiles, + classicalMethodPrefix: prefix, + subFolder: + clients.length > 1 + ? normalizeName(getClassicalClientName(client), NameType.File) + : undefined, + hierarchies: hierarchies + }); + } + } + } + return generatedFiles; +} + +/** + * Generate common method call logic + */ +export function generateMethodCall( + method: ServiceOperation, + parameters: CommonValue[], + options: ClientEmitOptions, + dpgContext?: SdkContext +): { methodCall: string; clientParams: string[]; clientParamDefs: string[] } { + // Prepare client-level parameters + const clientParamValues = parameters.filter((p) => p.onClient); + const clientParams: string[] = clientParamValues + .filter((p) => !p.isOptional) + .map((param) => param.name); + const clientParamDefs: string[] = clientParamValues + .filter((p) => !p.isOptional) + .map((param) => `const ${param.name} = ${param.value};`); + + // Prepare operation-level parameters + const methodParamValues = parameters.filter((p) => !p.onClient); + + let methodParams: string[] = []; + + // If dpgContext is provided, reorder parameters according to function signature + if (dpgContext) { + // Get the actual function signature parameter order + const operationFunction = getOperationFunction( + dpgContext, + [options.hierarchies ?? [], method], + "Client" + ); + + // Extract parameter names from the function signature (excluding context and options) + const signatureParamNames = + operationFunction.parameters + ?.filter( + (p) => + p.name !== "context" && + !p.type?.toString().includes("OptionalParams") + ) + .map((p) => p.name) ?? []; + + // Create a map for quick lookup of parameter values by name + const paramValueMap = new Map(methodParamValues.map((p) => [p.name, p])); + + // Reorder methodParamValues according to the signature order + const orderedRequiredParams = signatureParamNames + .map((name) => paramValueMap.get(name)) + .filter((p): p is CommonValue => p !== undefined && !p.isOptional); + + methodParams = orderedRequiredParams.map((p) => `${p.value}`); + } else { + // Original logic when dpgContext is not provided + methodParams = methodParamValues + .filter((p) => !p.isOptional) + .map((p) => `${p.value}`); + } + + const optionalParams = methodParamValues + .filter((p) => p.isOptional) + .map((param) => `${param.name}: ${param.value}`); + if (optionalParams.length > 0) { + methodParams.push(`{${optionalParams.join(", ")}}`); + } + + const prefix = options.classicalMethodPrefix + ? `${options.classicalMethodPrefix}.` + : ""; + const methodCall = `client.${prefix}${normalizeName(method.oriName ?? method.name, NameType.Property)}(${methodParams.join(", ")})`; + + return { methodCall, clientParams, clientParamDefs }; +} + +/** + * Common source file creation logic + */ +export function createSourceFile( + dpgContext: SdkContext, + method: ServiceOperation, + options: ClientEmitOptions, + type: "sample" | "test", + fileName: string +): SourceFile { + const project = useContext("outputProject"); + const operationPrefix = `${options.classicalMethodPrefix ?? ""} ${ + method.oriName ?? method.name + }`; + const baseFolder = + type === "sample" ? "samples-dev" : join("test", "generated"); + const folder = join( + dpgContext.generationPathDetail?.rootDir ?? "", + baseFolder, + options.subFolder ?? "" + ); + const fileExtension = type === "sample" ? ".ts" : ".spec.ts"; + const normalizedFileName = normalizeName( + fileName || `${operationPrefix} ${type}`, + NameType.File + ); + + return project.createSourceFile( + join(folder, `${normalizedFileName}${fileExtension}`), + "", + { overwrite: true } + ); +} + +/** + * Generate assertions for a specific value (recursive for nested objects) + */ +export function generateAssertionsForValue( + value: SdkExampleValue, + path: string, + maxDepth: number = 3, + currentDepth: number = 0 +): string[] { + const assertions: string[] = []; + + // Prevent infinite recursion for deeply nested objects + if (currentDepth >= maxDepth) { + return assertions; + } + + switch (value.kind) { + case "string": { + switch (value.type.kind) { + case "utcDateTime": + assertions.push( + `assert.strictEqual(${path}, new Date("${value.value}"));` + ); + break; + case "bytes": { + const encode = value.type.encode ?? "base64"; + assertions.push( + `assert.equal(${path}, Buffer.from("${value.value}", "${encode}"));` + ); + break; + } + default: { + const retValue = `"${value.value + ?.toString() + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + .replace(/\f/g, "\\f") + .replace(/>/g, ">") + .replace(/ 0) { + assertions.push(`assert.ok(Array.isArray(${path}));`); + assertions.push( + `assert.strictEqual(${path}.length, ${value.value.length});` + ); + + // Assert on first few items to avoid overly verbose tests + const itemsToCheck = Math.min(value.value.length, 2); + for (let i = 0; i < itemsToCheck; i++) { + const item = value.value[i]; + if (item) { + const itemAssertions = generateAssertionsForValue( + item, + `${path}[${i}]`, + maxDepth, + currentDepth + 1 + ); + assertions.push(...itemAssertions); + } + } + } + break; + + case "model": + case "dict": + if (value.value && typeof value.value === "object") { + const entries = Object.entries(value.value); + const propertiesToCheck = entries; + + for (const [key, val] of propertiesToCheck) { + if (val && typeof val === "object" && "kind" in val) { + const propPath = `${path}.${key}`; + const propAssertions = generateAssertionsForValue( + val as SdkExampleValue, + propPath, + maxDepth, + currentDepth + 1 + ); + assertions.push(...propAssertions); + } + } + } + break; + + case "null": + assertions.push(`assert.strictEqual(${path}, null);`); + break; + + case "union": + // For unions, generate assertions for the actual value + if (value.value) { + const unionAssertions = generateAssertionsForValue( + value.value as SdkExampleValue, + path, + maxDepth, + currentDepth + ); + assertions.push(...unionAssertions); + } + break; + } + + return assertions; +} + +/** + * Generate response assertions based on the example responses + */ +export function generateResponseAssertions( + example: SdkHttpOperationExample, + resultVariableName: string, + isPaging: boolean = false +): string[] { + const assertions: string[] = []; + + // Get the responses + const responses = example.responses; + if (!responses || Object.keys(responses).length === 0) { + return assertions; + } + + // TypeSpec SDK uses numeric indices for responses, get the first response + const responseKeys = Object.keys(responses); + if (responseKeys.length === 0) { + return assertions; + } + + const firstResponseKey = responseKeys[0]; + if (!firstResponseKey) { + return assertions; + } + + const firstResponse = (responses as any)[firstResponseKey]; + const responseBody = firstResponse?.bodyValue; + + if (!responseBody) { + return assertions; + } + + if (isPaging) { + // For paging operations, the response body should have a 'value' array + if (responseBody.kind === "model" || responseBody.kind === "dict") { + const responseValue = responseBody.value as Record< + string, + SdkExampleValue + >; + const valueArray = responseValue?.["value"]; + + if (valueArray && valueArray.kind === "array" && valueArray.value) { + // Assert on the length of the collected results + assertions.push( + `assert.strictEqual(${resultVariableName}.length, ${valueArray.value.length});` + ); + + // Assert on the first item if available + if (valueArray.value.length > 0) { + const firstItem = valueArray.value[0]; + if (firstItem) { + const itemAssertions = generateAssertionsForValue( + firstItem, + `${resultVariableName}[0]`, + 2, // Limit depth for paging items + 0 + ); + assertions.push(...itemAssertions); + } + } + } + } + } else { + // Generate assertions based on response body structure + const responseAssertions = generateAssertionsForValue( + responseBody, + resultVariableName + ); + assertions.push(...responseAssertions); + } + + return assertions; +} + +function getEnvironmentVariableName( + paramName: string, + clientName?: string +): string { + // Remove "Param" suffix if present + const cleanName = paramName.replace(/Param$/, ""); + + // Remove "Client" suffix from client name if present and convert to UPPER_SNAKE_CASE + let prefix = ""; + if (clientName) { + const cleanClientName = clientName.replace(/Client$/, ""); + prefix = + cleanClientName + .replace(/([A-Z])/g, "_$1") + .toUpperCase() + .replace(/^_/, "") + "_"; + } + + // Convert camelCase to UPPER_SNAKE_CASE + const envVarName = cleanName + .replace(/([A-Z])/g, "_$1") + .toUpperCase() + .replace(/^_/, ""); + + return `process.env.${prefix}${envVarName} || ""`; +} diff --git a/packages/typespec-ts/src/modular/static-helpers-metadata.ts b/packages/typespec-ts/src/modular/static-helpers-metadata.ts index 0962e8c26a..bf16380f28 100644 --- a/packages/typespec-ts/src/modular/static-helpers-metadata.ts +++ b/packages/typespec-ts/src/modular/static-helpers-metadata.ts @@ -114,3 +114,11 @@ export const CloudSettingHelpers = { location: "cloudSettingHelpers.ts" } } as const; + +export const CreateRecorderHelpers = { + createRecorder: { + kind: "function", + name: "createRecorder", + location: "recordedClient.ts" + } +} as const; diff --git a/packages/typespec-ts/static/test-helpers/recordedClient.ts b/packages/typespec-ts/static/test-helpers/recordedClient.ts new file mode 100644 index 0000000000..263876f160 --- /dev/null +++ b/packages/typespec-ts/static/test-helpers/recordedClient.ts @@ -0,0 +1,30 @@ +import type { + RecorderStartOptions, + VitestTestContext +} from "@azure-tools/test-recorder"; +import { Recorder } from "@azure-tools/test-recorder"; + +const replaceableVariables: Record = { + SUBSCRIPTION_ID: "azure_subscription_id" +}; + +const recorderEnvSetup: RecorderStartOptions = { + envSetupForPlayback: replaceableVariables, + removeCentralSanitizers: [ + "AZSDK3493", // .name in the body is not a secret and is listed below in the beforeEach section + "AZSDK3430" // .id in the body is not a secret and is listed below in the beforeEach section + ] +}; + +/** + * creates the recorder and reads the environment variables from the `.env` file. + * Should be called first in the test suite to make sure environment variables are + * read before they are being used. + */ +export async function createRecorder( + context: VitestTestContext +): Promise { + const recorder = new Recorder(context); + await recorder.start(recorderEnvSetup); + return recorder; +} diff --git a/packages/typespec-ts/test-next/integration/load-static-files.test.ts b/packages/typespec-ts/test-next/integration/load-static-files.test.ts index 12db3f151d..94c1574f01 100644 --- a/packages/typespec-ts/test-next/integration/load-static-files.test.ts +++ b/packages/typespec-ts/test-next/integration/load-static-files.test.ts @@ -26,7 +26,7 @@ describe("loadStaticHelpers", () => { const helperDeclarations = await loadStaticHelpers(project, helpers, { helpersAssetDirectory }); - expect(project.getSourceFiles()).to.toHaveLength(1); + expect(project.getSourceFiles()).to.toHaveLength(2); const buildCsvCollectionDeclaration = helperDeclarations.get( refkey(helpers.buildCsvCollection) ); diff --git a/packages/typespec-ts/test/modularUnit/scenarios.spec.ts b/packages/typespec-ts/test/modularUnit/scenarios.spec.ts index b1d73e8f57..d3a49d8e81 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios.spec.ts +++ b/packages/typespec-ts/test/modularUnit/scenarios.spec.ts @@ -7,7 +7,8 @@ import { emitModularModelsFromTypeSpec, emitModularOperationsFromTypeSpec, emitRootIndexFromTypeSpec, - emitSamplesFromTypeSpec + emitSamplesFromTypeSpec, + emitTestsFromTypeSpec } from "../util/emitUtil.js"; import { assertEqualContent, ExampleJson } from "../util/testUtil.js"; import { format } from "prettier"; @@ -195,6 +196,95 @@ const OUTPUT_CODE_BLOCK_TYPES: Record = { return text; }, + // Pattern for multiple test files - each file gets its own block + "(ts|typescript) tests {fileName}": async ( + tsp, + { fileName }, + namedUnknownArgs + ) => { + if (!namedUnknownArgs || !namedUnknownArgs["examples"]) { + throw new Error(`Expected 'examples' to be passed in as an argument`); + } + const configs = namedUnknownArgs["configs"] as Record; + const examples = namedUnknownArgs["examples"] as ExampleJson[]; + const result = await emitTestsFromTypeSpec(tsp, examples, configs); + + // Normalize fileName to handle both "backupTest" and "backupTest.spec.ts" patterns + const normalizedFileName = fileName?.replace(/\.spec\.ts$/, "") || ""; + + // Find the specific file by name + const targetFile = result.find((x) => + x.getFilePath().includes(normalizedFileName) + ); + if (!targetFile) { + throw new Error( + `File with name containing '${normalizedFileName}' not found in generated tests` + ); + } + + return `/** This file path is ${targetFile.getFilePath()} */\n\n${targetFile.getFullText()}`; + }, + + // Legacy pattern for single test file (backward compatibility) + "(ts|typescript) tests": async (tsp, {}, namedUnknownArgs) => { + if (!namedUnknownArgs || !namedUnknownArgs["examples"]) { + throw new Error(`Expected 'examples' to be passed in as an argument`); + } + const configs = namedUnknownArgs["configs"] as Record; + const examples = namedUnknownArgs["examples"] as ExampleJson[]; + const result = await emitTestsFromTypeSpec(tsp, examples, configs); + + if (result.length === 1) { + // Single file - return as before + const file = result[0]!; + let content = file.getFullText(); + + // Fix malformed content by adding line breaks after common patterns + content = content + .replace(/;\s*import\s/g, ";\nimport ") + .replace(/}\s*import\s/g, "}\nimport ") + .replace(/;\s*const\s/g, ";\nconst ") + .replace(/}\s*const\s/g, "}\nconst ") + .replace(/;\s*export\s/g, ";\nexport ") + .replace(/}\s*export\s/g, "}\nexport ") + .replace(/;\s*describe\s/g, ";\ndescribe ") + .replace(/}\s*describe\s/g, "}\ndescribe ") + .replace(/{\s*let\s/g, "{\n let ") + .replace(/;\s*beforeEach\s/g, ";\n beforeEach ") + .replace(/;\s*afterEach\s/g, ";\n afterEach ") + .replace(/;\s*it\s/g, ";\n it ") + .replace(/}\s*\);/g, "}\n);") + .replace(/\/\*\* This file path is/g, "\n\n/** This file path is"); + + return `/** This file path is ${file.getFilePath()} */\n\n${content}`; + } else { + // Multiple files - join them but warn this should be separate blocks + const text = result + .map((x) => { + let content = x.getFullText(); + // Apply the same fixes for multiple files + content = content + .replace(/;\s*import\s/g, ";\nimport ") + .replace(/}\s*import\s/g, "}\nimport ") + .replace(/;\s*const\s/g, ";\nconst ") + .replace(/}\s*const\s/g, "}\nconst ") + .replace(/;\s*export\s/g, ";\nexport ") + .replace(/}\s*export\s/g, "}\nexport ") + .replace(/;\s*describe\s/g, ";\ndescribe ") + .replace(/}\s*describe\s/g, "}\ndescribe ") + .replace(/{\s*let\s/g, "{\n let ") + .replace(/;\s*beforeEach\s/g, ";\n beforeEach ") + .replace(/;\s*afterEach\s/g, ";\n afterEach ") + .replace(/;\s*it\s/g, ";\n it ") + .replace(/}\s*\);/g, "}\n);"); + + return `/** This file path is ${x.getFilePath()} */\n\n${content}`; + }) + .join("\n\n"); + return text; + } + }, + //Snapshot of the clientContext file for a given typespec "(ts|typescript) clientContext": async (tsp, {}, namedUnknownArgs) => { const configs = namedUnknownArgs diff --git a/packages/typespec-ts/test/modularUnit/scenarios/samples/client/parameterizedHost.md b/packages/typespec-ts/test/modularUnit/scenarios/samples/client/parameterizedHost.md index f981a4b13f..8a97c9afc1 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios/samples/client/parameterizedHost.md +++ b/packages/typespec-ts/test/modularUnit/scenarios/samples/client/parameterizedHost.md @@ -115,3 +115,9 @@ async function main() { main().catch(console.error); ``` + +## Generated tests + +```ts tests + +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/samples/client/renameClientName.md b/packages/typespec-ts/test/modularUnit/scenarios/samples/client/renameClientName.md index 1a01b65f6e..a883e8cdde 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios/samples/client/renameClientName.md +++ b/packages/typespec-ts/test/modularUnit/scenarios/samples/client/renameClientName.md @@ -81,3 +81,35 @@ async function main(): Promise { main().catch(console.error); ``` + +## Generated tests + +```ts tests +/** This file path is /test/generated/readTest.spec.ts */ + +import { TestServiceClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestServiceClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TEST_SERVICE_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestServiceClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for read", async function () { + const result = await client.read(); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterOrdering.md b/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterOrdering.md index 55dc98b142..41a8ab2d84 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterOrdering.md +++ b/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterOrdering.md @@ -176,10 +176,10 @@ import { TestingClient } from "@azure/internal-test"; async function verify(): Promise { const endpoint = process.env.TESTING_ENDPOINT || ""; const client = new TestingClient(endpoint); - const result = await client.verify({ - message: "test message", - }, - "zdgrzzaxlodrvewbksn"); + const result = await client.verify( + { message: "test message" }, + "zdgrzzaxlodrvewbksn", + ); console.log(result); } @@ -188,4 +188,4 @@ async function main(): Promise { } main().catch(console.error); -``` \ No newline at end of file +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterTypesCheck.md b/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterTypesCheck.md index 592607802e..8c65896dca 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterTypesCheck.md +++ b/packages/typespec-ts/test/modularUnit/scenarios/samples/parameters/parameterTypesCheck.md @@ -38,7 +38,10 @@ model Widget { offsetDateTimeProp: offsetDateTime; durationProp: duration; withEscapeChars: string; - unknownRecord: Record + unknownRecord: Record; + certificate?: bytes; + @encode("base64url") + profile?: bytes; } @doc("show example demo") @@ -75,6 +78,7 @@ op read(@bodyRoot body: Widget): { @body body: {}}; "unionValue": "test", "nullValue": null, "additionalProp": "additional prop", + "additionalProp2": "additional prop2", "renamedProp": "prop renamed", "stringLiteral": "foo", "booleanLiteral": true, @@ -85,7 +89,9 @@ op read(@bodyRoot body: Widget): { @body body: {}}; "offsetDateTimeProp": "2022-08-26T18:38:00Z", "durationProp": "P123DT22H14M12.011S", "withEscapeChars": "\"Tag 10\".Value", - "unknownRecord": { "a": "foo" } + "unknownRecord": { "a": "foo" }, + "certificate": "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "profile": "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V" } }, "responses": { @@ -141,8 +147,17 @@ async function read(): Promise { durationProp: "P123DT22H14M12.011S", withEscapeChars: '"Tag 10".Value', unknownRecord: { a: "foo" }, + certificate: Buffer.from( + "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "base64", + ), + profile: Buffer.from( + "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "base64url", + ), additionalProperties: { additionalProp: "additional prop", + additionalProp2: "additional prop2", }, }); console.log(result); diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/basicOperationTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/basicOperationTest.md new file mode 100644 index 0000000000..2ec6739028 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/basicOperationTest.md @@ -0,0 +1,141 @@ +# Should generate basic test for simple operation + +Test generation should create basic test for a simple operation with examples. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Age of employee */ + age?: int32; + /** City of employee */ + city?: string; + /** Profile of employee */ + profile?: string, +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; +} +``` + +## Example + +Raw json files. + +```json for Employees_Get +{ + "title": "Employees_Get", + "operationId": "Employees_Get", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi", + "employeeName": "testEmployee" + }, + "responses": { + "200": { + "body": { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + "name": "testEmployee", + "type": "Microsoft.Contoso/employees", + "location": "eastus", + "properties": { + "age": 30, + "city": "Seattle", + "profile": "developer" + }, + "tags": { + "environment": "test" + } + } + } + } +} +``` + +## Generated tests + +```ts tests getTest +/** This file path is /test/generated/getTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("get a Employee", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should get a Employee for employeesGet", async function () { + const result = await client.get("rgopenapi", "testEmployee"); + assert.ok(result); + assert.strictEqual( + result.id, + "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + ); + assert.strictEqual(result.name, "testEmployee"); + assert.strictEqual(result.type, "Microsoft.Contoso/employees"); + assert.strictEqual(result.location, "eastus"); + assert.strictEqual(result.properties.age, 30); + assert.strictEqual(result.properties.city, "Seattle"); + assert.strictEqual(result.properties.profile, "developer"); + assert.strictEqual(result.tags.environment, "test"); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/clientParameterTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/clientParameterTest.md new file mode 100644 index 0000000000..7fb7123ad9 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/clientParameterTest.md @@ -0,0 +1,266 @@ +# Sample generation should generate required client-level parameters + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.Core.Traits; + +@useAuth(AadOauth2Auth<["https://contoso.azure.com/.default"]>) +@service(#{ + title: "Contoso Widget Manager", +}) +@versioned(Contoso.WidgetManager.Versions) +namespace Azure.Contoso.WidgetManager; + +@doc("Versions info.") +enum Versions { + @doc("The 2021-10-01-preview version.") + v2021_10_01_preview: "2021-10-01-preview", +} + +@doc("A widget.") +@resource("widgets") +model WidgetSuite { + @key("widgetName") + @doc("The widget name.") + @visibility(Lifecycle.Read) + name: string; + + @doc("The ID of the widget's manufacturer.") + manufacturerId: string; + +} + +interface Widgets { + @doc("List Widget resources") + listWidgets is ResourceList< + WidgetSuite, + ListQueryParametersTrait + >; +} +``` + +## Example + +Raw json files. + +```json for Widgets_ListWidgets +{ + "title": "Widgets_ListWidgets", + "operationId": "Widgets_ListWidgets", + "parameters": { + "top": 8, + "skip": 15, + "maxpagesize": 27, + "api-version": "2021-10-01-preview" + }, + "responses": { + "200": { + "body": {} + } + } +} +``` + +This is the tspconfig.yaml. + +```yaml +hierarchy-client: true +enable-operation-group: false +``` + +## Generated tests + +```ts tests +/** This file path is /test/generated/widgetsListWidgetsTest.spec.ts */ + +import { WidgetManagerClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("list Widget resources", () => { + let recorder: Recorder; + let client: WidgetManagerClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = createTestCredential(); + const clientOptions = recorder.configureClientOptions({}); + client = new WidgetManagerClient(endpoint, credential, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should list Widget resources for widgetsListWidgets", async function () { + const resArray = new Array(); + for await (const item of client.widgets.listWidgets({ + top: 8, + skip: 15, + maxpagesize: 27, + })) { + resArray.push(item); + } + assert.ok(resArray); + }); +}); +``` + +# Sample generation should generate client-level subscriptionId for ARM clients + +## TypeSpec + +This is tsp definition. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Age of employee */ + age?: int32; + /** City of employee */ + city?: string; + /** Profile of employee */ + profile?: string, +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; +} +``` + +## Example + +Raw json files. + +```json for Employees_Get +{ + "title": "Employees_Get", + "operationId": "Employees_Get", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi", + "employeeName": "testEmployee" + }, + "responses": { + "200": { + "body": { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + "name": "testEmployee", + "type": "Microsoft.Contoso/employees", + "location": "eastus", + "properties": { + "age": 30, + "city": "Seattle", + "profile": "developer" + }, + "tags": { + "environment": "test" + } + } + } + } +} +``` + +## Generated tests + +```ts tests +/** This file path is /test/generated/getTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("get a Employee", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should get a Employee for employeesGet", async function () { + const result = await client.get("rgopenapi", "testEmployee"); + assert.ok(result); + assert.strictEqual( + result.id, + "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + ); + assert.strictEqual(result.name, "testEmployee"); + assert.strictEqual(result.type, "Microsoft.Contoso/employees"); + assert.strictEqual(result.location, "eastus"); + assert.strictEqual(result.properties.age, 30); + assert.strictEqual(result.properties.city, "Seattle"); + assert.strictEqual(result.properties.profile, "developer"); + assert.strictEqual(result.tags.environment, "test"); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/complexResponseTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/complexResponseTest.md new file mode 100644 index 0000000000..b3fb931ec5 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/complexResponseTest.md @@ -0,0 +1,177 @@ +# Should generate test with complex nested response assertions + +Test generation should create proper response assertions for complex nested objects. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Department information */ + department?: DepartmentInfo; + /** Skills array */ + skills?: string[]; + /** Projects map */ + projects?: Record; + /** Active status */ + isActive?: boolean; +} + +/** Department information */ +model DepartmentInfo { + /** Department name */ + name?: string; + /** Manager info */ + manager?: ManagerInfo; + /** Budget */ + budget?: int32; +} + +/** Manager information */ +model ManagerInfo { + /** Manager name */ + name?: string; + /** Manager email */ + email?: string; +} + +/** Project details */ +model ProjectDetails { + /** Project name */ + title?: string; + /** Project status */ + status?: string; +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; +} +``` + +## Example and generated tests + +Raw json files. + +```json for Employees_Get +{ + "title": "Employees_Get", + "operationId": "Employees_Get", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi", + "employeeName": "complexEmployee" + }, + "responses": { + "200": { + "body": { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/complexEmployee", + "name": "complexEmployee", + "type": "Microsoft.Contoso/employees", + "location": "eastus", + "properties": { + "department": { + "name": "Engineering", + "manager": { + "name": "John Doe", + "email": "john.doe@contoso.com" + }, + "budget": 500000 + }, + "skills": ["TypeScript", "Azure", "REST"], + "projects": { + "project1": { + "title": "Cloud Migration", + "status": "active" + }, + "project2": { + "title": "API Modernization", + "status": "completed" + } + }, + "isActive": true + } + } + } + } +} +``` + +```ts tests getTest +/** This file path is /test/generated/getTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("get a Employee", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should get a Employee for employeesGet", async function () { + const result = await client.get("rgopenapi", "complexEmployee"); + assert.ok(result); + assert.strictEqual( + result.id, + "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/complexEmployee", + ); + assert.strictEqual(result.name, "complexEmployee"); + assert.strictEqual(result.type, "Microsoft.Contoso/employees"); + assert.strictEqual(result.location, "eastus"); + assert.ok(Array.isArray(result.properties.skills)); + assert.strictEqual(result.properties.skills.length, 3); + assert.strictEqual(result.properties.isActive, true); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/dpgOperationsTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/dpgOperationsTest.md new file mode 100644 index 0000000000..733bf89c0c --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/dpgOperationsTest.md @@ -0,0 +1,319 @@ +# Should generate samples for data-plane operations + +Sample generation should dpg template and operations successfully. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.Core.Traits; + +@useAuth(AadOauth2Auth<["https://contoso.azure.com/.default"]>) +@service(#{ + title: "Contoso Widget Manager", +}) +@versioned(Contoso.WidgetManager.Versions) +namespace Azure.Contoso.WidgetManager; + +@doc("Versions info.") +enum Versions { + @doc("The 2021-10-01-preview version.") + v2021_10_01_preview: "2021-10-01-preview", +} + +@doc("A widget.") +@resource("widgets") +model WidgetSuite { + @key("widgetName") + @doc("The widget name.") + @visibility(Lifecycle.Read) + name: string; + + @doc("The ID of the widget's manufacturer.") + manufacturerId: string; + +} + +interface Widgets { + @doc("Fetch a Widget by name.") + getWidget is ResourceRead; + + @doc("Gets status of a Widget operation.") + getWidgetOperationStatus is GetResourceOperationStatus; + + @doc("Creates or updates a Widget asynchronously.") + @pollingOperation(Widgets.getWidgetOperationStatus) + createOrUpdateWidget is StandardResourceOperations.LongRunningResourceCreateOrUpdate; + + @doc("Delete a Widget asynchronously.") + @pollingOperation(Widgets.getWidgetOperationStatus) + deleteWidget is LongRunningResourceDelete; + + @doc("List Widget resources") + listWidgets is ResourceList< + WidgetSuite, + ListQueryParametersTrait + >; +} +``` + +## Example + +Raw json files. + +```json for Widgets_ListWidgets +{ + "title": "Widgets_ListWidgets", + "operationId": "Widgets_ListWidgets", + "parameters": { + "top": 8, + "skip": 15, + "maxpagesize": 27, + "api-version": "2021-10-01-preview" + }, + "responses": { + "200": { + "body": {} + } + } +} +``` + +```json for Widgets_CreateOrUpdateWidget +{ + "title": "Widgets_CreateOrUpdateWidget", + "operationId": "Widgets_CreateOrUpdateWidget", + "parameters": { + "widgetName": "name1", + "api-version": "2021-10-01-preview", + "resource": { + "manufacturerId": "manufacturer id1" + } + }, + "responses": { + "200": {} + } +} +``` + +```json for Widgets_DeleteWidget +{ + "operationId": "Widgets_DeleteWidget", + "title": "Delete widget by widget name using long-running operation.", + "parameters": { + "api-version": "2021-10-01-preview", + "widgetName": "searchbox" + }, + "responses": { + "202": {} + } +} +``` + +This is the tspconfig.yaml. + +```yaml +hierarchy-client: true +enable-operation-group: false +``` + +## Samples + +Generate samples for dpg cases: + +```ts samples +/** This file path is /samples-dev/widgetsListWidgetsSample.ts */ +import { WidgetManagerClient } from "@azure/internal-test"; +import { DefaultAzureCredential } from "@azure/identity"; + +/** + * This sample demonstrates how to list Widget resources + * + * @summary list Widget resources + * x-ms-original-file: 2021-10-01-preview/json_for_Widgets_ListWidgets.json + */ +async function widgetsListWidgets(): Promise { + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = new DefaultAzureCredential(); + const client = new WidgetManagerClient(endpoint, credential); + const resArray = new Array(); + for await (const item of client.widgets.listWidgets({ + top: 8, + skip: 15, + maxpagesize: 27, + })) { + resArray.push(item); + } + + console.log(resArray); +} + +async function main(): Promise { + await widgetsListWidgets(); +} + +main().catch(console.error); + +/** This file path is /samples-dev/widgetsDeleteWidgetSample.ts */ +import { WidgetManagerClient } from "@azure/internal-test"; +import { DefaultAzureCredential } from "@azure/identity"; + +/** + * This sample demonstrates how to delete a Widget asynchronously. + * + * @summary delete a Widget asynchronously. + * x-ms-original-file: 2021-10-01-preview/json_for_Widgets_DeleteWidget.json + */ +async function deleteWidgetByWidgetNameUsingLongRunningOperation(): Promise { + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = new DefaultAzureCredential(); + const client = new WidgetManagerClient(endpoint, credential); + const result = await client.widgets.deleteWidget("searchbox"); + console.log(result); +} + +async function main(): Promise { + await deleteWidgetByWidgetNameUsingLongRunningOperation(); +} + +main().catch(console.error); + +/** This file path is /samples-dev/widgetsCreateOrUpdateWidgetSample.ts */ +import { WidgetManagerClient } from "@azure/internal-test"; +import { DefaultAzureCredential } from "@azure/identity"; + +/** + * This sample demonstrates how to creates or updates a Widget asynchronously. + * + * @summary creates or updates a Widget asynchronously. + * x-ms-original-file: 2021-10-01-preview/json_for_Widgets_CreateOrUpdateWidget.json + */ +async function widgetsCreateOrUpdateWidget(): Promise { + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = new DefaultAzureCredential(); + const client = new WidgetManagerClient(endpoint, credential); + const result = await client.widgets.createOrUpdateWidget("name1", { + manufacturerId: "manufacturer id1", + }); + console.log(result); +} + +async function main(): Promise { + await widgetsCreateOrUpdateWidget(); +} + +main().catch(console.error); +``` + +## Generated tests + +```ts tests +/** This file path is /test/generated/widgetsListWidgetsTest.spec.ts */ + +import { WidgetManagerClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("list Widget resources", () => { + let recorder: Recorder; + let client: WidgetManagerClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = createTestCredential(); + const clientOptions = recorder.configureClientOptions({}); + client = new WidgetManagerClient(endpoint, credential, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should list Widget resources for widgetsListWidgets", async function () { + const resArray = new Array(); + for await (const item of client.widgets.listWidgets({ + top: 8, + skip: 15, + maxpagesize: 27, + })) { + resArray.push(item); + } + assert.ok(resArray); + }); +}); + +/** This file path is /test/generated/widgetsDeleteWidgetTest.spec.ts */ + +import { WidgetManagerClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("delete a Widget asynchronously", () => { + let recorder: Recorder; + let client: WidgetManagerClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = createTestCredential(); + const clientOptions = recorder.configureClientOptions({}); + client = new WidgetManagerClient(endpoint, credential, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should delete a Widget asynchronously for deleteWidgetByWidgetNameUsingLongRunningOperation", async function () { + const result = await client.widgets.deleteWidget("searchbox"); + assert.ok(result); + }); +}); + +/** This file path is /test/generated/widgetsCreateOrUpdateWidgetTest.spec.ts */ + +import { WidgetManagerClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("creates or updates a Widget asynchronously", () => { + let recorder: Recorder; + let client: WidgetManagerClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.WIDGET_MANAGER_ENDPOINT || ""; + const credential = createTestCredential(); + const clientOptions = recorder.configureClientOptions({}); + client = new WidgetManagerClient(endpoint, credential, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should creates or updates a Widget asynchronously for widgetsCreateOrUpdateWidget", async function () { + const result = await client.widgets.createOrUpdateWidget("name1", { + manufacturerId: "manufacturer id1", + }); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/lroOperationTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/lroOperationTest.md new file mode 100644 index 0000000000..07a05d8076 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/lroOperationTest.md @@ -0,0 +1,167 @@ +# Should generate test for long running operation + +Test generation should create test for long running operations with proper poller handling. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Age of employee */ + age?: int32; + /** City of employee */ + city?: string; + /** Profile of employee */ + profile?: string, +} + +@armResourceOperations +interface Employees { + createOrUpdate is ArmResourceCreateOrReplaceAsync; +} +``` + +## Example and generated tests + +Raw json files. + +```json for Employees_CreateOrUpdate +{ + "title": "Employees_CreateOrUpdate", + "operationId": "Employees_CreateOrUpdate", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi", + "employeeName": "testEmployee", + "resource": { + "location": "eastus", + "properties": { + "age": 25, + "city": "Seattle", + "profile": "developer" + }, + "tags": { + "environment": "test" + } + } + }, + "responses": { + "200": { + "body": { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + "name": "testEmployee", + "type": "Microsoft.Contoso/employees", + "location": "eastus", + "properties": { + "age": 25, + "city": "Seattle", + "profile": "developer" + }, + "tags": { + "environment": "test" + } + } + }, + "201": { + "body": { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + "name": "testEmployee", + "type": "Microsoft.Contoso/employees", + "location": "eastus", + "properties": { + "age": 25, + "city": "Seattle", + "profile": "developer" + }, + "tags": { + "environment": "test" + } + } + } + } +} +``` + +```ts tests createOrUpdateTest +/** This file path is /test/generated/createOrUpdateTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("create a Employee", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should create a Employee for employeesCreateOrUpdate", async function () { + const result = await client.createOrUpdate("rgopenapi", "testEmployee", { + location: "eastus", + properties: { age: 25, city: "Seattle", profile: "developer" }, + tags: { environment: "test" }, + }); + assert.ok(result); + assert.strictEqual( + result.id, + "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/testEmployee", + ); + assert.strictEqual(result.name, "testEmployee"); + assert.strictEqual(result.type, "Microsoft.Contoso/employees"); + assert.strictEqual(result.location, "eastus"); + assert.strictEqual(result.properties.age, 25); + assert.strictEqual(result.properties.city, "Seattle"); + assert.strictEqual(result.properties.profile, "developer"); + assert.strictEqual(result.tags.environment, "test"); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/pagingOperationTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/pagingOperationTest.md new file mode 100644 index 0000000000..17866b23d4 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/pagingOperationTest.md @@ -0,0 +1,147 @@ +# Should generate test with response assertions for paging operation + +Test generation should create test with proper response assertions for paging operations. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Age of employee */ + age?: int32; + /** City of employee */ + city?: string; + /** Profile of employee */ + profile?: string, +} + +@armResourceOperations +interface Employees { + listByResourceGroup is ArmResourceListByParent; +} +``` + +## Example and generated tests + +Raw json files. + +```json for Employees_ListByResourceGroup +{ + "title": "Employees_ListByResourceGroup", + "operationId": "Employees_ListByResourceGroup", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi" + }, + "responses": { + "200": { + "body": { + "value": [ + { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/emp1", + "name": "emp1", + "type": "Microsoft.Contoso/employees", + "location": "eastus", + "properties": { + "age": 25, + "city": "Boston", + "profile": "designer" + } + }, + { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/emp2", + "name": "emp2", + "type": "Microsoft.Contoso/employees", + "location": "westus", + "properties": { + "age": 35, + "city": "Portland", + "profile": "manager" + } + } + ] + } + } + } +} +``` + +```ts tests listByResourceGroupTest +/** This file path is /test/generated/listByResourceGroupTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("list Employee resources by resource group", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should list Employee resources by resource group for employeesListByResourceGroup", async function () { + const resArray = new Array(); + for await (const item of client.listByResourceGroup("rgopenapi")) { + resArray.push(item); + } + assert.ok(resArray); + assert.strictEqual(resArray.length, 2); + assert.strictEqual( + resArray[0].id, + "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/emp1", + ); + assert.strictEqual(resArray[0].name, "emp1"); + assert.strictEqual(resArray[0].type, "Microsoft.Contoso/employees"); + assert.strictEqual(resArray[0].location, "eastus"); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/operations/voidOperationTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/voidOperationTest.md new file mode 100644 index 0000000000..c6c5b380f5 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/operations/voidOperationTest.md @@ -0,0 +1,110 @@ +# Should generate test for void operation + +Test generation should create test for operations that return void. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Age of employee */ + age?: int32; + /** City of employee */ + city?: string; + /** Profile of employee */ + profile?: string, +} + +@armResourceOperations +interface Employees { + delete is ArmResourceDeleteWithoutOkAsync; +} +``` + +## Example and generated tests + +Raw json files. + +```json for Employees_Delete +{ + "title": "Employees_Delete", + "operationId": "Employees_Delete", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi", + "employeeName": "testEmployee" + }, + "responses": { + "202": {}, + "204": {} + } +} +``` + +```ts tests deleteTest +/** This file path is /test/generated/deleteTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { beforeEach, afterEach, it, describe } from "vitest"; + +describe("delete a Employee", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should delete a Employee for employeesDelete", async function () { + await client.delete("rgopenapi", "testEmployee"); + /* Test passes if no exception is thrown */ + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyOptionalCheckTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyOptionalCheckTest.md new file mode 100644 index 0000000000..7af79d0c6d --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyOptionalCheckTest.md @@ -0,0 +1,88 @@ +# Should generate tests for body optional check + +Test generation should create tests for operations with optional body parameters that can be omitted, ensuring proper handling when the body parameter is optional. + +## TypeSpec + +This is tsp definition. + +```tsp +@doc("This is a simple model.") +model BodyParameter { + name: string; +} +@doc("This is a model with all http request decorator.") +model CompositeRequest { + @path + name: string; + + @query + requiredQuery: string; + + @query + optionalQuery?: string; + + @body + widget?: BodyParameter; +} + +@doc("show example demo") +op read(...CompositeRequest): { @body body: {}}; +``` + +## Example and generated tests + +Raw json files. + +```json for read +{ + "title": "read", + "operationId": "read", + "parameters": { + "name": "required path param", + "optionalQuery": "renamed optional query", + "requiredQuery": "required query", + "body": { + "name": "body name" + } + }, + "responses": { + "200": { + "body": {} + } + } +} +``` + +```ts tests readTest +/** This file path is /test/generated/readTest.spec.ts */ + +import { TestingClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestingClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TESTING_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestingClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for read", async function () { + const result = await client.read("required path param", "required query", { + widget: { name: "body name" }, + optionalQuery: "renamed optional query", + }); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyOptionalParameterTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyOptionalParameterTest.md new file mode 100644 index 0000000000..10fec87566 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyOptionalParameterTest.md @@ -0,0 +1,156 @@ +# Should generate tests for optional body parameter + +Test generation should create tests for operations with optional body parameters, verifying the parameter is correctly handled when present or absent. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; +import "@azure-tools/typespec-client-generator-core"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ClientGenerator.Core; + +@armProviderNamespace +@service(#{ title: "HardwareSecurityModules" }) +@versioned(Versions) +@armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v6) +namespace Microsoft.HardwareSecurityModules; + +enum Versions { + v2021_10_01_preview: "2021-10-01-preview", +} + +model CloudHsmClusterProperties { + statusMessage?: string; +} + +model CloudHsmCluster + is Azure.ResourceManager.TrackedResource { + ...ResourceNameParameter< + Resource = CloudHsmCluster, + KeyName = "cloudHsmClusterName", + SegmentName = "cloudHsmClusters", + NamePattern = "^[a-zA-Z0-9-]{3,23}$" + >; + + identity?: Azure.ResourceManager.CommonTypes.ManagedServiceIdentity; +} + +model BackupRequestProperties extends BackupRestoreRequestBaseProperties {} + +model BackupRestoreRequestBaseProperties { + azureStorageBlobContainerUri: url; + + @secret + token?: string; +} + +model BackupResult { + properties?: BackupResultProperties; +} + +model BackupResultProperties { + azureStorageBlobContainerUri?: url; + backupId?: string; +} + +@armResourceOperations +interface CloudHsmClusters { + backup is ArmResourceActionAsync< + CloudHsmCluster, + BackupRequestProperties, + ArmResponse & Azure.Core.RequestIdResponseHeader, + LroHeaders = ArmAsyncOperationHeader & + ArmLroLocationHeader & + Azure.Core.Foundations.RetryAfterHeader & + Azure.Core.RequestIdResponseHeader, + OptionalRequestBody = true + >; +} +@@clientName(CloudHsmClusters.backup::parameters.body, + "backupRequestProperties" +); +``` + +## Example and generated tests + +Raw json files. + +```json for CloudHsmClusters_backup +{ + "title": "CloudHsmClusters_backup", + "operationId": "CloudHsmClusters_backup", + "parameters": { + "api-version": "2025-03-31", + "body": { + "azureStorageBlobContainerUri": "sss", + "token": "aaa" + }, + "cloudHsmClusterName": "chsm1", + "resourceGroupName": "rgcloudhsm", + "subscriptionId": "00000000-0000-0000-0000-000000000000" + }, + "responses": { + "202": { + "body": { + "properties": { + "azureStorageBlobContainerUri": "sss", + "backupId": "backup123" + } + } + } + } +} +``` + +```ts tests backupTest +/** This file path is /test/generated/backupTest.spec.ts */ + +import { HardwareSecurityModulesClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("a long-running resource action", () => { + let recorder: Recorder; + let client: HardwareSecurityModulesClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new HardwareSecurityModulesClient( + credential, + subscriptionId, + clientOptions, + ); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should a long-running resource action for cloudHsmClustersBackup", async function () { + const result = await client.backup("rgcloudhsm", "chsm1", { + backupRequestProperties: { + azureStorageBlobContainerUri: "sss", + token: "aaa", + }, + }); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyRequiredParameterTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyRequiredParameterTest.md new file mode 100644 index 0000000000..83f686d374 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/bodyRequiredParameterTest.md @@ -0,0 +1,92 @@ +# Should generate tests for body required parameter + +Test generation should create tests for operations with required body parameters, ensuring the parameter is properly handled in the test. + +## TypeSpec + +This is tsp definition. + +```tsp +@doc("This is a simple model.") +model BodyParameter { + name: string; +} +@doc("This is a model with all http request decorator.") +model CompositeRequest { + @path + name: string; + + @query + requiredQuery: string; + + @query + optionalQuery?: string; + + @body + body: BodyParameter; +} + +@doc("show example demo") +op read(...CompositeRequest): { @body body: {}}; +``` + +## Example and generated tests + +Raw json files. + +```json for read +{ + "title": "read", + "operationId": "read", + "parameters": { + "name": "required path param", + "optionalQuery": "renamed optional query", + "required-header": "required header", + "optional-header": "optional header", + "requiredQuery": "required query", + "body": { + "name": "body name" + } + }, + "responses": { + "200": { + "body": {} + } + } +} +``` + +```ts tests readTest +/** This file path is /test/generated/readTest.spec.ts */ + +import { TestingClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestingClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TESTING_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestingClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for read", async function () { + const result = await client.read( + "required path param", + "required query", + { name: "body name" }, + { optionalQuery: "renamed optional query" }, + ); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterNameTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterNameTest.md new file mode 100644 index 0000000000..ea57b55381 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterNameTest.md @@ -0,0 +1,190 @@ +# Should generate tests for parameter name variations + +Test generation should create tests for different parameter names, including client-side parameter name customization. + +## TypeSpec + +This is tsp definition. + +```tsp +import "@azure-tools/typespec-client-generator-core"; + +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ClientGenerator.Core; + +/** Microsoft.Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ + title: "Microsoft.Contoso management service", +}) +@versioned(Microsoft.Contoso.Versions) +namespace Microsoft.Contoso; + +/** The available API versions. */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_10_01_preview: "2021-10-01-preview", +} + +interface Operations extends Azure.ResourceManager.Operations {} + +/** Employee resource */ +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +/** Employee properties */ +model EmployeeProperties { + /** Age of employee */ + age?: int32; + + /** City of employee */ + city?: string; + + /** Profile of employee */ + @encode("base64url") + profile?: bytes; + + /** The status of the last operation. */ + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +/** The resource provisioning state. */ +@lroStatus +union ProvisioningState { + ResourceProvisioningState, + + /** The resource is being provisioned */ + Provisioning: "Provisioning", + + /** The resource is updating */ + Updating: "Updating", + + /** The resource is being deleted */ + Deleting: "Deleting", + + /** The resource create request has been accepted */ + Accepted: "Accepted", + + string, +} + +@armResourceOperations +interface Employees { + createOrUpdate is ArmResourceCreateOrReplaceAsync; +} +@@clientName(Employees.createOrUpdate::parameters.resource, "foo"); +``` + +## Example and generated tests + +Raw json files. + +```json for Employees_CreateOrUpdate +{ + "title": "Employees_CreateOrUpdate", + "operationId": "Employees_CreateOrUpdate", + "parameters": { + "api-version": "2021-10-01-preview", + "subscriptionId": "11809CA1-E126-4017-945E-AA795CD5C5A9", + "resourceGroupName": "rgopenapi", + "employeeName": "9KF-f-8b", + "foo": { + "properties": { + "age": 30, + "city": "gydhnntudughbmxlkyzrskcdkotrxn", + "profile": "ms" + }, + "tags": { + "key2913": "urperxmkkhhkp" + }, + "location": "itajgxyqozseoygnl" + } + }, + "responses": { + "200": { + "body": { + "id": "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/9KF-f-8b", + "name": "9KF-f-8b", + "type": "Microsoft.Contoso/employees", + "location": "itajgxyqozseoygnl", + "properties": { + "age": 30, + "city": "gydhnntudughbmxlkyzrskcdkotrxn", + "profile": "ms", + "provisioningState": "Succeeded" + }, + "tags": { + "key2913": "urperxmkkhhkp" + } + } + } + } +} +``` + +```ts tests createOrUpdateTest +/** This file path is /test/generated/createOrUpdateTest.spec.ts */ + +import { ContosoClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { Recorder, env } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("create a Employee", () => { + let recorder: Recorder; + let client: ContosoClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const credential = createTestCredential(); + const subscriptionId = env.SUBSCRIPTION_ID || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new ContosoClient(credential, subscriptionId, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should create a Employee for employeesCreateOrUpdate", async function () { + const result = await client.createOrUpdate("rgopenapi", "9KF-f-8b", { + properties: { + age: 30, + city: "gydhnntudughbmxlkyzrskcdkotrxn", + profile: Buffer.from("ms", "base64url"), + }, + tags: { key2913: "urperxmkkhhkp" }, + location: "itajgxyqozseoygnl", + }); + assert.ok(result); + assert.strictEqual( + result.id, + "/subscriptions/11809CA1-E126-4017-945E-AA795CD5C5A9/resourceGroups/rgopenapi/providers/Microsoft.Contoso/employees/9KF-f-8b", + ); + assert.strictEqual(result.name, "9KF-f-8b"); + assert.strictEqual(result.type, "Microsoft.Contoso/employees"); + assert.strictEqual(result.location, "itajgxyqozseoygnl"); + assert.strictEqual(result.properties.age, 30); + assert.strictEqual( + result.properties.city, + "gydhnntudughbmxlkyzrskcdkotrxn", + ); + assert.equal(result.properties.profile, Buffer.from("ms", "base64url")); + assert.strictEqual(result.tags.key2913, "urperxmkkhhkp"); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterNormalizationTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterNormalizationTest.md new file mode 100644 index 0000000000..a3e673aa58 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterNormalizationTest.md @@ -0,0 +1,75 @@ +# Should generate tests for parameter normalization + +Test generation should create tests for operations with parameters that require normalization, including uppercase parameter names that get normalized to camelCase. + +## TypeSpec + +This is tsp definition. + +```tsp +model ListCredentialsRequest{ + serviceName: string; + PROPERTY_NAME: string; +} + +@doc("show example demo") +op post(@query QUERY_PARAM?: string, @header HEADER_PARAM?: string,@path PATH_PARAM?: string, @body ListCredentialsRequest?: ListCredentialsRequest): void; +``` + +## Example and generated tests + +Raw json files. + +```json for post +{ + "title": "post", + "operationId": "post", + "parameters": { + "QUERY_PARAM": "query", + "header_param": "header", + "PATH_PARAM": "path", + "ListCredentialsRequest": { + "serviceName": "SSH", + "PROPERTY_NAME": "name" + } + }, + "responses": { + "204": {} + } +} +``` + +```ts tests postTest +/** This file path is /test/generated/postTest.spec.ts */ + +import { TestingClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestingClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TESTING_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestingClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for post", async function () { + await client.post({ + listCredentialsRequest: { serviceName: "SSH", propertyName: "name" }, + queryParam: "query", + headerParam: "header", + pathParam: "path", + }); + /* Test passes if no exception is thrown */ + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterSpreadTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterSpreadTest.md new file mode 100644 index 0000000000..9783e390a5 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterSpreadTest.md @@ -0,0 +1,103 @@ +# Should generate tests for parameter spread + +Test generation should create tests for operations using parameter spread, ensuring proper handling of the parameter order and spread syntax. + +## TypeSpec + +This is tsp definition. + +```tsp +@doc("This is a simple model.") +model BodyParameter { + name: string; +} +@doc("This is a model with all http request decorator.") +model CompositeRequest { + @path + name: string; + + @header + requiredHeader: string; // required-header + + @header + optionalHeader?: string; + + @query + requiredQuery: string; + + @query + @clientName("renamedOptional", "javascript") + optionalQuery?: string; + + @body + body: BodyParameter; +} + +@doc("show example demo") +op read(...CompositeRequest): { @body body: {}}; +``` + +## Example and generated tests + +Raw json files. + +```json for read +{ + "title": "read", + "operationId": "read", + "parameters": { + "name": "required path param", + "optionalQuery": "renamed optional query", + "required-header": "required header", + "optional-header": "optional header", + "requiredQuery": "required query", + "body": { + "name": "body name" + } + }, + "responses": { + "200": { + "body": {} + } + } +} +``` + +```ts tests readTest +/** This file path is /test/generated/readTest.spec.ts */ + +import { TestingClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestingClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TESTING_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestingClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for read", async function () { + const result = await client.read( + "required path param", + "required header", + "required query", + { name: "body name" }, + { + optionalHeader: "optional header", + renamedOptional: "renamed optional query", + }, + ); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterTypesTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterTypesTest.md new file mode 100644 index 0000000000..41bc852b4f --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/parameters/parameterTypesTest.md @@ -0,0 +1,177 @@ +# Should generate tests for parameter types validation + +Test generation should create tests for operations with different parameter types, ensuring proper handling of various data types including unknown, literal types, dates, and complex objects. + +## TypeSpec + +This is tsp definition. + +```tsp +model Foo { + bar: string; + barDate?: utcDateTime; +} + +model Widget { + unknownValueWithObject: unknown; + unknownValueWithArray: unknown; + unknownValueWithStr: unknown; + unknownValueWithNum: unknown; + unknownValueWithNull: unknown; + unknownValueWithBoolean: unknown; + unknownValueWithObjectNested: unknown; + strValue: string; + numValue: int32; + enumValue: "red" | "blue"; + modelValue: Foo; + dateValue: utcDateTime; + arrValue: string[]; + unionValue: Foo | string; + nullValue: null; + @clientName("jsClientName", "javascript") + renamedProp: string; + ...Record; + stringLiteral: "foo"; + booleanLiteral: true; + numberLiteral: 12; + plainDateProp: plainDate; + plainTimeProp: plainTime; + utcDateTimeProp: utcDateTime; + offsetDateTimeProp: offsetDateTime; + durationProp: duration; + withEscapeChars: string; + unknownRecord: Record; + certificate?: bytes; + @encode("base64url") + profile?: bytes; +} + +@doc("show example demo") +op read(@bodyRoot body: Widget): { @body body: {}}; +``` + +## Example and generated tests + +Raw json files. + +```json for read +{ + "title": "read", + "operationId": "read", + "parameters": { + "body": { + "unknownValueWithObject": { "foo": "bar" }, + "unknownValueWithArray": ["x", "y"], + "unknownValueWithStr": "string", + "unknownValueWithNum": 7, + "unknownValueWithNull": null, + "unknownValueWithBoolean": false, + "unknownValueWithObjectNested": { + "foo": "bar", + "bar": [{ "foo": "fooStr" }, "barStr", 7] + }, + "strValue": "00000000-0000-0000-0000-00000000000", + "numValue": 0.12, + "enumValue": "red", + "modelValue": { + "bar": "bar value", + "barDate": "2022-08-09" + }, + "dateValue": "2022-08-09", + "arrValue": ["x", "y"], + "unionValue": "test", + "nullValue": null, + "additionalProp": "additional prop", + "renamedProp": "prop renamed", + "stringLiteral": "foo", + "booleanLiteral": true, + "numberLiteral": 12, + "plainDateProp": "2022-12-12", + "plainTimeProp": "13:06:12", + "utcDateTimeProp": "2022-08-26T18:38:00Z", + "offsetDateTimeProp": "2022-08-26T18:38:00Z", + "durationProp": "P123DT22H14M12.011S", + "withEscapeChars": "\"Tag 10\".Value", + "unknownRecord": { "a": "foo" }, + "certificate": "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "profile": "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V" + } + }, + "responses": { + "200": { + "body": {} + } + } +} +``` + +```ts tests readTest +/** This file path is /test/generated/readTest.spec.ts */ + +import { TestingClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestingClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TESTING_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestingClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for read", async function () { + const result = await client.read({ + unknownValueWithObject: { foo: "bar" }, + unknownValueWithArray: ["x", "y"], + unknownValueWithStr: "string", + unknownValueWithNum: 7, + unknownValueWithNull: null, + unknownValueWithBoolean: false, + unknownValueWithObjectNested: { + foo: "bar", + bar: [{ foo: "fooStr" }, "barStr", 7], + }, + strValue: "00000000-0000-0000-0000-00000000000", + numValue: 0.12, + enumValue: "red", + modelValue: { bar: "bar value", barDate: new Date("2022-08-09") }, + dateValue: new Date("2022-08-09"), + arrValue: ["x", "y"], + unionValue: "test", + nullValue: null, + jsClientName: "prop renamed", + stringLiteral: "foo", + booleanLiteral: true, + numberLiteral: 12, + plainDateProp: "2022-12-12", + plainTimeProp: "13:06:12", + utcDateTimeProp: new Date("2022-08-26T18:38:00Z"), + offsetDateTimeProp: "2022-08-26T18:38:00Z", + durationProp: "P123DT22H14M12.011S", + withEscapeChars: '"Tag 10".Value', + unknownRecord: { a: "foo" }, + certificate: Buffer.from( + "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "base64", + ), + profile: Buffer.from( + "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "base64url", + ), + additionalProperties: { + additionalProp: "additional prop", + }, + }); + assert.ok(result); + }); +}); +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/test/responses/responseTypesTest.md b/packages/typespec-ts/test/modularUnit/scenarios/test/responses/responseTypesTest.md new file mode 100644 index 0000000000..1c3e705de0 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/test/responses/responseTypesTest.md @@ -0,0 +1,183 @@ +# Should generate tests for response types validation + +Test generation should create tests for operations with different parameter types, ensuring proper handling of various data types including unknown, literal types, dates, and complex objects. + +## TypeSpec + +This is tsp definition. + +```tsp +model Foo { + bar: string; + barDate?: utcDateTime; +} + +model Widget { + unknownValueWithObject: unknown; + unknownValueWithArray: unknown; + unknownValueWithStr: unknown; + unknownValueWithNum: unknown; + unknownValueWithNull: unknown; + unknownValueWithBoolean: unknown; + unknownValueWithObjectNested: unknown; + strValue: string; + numValue: int32; + enumValue: "red" | "blue"; + modelValue: Foo; + dateValue: utcDateTime; + arrValue: string[]; + unionValue: Foo | string; + nullValue: null; + @clientName("jsClientName", "javascript") + renamedProp: string; + ...Record; + stringLiteral: "foo"; + booleanLiteral: true; + numberLiteral: 12; + plainDateProp: plainDate; + plainTimeProp: plainTime; + utcDateTimeProp: utcDateTime; + offsetDateTimeProp: offsetDateTime; + durationProp: duration; + withEscapeChars: string; + unknownRecord: Record; + certificate?: bytes; + @encode("base64url") + profile?: bytes; +} + +@doc("show example demo") +op read(): Widget; +``` + +## Example and generated tests + +Raw json files. + +```json for read +{ + "title": "read", + "operationId": "read", + "parameters": {}, + "responses": { + "200": { + "body": { + "unknownValueWithObject": { "foo": "bar" }, + "unknownValueWithArray": ["x", "y"], + "unknownValueWithStr": "string", + "unknownValueWithNum": 7, + "unknownValueWithNull": null, + "unknownValueWithBoolean": false, + "unknownValueWithObjectNested": { + "foo": "bar", + "bar": [{ "foo": "fooStr" }, "barStr", 7] + }, + "strValue": "00000000-0000-0000-0000-00000000000", + "numValue": 0.12, + "enumValue": "red", + "modelValue": { + "bar": "bar value", + "barDate": "2022-08-09" + }, + "dateValue": "2022-08-09", + "arrValue": ["x", "y"], + "unionValue": "test", + "nullValue": null, + "additionalProp": "additional prop", + "renamedProp": "prop renamed", + "stringLiteral": "foo", + "booleanLiteral": true, + "numberLiteral": 12, + "plainDateProp": "2022-12-12", + "plainTimeProp": "13:06:12", + "utcDateTimeProp": "2022-08-26T18:38:00Z", + "offsetDateTimeProp": "2022-08-26T18:38:00Z", + "durationProp": "P123DT22H14M12.011S", + "withEscapeChars": "\"Tag 10\".Value", + "unknownRecord": { "a": "foo" }, + "certificate": "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "profile": "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V" + } + } + } +} +``` + +```ts tests readTest +/** This file path is /test/generated/readTest.spec.ts */ + +import { TestingClient } from "../../src/index.js"; +import { createRecorder } from "./util/recordedClient.js"; +import { Recorder } from "@azure-tools/test-recorder"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("show example demo", () => { + let recorder: Recorder; + let client: TestingClient; + + beforeEach(async function (ctx) { + recorder = await createRecorder(ctx); + const endpoint = process.env.TESTING_ENDPOINT || ""; + const clientOptions = recorder.configureClientOptions({}); + client = new TestingClient(endpoint, clientOptions); + }); + + afterEach(async function () { + await recorder.stop(); + }); + + it("should show example demo for read", async function () { + const result = await client.read(); + assert.ok(result); + assert.equal(result.unknownValueWithObject, { foo: "bar" }); + assert.equal(result.unknownValueWithArray, ["x", "y"]); + assert.equal(result.unknownValueWithStr, "string"); + assert.equal(result.unknownValueWithNum, 7); + assert.equal(result.unknownValueWithNull, null); + assert.equal(result.unknownValueWithBoolean, false); + assert.equal(result.unknownValueWithObjectNested, { + foo: "bar", + bar: [{ foo: "fooStr" }, "barStr", 7], + }); + assert.strictEqual(result.strValue, "00000000-0000-0000-0000-00000000000"); + assert.strictEqual(result.numValue, 0.12); + assert.strictEqual(result.enumValue, "red"); + assert.strictEqual(result.modelValue.bar, "bar value"); + assert.strictEqual(result.modelValue.barDate, new Date("2022-08-09")); + assert.strictEqual(result.dateValue, new Date("2022-08-09")); + assert.ok(Array.isArray(result.arrValue)); + assert.strictEqual(result.arrValue.length, 2); + assert.strictEqual(result.arrValue[0], "x"); + assert.strictEqual(result.arrValue[1], "y"); + assert.equal(result.nullValue, null); + assert.strictEqual(result.renamedProp, "prop renamed"); + assert.strictEqual(result.stringLiteral, "foo"); + assert.strictEqual(result.booleanLiteral, true); + assert.strictEqual(result.numberLiteral, 12); + assert.strictEqual(result.plainDateProp, "2022-12-12"); + assert.strictEqual(result.plainTimeProp, "13:06:12"); + assert.strictEqual( + result.utcDateTimeProp, + new Date("2022-08-26T18:38:00Z"), + ); + assert.strictEqual(result.offsetDateTimeProp, "2022-08-26T18:38:00Z"); + assert.strictEqual(result.durationProp, "P123DT22H14M12.011S"); + assert.strictEqual(result.withEscapeChars, '"Tag 10".Value'); + assert.equal(result.unknownRecord.a, "foo"); + assert.equal( + result.certificate, + Buffer.from( + "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "base64", + ), + ); + assert.equal( + result.profile, + Buffer.from( + "TUlJRE5EQ0NBaHlnQXdJQkFnSVFDYUxFKzVTSlNVeWdncDM0V", + "base64url", + ), + ); + }); +}); +``` diff --git a/packages/typespec-ts/test/util/emitUtil.ts b/packages/typespec-ts/test/util/emitUtil.ts index d4f59a75d2..5df368f925 100644 --- a/packages/typespec-ts/test/util/emitUtil.ts +++ b/packages/typespec-ts/test/util/emitUtil.ts @@ -38,6 +38,7 @@ import { transformToParameterTypes } from "../../src/transform/transformParamete import { transformToResponseTypes } from "../../src/transform/transformResponses.js"; import { useBinder } from "../../src/framework/hooks/binder.js"; import { emitSamples } from "../../src/modular/emitSamples.js"; +import { emitTests } from "../../src/modular/emitTests.js"; import { renameClientName } from "../../src/index.js"; import { buildRootIndex } from "../../src/modular/buildRootIndex.js"; import { useContext } from "../../src/contextManager.js"; @@ -682,3 +683,30 @@ export async function emitSamplesFromTypeSpec( useBinder().resolveAllReferences("/"); return files; } + +export async function emitTestsFromTypeSpec( + tspContent: string, + examples: ExampleJson[], + configs: Record = {} +) { + const context = await compileTypeSpecFor(tspContent, examples); + configs["typespecTitleMap"] = configs["typespec-title-map"]; + configs["hierarchyClient"] = configs["hierarchy-client"]; + configs["enableOperationGroup"] = configs["enable-operation-group"]; + const dpgContext = await createDpgContextTestHelper(context.program, false, { + "examples-directory": `./examples`, + packageDetails: { + name: "@azure/internal-test" + }, + ...configs + }); + const modularEmitterOptions = transformModularEmitterOptions(dpgContext, "", { + casing: "camel" + }); + for (const subClient of dpgContext.sdkPackage.clients) { + await renameClientName(subClient, modularEmitterOptions); + } + const files = await emitTests(dpgContext); + useBinder().resolveAllReferences("/"); + return files; +} diff --git a/packages/typespec-ts/test/util/testUtil.ts b/packages/typespec-ts/test/util/testUtil.ts index 826f233e7e..bcd878bbf4 100644 --- a/packages/typespec-ts/test/util/testUtil.ts +++ b/packages/typespec-ts/test/util/testUtil.ts @@ -22,6 +22,7 @@ import { loadStaticHelpers } from "../../src/framework/load-static-helpers.js"; import path from "path"; import { getDirname } from "../../src/utils/dirname.js"; import { + CreateRecorderHelpers, MultipartHelpers, PagingHelpers, PollingHelpers, @@ -31,7 +32,8 @@ import { import { AzureCoreDependencies, AzureIdentityDependencies, - AzurePollingDependencies + AzurePollingDependencies, + AzureTestDependencies } from "../../src/modular/external-dependencies.js"; export interface ExampleJson { @@ -259,7 +261,8 @@ export async function provideBinderWithAzureDependencies(project: Project) { const extraDependencies = { ...AzurePollingDependencies, ...AzureCoreDependencies, - ...AzureIdentityDependencies + ...AzureIdentityDependencies, + ...AzureTestDependencies }; const staticHelpers = { @@ -267,7 +270,8 @@ export async function provideBinderWithAzureDependencies(project: Project) { ...PagingHelpers, ...PollingHelpers, ...UrlTemplateHelpers, - ...MultipartHelpers + ...MultipartHelpers, + ...CreateRecorderHelpers }; const staticHelperMap = await loadStaticHelpers(project, staticHelpers, {