diff --git a/core/generator/gapic-generator-typescript/typescript/src/generator.ts b/core/generator/gapic-generator-typescript/typescript/src/generator.ts index 84a8e818781f..b66849bed378 100644 --- a/core/generator/gapic-generator-typescript/typescript/src/generator.ts +++ b/core/generator/gapic-generator-typescript/typescript/src/generator.ts @@ -86,6 +86,7 @@ export class Generator { restNumericEnums?: boolean; mixinsOverride?: string[]; format?: string | string[]; + enableTelemetryTracing?: boolean; private root: protobuf.Root; @@ -227,6 +228,12 @@ export class Generator { } } + private readEnableTelemetryTracing() { + if (this.paramMap['enable-telemetry-tracing'] === 'true') { + this.enableTelemetryTracing = true; + } + } + private readLegacyProtoLoad() { if (this.paramMap['legacy-proto-load'] === 'true') { this.legacyProtoLoad = true; @@ -274,6 +281,7 @@ export class Generator { this.readLegacyProtoLoad(); this.readRestNumericEnums(); this.readFormat(); + this.readEnableTelemetryTracing(); } } @@ -334,6 +342,7 @@ export class Generator { legacyProtoLoad: this.legacyProtoLoad, restNumericEnums: this.restNumericEnums, mixinsOverridden: this.mixinsOverride !== undefined, + enableTelemetryTracing: this.enableTelemetryTracing, }); return api; } diff --git a/core/generator/gapic-generator-typescript/typescript/src/schema/api.ts b/core/generator/gapic-generator-typescript/typescript/src/schema/api.ts index a49572f920f2..2e8c02aadc69 100644 --- a/core/generator/gapic-generator-typescript/typescript/src/schema/api.ts +++ b/core/generator/gapic-generator-typescript/typescript/src/schema/api.ts @@ -45,6 +45,7 @@ export class API { restNumericEnums: boolean; documentationUri: any; newIssueUri: string; + enableTelemetryTracing?: boolean; title?: string; static isIgnoredService( @@ -112,6 +113,7 @@ export class API { this.documentationUri = options.serviceYaml?.publishing?.documentation_uri ?? ''; this.newIssueUri = options.serviceYaml?.publishing?.new_issue_uri ?? ''; + this.enableTelemetryTracing = options.enableTelemetryTracing ?? false; this.title = options.serviceYaml?.title; const [allResourceDatabase, resourceDatabase] = diff --git a/core/generator/gapic-generator-typescript/typescript/src/schema/naming.ts b/core/generator/gapic-generator-typescript/typescript/src/schema/naming.ts index 0d95f68a732c..49c90c9f1b63 100644 --- a/core/generator/gapic-generator-typescript/typescript/src/schema/naming.ts +++ b/core/generator/gapic-generator-typescript/typescript/src/schema/naming.ts @@ -30,6 +30,7 @@ export interface Options { legacyProtoLoad?: boolean; restNumericEnums?: boolean; mixinsOverridden?: boolean; + enableTelemetryTracing?: boolean; } export class Naming { diff --git a/core/generator/gapic-generator-typescript/typescript/test/unit/api.ts b/core/generator/gapic-generator-typescript/typescript/test/unit/api.ts index 4be052b5bccc..0fddfb7f3e71 100644 --- a/core/generator/gapic-generator-typescript/typescript/test/unit/api.ts +++ b/core/generator/gapic-generator-typescript/typescript/test/unit/api.ts @@ -325,4 +325,25 @@ describe('src/schema/api.ts', () => { assert('.google.cloud.example.v1.MessageB' in secondCallMessages); }); }); + + it('should set enableTelemetryTracing option', () => { + const fd = {} as protos.google.protobuf.FileDescriptorProto; + fd.name = 'google/cloud/test/v1/test.proto'; + fd.package = 'google.cloud.test.v1'; + fd.service = [{} as protos.google.protobuf.ServiceDescriptorProto]; + fd.service[0].name = 'ZService'; + fd.service[0].options = { + '.google.api.defaultHost': 'hostname.example.com:443', + }; + const apiWithTracing = new API([fd], 'google.cloud.test.v1', { + grpcServiceConfig: {} as protos.grpc.service_config.ServiceConfig, + enableTelemetryTracing: true, + }); + assert.strictEqual(apiWithTracing.enableTelemetryTracing, true); + + const apiWithoutTracing = new API([fd], 'google.cloud.test.v1', { + grpcServiceConfig: {} as protos.grpc.service_config.ServiceConfig, + }); + assert.strictEqual(apiWithoutTracing.enableTelemetryTracing, false); + }); }); diff --git a/core/generator/gapic-generator-typescript/typescript/test/unit/generator.ts b/core/generator/gapic-generator-typescript/typescript/test/unit/generator.ts new file mode 100644 index 000000000000..5ec3b3e1b8a1 --- /dev/null +++ b/core/generator/gapic-generator-typescript/typescript/test/unit/generator.ts @@ -0,0 +1,399 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import assert from 'assert'; +import {describe, it, beforeEach, afterEach} from 'mocha'; +import {Generator} from '../../src/generator.js'; +import type {API} from '../../src/schema/api.js'; +import type * as protos from '../../../protos/index.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +interface TestGenerator { + getParamMap(parameter: string): void; + readGrpcServiceConfig(): Promise; + readBundleConfig(): void; + readServiceYaml(): void; + readPublishPackageName(): void; + readMainServiceName(): void; + readTemplates(): void; + readRest(): void; + readDiregapic(): void; + readHandwrittenLayer(): void; + readFormat(): void; + readEnableTelemetryTracing(): void; + readLegacyProtoLoad(): void; + readRestNumericEnums(): void; + readMixins(): void; + addProtosToResponse(): void; + buildAPIObject(): API; +} + +describe('src/generator.ts', () => { + let generator: Generator; + + beforeEach(() => { + generator = new Generator(); + }); + + function getTestGenerator(gen: Generator): TestGenerator { + return gen as unknown as TestGenerator; + } + + it('should initialize with default values', () => { + assert.deepStrictEqual(generator.request, {}); + assert.deepStrictEqual(generator.response, {}); + assert.deepStrictEqual(generator.grpcServiceConfig, {}); + assert.deepStrictEqual(generator.paramMap, {}); + assert.deepStrictEqual(generator.templates, [ + 'typescript_gapic', + 'typescript_packing_test', + 'typescript_samples', + ]); + }); + + describe('parameter parsing', () => { + it('should parse parameter options string', () => { + getTestGenerator(generator).getParamMap( + 'package-name=@google-cloud/test,transport=rest,diregapic=true', + ); + assert.strictEqual( + generator.paramMap['package-name'], + '@google-cloud/test', + ); + assert.strictEqual(generator.paramMap['transport'], 'rest'); + assert.strictEqual(generator.paramMap['diregapic'], 'true'); + }); + + it('should handle parameter without explicit value', () => { + getTestGenerator(generator).getParamMap('handwritten-layer'); + assert.strictEqual(generator.paramMap['handwritten-layer'], 'true'); + }); + + it('should handle quoted parameters', () => { + getTestGenerator(generator).getParamMap('"main-service=TestService"'); + assert.strictEqual(generator.paramMap['main-service'], 'TestService'); + }); + + it('should convert parameter keys to kebab-case', () => { + getTestGenerator(generator).getParamMap( + 'main_service=TestService,legacy_proto_load=true', + ); + assert.strictEqual(generator.paramMap['main-service'], 'TestService'); + assert.strictEqual(generator.paramMap['legacy-proto-load'], 'true'); + }); + }); + + describe('option readers', () => { + it('should read publish package name', () => { + generator.paramMap['package-name'] = '@google-cloud/speech'; + getTestGenerator(generator).readPublishPackageName(); + assert.strictEqual(generator.publishName, '@google-cloud/speech'); + }); + + it('should read main service name', () => { + generator.paramMap['main-service'] = 'Speech'; + getTestGenerator(generator).readMainServiceName(); + assert.strictEqual(generator.mainServiceName, 'Speech'); + }); + + it('should read custom templates and metadata flag', () => { + generator.paramMap['template'] = 'custom_template_1;custom_template_2'; + generator.paramMap['metadata'] = 'true'; + getTestGenerator(generator).readTemplates(); + assert.deepStrictEqual(generator.templates, [ + 'custom_template_1', + 'custom_template_2', + 'typescript_gapic_metadata', + ]); + }); + + it('should read transport rest option', () => { + generator.paramMap['transport'] = 'rest'; + getTestGenerator(generator).readRest(); + assert.strictEqual(generator.rest, true); + }); + + it('should read diregapic option', () => { + generator.paramMap['diregapic'] = 'true'; + getTestGenerator(generator).readDiregapic(); + assert.strictEqual(generator.diregapic, true); + assert.strictEqual(generator.rest, true); + }); + + it('should read handwritten-layer option', () => { + generator.paramMap['handwritten-layer'] = 'true'; + getTestGenerator(generator).readHandwrittenLayer(); + assert.strictEqual(generator.handwrittenLayer, true); + }); + + it('should read format option', () => { + generator.paramMap['format'] = 'esm;cjs'; + getTestGenerator(generator).readFormat(); + assert.deepStrictEqual(generator.format, ['esm', 'cjs']); + }); + + it('should read telemetry tracing option', () => { + generator.paramMap['enable-telemetry-tracing'] = 'true'; + getTestGenerator(generator).readEnableTelemetryTracing(); + assert.strictEqual(generator.enableTelemetryTracing, true); + }); + + it('should read legacy proto load option', () => { + generator.paramMap['legacy-proto-load'] = 'true'; + getTestGenerator(generator).readLegacyProtoLoad(); + assert.strictEqual(generator.legacyProtoLoad, true); + }); + + it('should read rest numeric enums option', () => { + generator.paramMap['rest-numeric-enums'] = 'true'; + getTestGenerator(generator).readRestNumericEnums(); + assert.strictEqual(generator.restNumericEnums, true); + }); + + it('should read mixins option', () => { + generator.paramMap['mixins'] = + 'google.iam.v1.IAMPolicy;google.longrunning.Operations'; + getTestGenerator(generator).readMixins(); + assert.deepStrictEqual(generator.mixinsOverride, [ + 'google.iam.v1.IAMPolicy', + 'google.longrunning.Operations', + ]); + }); + }); + + describe('file configurations', () => { + const tmpDir = path.join(os.tmpdir(), 'generator-test-' + Date.now()); + + beforeEach(() => { + fs.mkdirSync(tmpDir, {recursive: true}); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); + }); + + it('should throw when gRPC service config file does not exist', async () => { + generator.paramMap['grpc-service-config'] = path.join( + tmpDir, + 'nonexistent.json', + ); + await assert.rejects(async () => { + await getTestGenerator(generator).readGrpcServiceConfig(); + }, /ERROR: File .* cannot be opened\./); + }); + + it('should read valid gRPC service config file', async () => { + const grpcConfigPath = path.join(tmpDir, 'grpc_service_config.json'); + fs.writeFileSync( + grpcConfigPath, + JSON.stringify({ + methodConfig: [ + { + name: [{service: 'google.test.v1.TestService'}], + timeout: '60s', + }, + ], + }), + ); + generator.paramMap['grpc-service-config'] = grpcConfigPath; + await getTestGenerator(generator).readGrpcServiceConfig(); + assert.ok(generator.grpcServiceConfig.methodConfig); + assert.strictEqual(generator.grpcServiceConfig.methodConfig.length, 1); + }); + + it('should throw when bundle config file does not exist', () => { + generator.paramMap['bundle-config'] = path.join( + tmpDir, + 'nonexistent.yaml', + ); + assert.throws(() => { + getTestGenerator(generator).readBundleConfig(); + }, /ERROR: File .* cannot be opened\./); + }); + + it('should throw when service yaml file does not exist', () => { + generator.paramMap['service-yaml'] = path.join( + tmpDir, + 'nonexistent.yaml', + ); + assert.throws(() => { + getTestGenerator(generator).readServiceYaml(); + }, /ERROR: File .* cannot be opened\./); + }); + + it('should read valid service yaml file', () => { + const serviceYamlPath = path.join(tmpDir, 'service.yaml'); + fs.writeFileSync( + serviceYamlPath, + `title: Test Service +apis: + - name: google.iam.v1.IAMPolicy + - name: google.longrunning.Operations +`, + ); + generator.paramMap['service-yaml'] = serviceYamlPath; + getTestGenerator(generator).readServiceYaml(); + assert.strictEqual(generator.serviceYaml?.title, 'Test Service'); + assert.deepStrictEqual(generator.serviceYaml?.apis, [ + 'google.iam.v1.IAMPolicy', + 'google.longrunning.Operations', + ]); + }); + + it('should override mixins when mixinsOverride is set', () => { + generator.mixinsOverride = ['google.iam.v1.IAMPolicy']; + getTestGenerator(generator).readServiceYaml(); + assert.deepStrictEqual(generator.serviceYaml?.apis, [ + 'google.iam.v1.IAMPolicy', + ]); + + generator.mixinsOverride = ['none']; + getTestGenerator(generator).readServiceYaml(); + assert.deepStrictEqual(generator.serviceYaml?.apis, []); + }); + + it('should read valid bundle config file', () => { + const bundleConfigPath = path.join(tmpDir, 'bundle.yaml'); + fs.writeFileSync( + bundleConfigPath, + `interfaces: + - name: google.test.v1.TestService + methods: + - name: ListItems + batching: + thresholds: + element_count_threshold: 10 + request_byte_threshold: 100 + delay_threshold_millis: 1000 + batch_descriptor: + batched_field: items + discriminator_fields: + - parent + subresponse_field: items +`, + ); + generator.paramMap['bundle-config'] = bundleConfigPath; + getTestGenerator(generator).readBundleConfig(); + assert.strictEqual(generator.bundleConfigs.length, 1); + assert.strictEqual(generator.bundleConfigs[0].serviceName, 'TestService'); + assert.strictEqual(generator.bundleConfigs[0].methodName, 'ListItems'); + }); + }); + + describe('addProtosToResponse', () => { + it('should populate response with proto.list file', () => { + generator.request = { + protoFile: [ + {name: 'google/cloud/test/v1/test.proto'}, + {name: 'google/cloud/test/v1/other.proto'}, + ], + } as unknown as protos.google.protobuf.compiler.CodeGeneratorRequest; + generator.response = { + file: [], + } as unknown as protos.google.protobuf.compiler.CodeGeneratorResponse; + + getTestGenerator(generator).addProtosToResponse(); + + assert.strictEqual(generator.response.file?.length, 1); + assert.strictEqual(generator.response.file?.[0].name, 'proto.list'); + assert.strictEqual( + generator.response.file?.[0].content, + 'google/cloud/test/v1/test.proto\ngoogle/cloud/test/v1/other.proto\n', + ); + }); + }); + + describe('buildAPIObject', () => { + it('should throw error if protos define no services', () => { + generator.request = { + protoFile: [ + { + name: 'google/cloud/test/v1/test.proto', + package: 'google.cloud.test.v1', + }, + ], + fileToGenerate: ['google/cloud/test/v1/test.proto'], + } as protos.google.protobuf.compiler.CodeGeneratorRequest; + + assert.throws(() => { + getTestGenerator(generator).buildAPIObject(); + }, /ERROR: Protos do not define any service/); + }); + + it('should create API object when services are defined', () => { + generator.request = { + protoFile: [ + { + name: 'google/cloud/test/v1/test.proto', + package: 'google.cloud.test.v1', + service: [ + { + name: 'TestService', + options: { + '.google.api.defaultHost': 'test.googleapis.com', + }, + }, + ], + }, + ], + fileToGenerate: ['google/cloud/test/v1/test.proto'], + } as protos.google.protobuf.compiler.CodeGeneratorRequest; + + const api = getTestGenerator(generator).buildAPIObject(); + assert.ok(api); + assert.strictEqual(api.packageName, 'google.cloud.test.v1'); + }); + }); + + describe('processTemplates', () => { + it('should throw error if template location does not exist', async () => { + generator.templates = ['nonexistent_template_dir']; + const api = {} as API; + + await assert.rejects(async () => { + await generator.processTemplates(api); + }, /Template directory .* does not exist\./); + }); + }); + + describe('generate', () => { + it('should capture error message in response on failure', async () => { + const originalStdoutWrite = process.stdout.write; + let writtenBuffer: Buffer | null = null; + + process.stdout.write = (chunk: Uint8Array | string) => { + writtenBuffer = Buffer.from(chunk); + return true; + }; + + try { + generator.request = { + protoFile: [], + } as unknown as protos.google.protobuf.compiler.CodeGeneratorRequest; + + await generator.generate(); + + assert.strictEqual( + generator.response.error, + 'ERROR: Protos do not define any service, client library cannot be generated.', + ); + assert.ok(writtenBuffer); + } finally { + process.stdout.write = originalStdoutWrite; + } + }); + }); +}); diff --git a/core/generator/gapic-generator-typescript/typescript/test/unit/naming.ts b/core/generator/gapic-generator-typescript/typescript/test/unit/naming.ts index 2aec137f42ec..8ab6e94db8ff 100644 --- a/core/generator/gapic-generator-typescript/typescript/test/unit/naming.ts +++ b/core/generator/gapic-generator-typescript/typescript/test/unit/naming.ts @@ -218,4 +218,17 @@ describe('src/schema/naming.ts', () => { assert(naming); }); }); + + it('supports enableTelemetryTracing option', () => { + const descriptor = {} as protos.google.protobuf.FileDescriptorProto; + descriptor.package = 'google.namespace.service.v1beta1'; + descriptor.service = [{} as protos.google.protobuf.ServiceDescriptorProto]; + const options: Options = { + grpcServiceConfig: {} as protos.grpc.service_config.ServiceConfig, + enableTelemetryTracing: true, + }; + const naming = new Naming([descriptor], options); + assert(naming); + assert.strictEqual(naming.name, 'Service'); + }); });