diff --git a/packages/toolbox-adk/integration.cloudbuild.yaml b/packages/toolbox-adk/integration.cloudbuild.yaml index dcd94df5..52bacd26 100644 --- a/packages/toolbox-adk/integration.cloudbuild.yaml +++ b/packages/toolbox-adk/integration.cloudbuild.yaml @@ -28,18 +28,18 @@ steps: - '-c' - corepack enable && pnpm --filter @toolbox-sdk/adk run test:unit - # - id: Run integration tests - # name: 'node:${_VERSION}' - # entrypoint: /bin/bash - # waitFor: ['Install dependencies'] - # env: - # - TOOLBOX_URL=$_TOOLBOX_URL - # - TOOLBOX_VERSION=$_TOOLBOX_VERSION - # - GOOGLE_CLOUD_PROJECT=$PROJECT_ID - # - TOOLBOX_MANIFEST_VERSION=$_TOOLBOX_MANIFEST_VERSION - # args: - # - '-c' - # - corepack enable && pnpm --filter @toolbox-sdk/adk run test:e2e + - id: Run integration tests + name: 'node:${_VERSION}' + entrypoint: /bin/bash + waitFor: ['Install dependencies'] + env: + - TOOLBOX_URL=$_TOOLBOX_URL + - TOOLBOX_VERSION=$_TOOLBOX_VERSION + - GOOGLE_CLOUD_PROJECT=$PROJECT_ID + - TOOLBOX_MANIFEST_VERSION=$_TOOLBOX_MANIFEST_VERSION + args: + - '-c' + - corepack enable && pnpm --filter @toolbox-sdk/adk run test:e2e options: logging: CLOUD_LOGGING_ONLY diff --git a/packages/toolbox-adk/package.json b/packages/toolbox-adk/package.json index 1a7532bf..f28c712b 100644 --- a/packages/toolbox-adk/package.json +++ b/packages/toolbox-adk/package.json @@ -1,6 +1,6 @@ { "name": "@toolbox-sdk/adk", - "version": "0.0.1", + "version": "0.1.2", "type": "module", "description": "JavaScript ADK SDK for interacting with the Toolbox service", "license": "Apache-2.0", @@ -41,7 +41,7 @@ "compile:cjs": "tsc -p tsconfig.cjs.json", "prepare": "npm run compile", "test:unit": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.json", - "test:e2e": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.e2e.config.json --runInBand", + "test:e2e": "npx tsc -p tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.e2e.config.json --runInBand", "coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.json --coverage" }, "dependencies": { @@ -51,9 +51,6 @@ "@toolbox-sdk/core": "workspace:*", "axios": "^1.12.2", "openapi-types": "^12.1.3", - "openapi-types": "^12.1.3", - "@google/genai": "^1.14.0", - "@toolbox-sdk/core": "workspace:*", "zod": "^3.24.4" } } diff --git a/packages/toolbox-adk/test/e2e/jest.globalSetup.ts b/packages/toolbox-adk/test/e2e/jest.globalSetup.ts new file mode 100644 index 00000000..b91cb3b5 --- /dev/null +++ b/packages/toolbox-adk/test/e2e/jest.globalSetup.ts @@ -0,0 +1,168 @@ +// Copyright 2025 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 * as path from 'path'; +import fs from 'fs-extra'; +import {spawn} from 'child_process'; +import {fileURLToPath} from 'url'; +import { + getEnvVar, + accessSecretVersion, + createTmpFile, + downloadBlob, + getToolboxBinaryGcsPath, + delay, +} from './utils.js'; +import {CustomGlobal} from './types.js'; + +const TOOLBOX_BINARY_NAME = 'toolbox'; +const SERVER_READY_TIMEOUT_MS = 30000; // 30 seconds +const SERVER_READY_POLL_INTERVAL_MS = 2000; // 2 seconds + +export default async function globalSetup(): Promise { + console.log('\nJest Global Setup: Starting...'); + + try { + const projectId = getEnvVar('GOOGLE_CLOUD_PROJECT'); + const toolboxVersion = getEnvVar('TOOLBOX_VERSION'); + (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__ = projectId; + + // Fetch tools manifest and create temp file + const toolsManifest = await accessSecretVersion( + projectId, + 'sdk_testing_tools', + getEnvVar('TOOLBOX_MANIFEST_VERSION'), + ); + const toolsFilePath = await createTmpFile(toolsManifest); + (globalThis as CustomGlobal).__TOOLS_FILE_PATH__ = toolsFilePath; + console.log(`Tools manifest stored at: ${toolsFilePath}`); + + // Download toolbox binary + const toolboxGcsPath = getToolboxBinaryGcsPath(toolboxVersion); + + // Add these two lines to define __dirname + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + const localToolboxPath = path.resolve(__dirname, TOOLBOX_BINARY_NAME); + + console.log( + `Downloading toolbox binary from gs://genai-toolbox/${toolboxGcsPath} to ${localToolboxPath}...`, + ); + await downloadBlob('genai-toolbox', toolboxGcsPath, localToolboxPath); + console.log('Toolbox binary downloaded successfully.'); + + // Make toolbox executable + await fs.chmod(localToolboxPath, 0o700); + + // Start toolbox server + console.log('Starting toolbox server process...'); + const serverProcess = spawn( + localToolboxPath, + ['--tools-file', toolsFilePath], + { + stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + }, + ); + + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ = serverProcess; + + serverProcess.stdout?.on('data', (data: Buffer) => { + console.log(`[ToolboxServer STDOUT]: ${data.toString().trim()}`); + }); + + serverProcess.stderr?.on('data', (data: Buffer) => { + console.error(`[ToolboxServer STDERR]: ${data.toString().trim()}`); + }); + + serverProcess.on('error', err => { + console.error('Toolbox server process error:', err); + throw new Error('Failed to start toolbox server process.'); + }); + + serverProcess.on('exit', (code, signal) => { + console.log( + `Toolbox server process exited with code ${code}, signal ${signal}.`, + ); + if ( + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ && + !(globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ + ) { + console.error('Toolbox server exited prematurely during setup.'); + } + }); + + // Wait for server to start (basic poll check) + let started = false; + const startTime = Date.now(); + while (Date.now() - startTime < SERVER_READY_TIMEOUT_MS) { + if ( + serverProcess.pid && + !serverProcess.killed && + serverProcess.exitCode === null + ) { + console.log( + 'Toolbox server process appears to be running. Polling for stability...', + ); + await delay(SERVER_READY_POLL_INTERVAL_MS * 2); + if (serverProcess.exitCode === null) { + console.log( + 'Toolbox server started successfully (process is active).', + ); + started = true; + break; + } else { + console.log('Toolbox server process exited after initial start.'); + break; + } + } + await delay(SERVER_READY_POLL_INTERVAL_MS); + console.log('Checking if toolbox server is started...'); + } + + if (!started) { + if (serverProcess && !serverProcess.killed) { + serverProcess.kill('SIGTERM'); + } + throw new Error( + `Toolbox server failed to start within ${SERVER_READY_TIMEOUT_MS / 1000} seconds.`, + ); + } + + console.log('Jest Global Setup: Completed successfully.'); + } catch (error) { + console.error('Jest Global Setup Failed:', error); + // Attempt to kill server if it started partially + const serverProcess = (globalThis as CustomGlobal) + .__TOOLBOX_SERVER_PROCESS__; + if (serverProcess && !serverProcess.killed) { + console.log('Attempting to terminate partially started server...'); + serverProcess.kill('SIGKILL'); + } + // Clean up temp file if created + const toolsFilePath = (globalThis as CustomGlobal).__TOOLS_FILE_PATH__; + if (toolsFilePath) { + try { + await fs.remove(toolsFilePath); + } catch (e) { + console.error( + 'Error removing temp tools file during setup failure:', + e, + ); + } + } + (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__ = undefined; + throw error; + } +} diff --git a/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts b/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts new file mode 100644 index 00000000..0b61fccd --- /dev/null +++ b/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts @@ -0,0 +1,86 @@ +// Copyright 2025 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 * as fs from 'fs-extra'; +import {CustomGlobal} from './types.js'; + +const SERVER_TERMINATE_TIMEOUT_MS = 10000; // 10 seconds + +export default async function globalTeardown(): Promise { + console.log('\nJest Global Teardown: Starting...'); + (globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ = true; + + const customGlobal = globalThis as CustomGlobal; + const serverProcess = customGlobal.__TOOLBOX_SERVER_PROCESS__; + const toolsFilePath = customGlobal.__TOOLS_FILE_PATH__; + + if (serverProcess && !serverProcess.killed) { + console.log('Stopping toolbox server process...'); + serverProcess.kill('SIGTERM'); // Graceful termination + + // Wait for the process to exit + const stopPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!serverProcess.killed) { + console.warn( + 'Toolbox server did not terminate gracefully, sending SIGKILL.', + ); + serverProcess.kill('SIGKILL'); + } + // Resolve even if SIGKILL is needed, as we want teardown to finish + resolve(); + }, SERVER_TERMINATE_TIMEOUT_MS); + + serverProcess.on('exit', (code, signal) => { + clearTimeout(timeout); + console.log( + `Toolbox server process exited with code ${code}, signal ${signal} during teardown.`, + ); + resolve(); + }); + serverProcess.on('error', err => { + // Should not happen if already running + clearTimeout(timeout); + console.error('Error during server process termination:', err); + reject(err); + }); + }); + + try { + await stopPromise; + } catch (error) { + console.error('Error while waiting for server to stop:', error); + if (!serverProcess.killed) serverProcess.kill('SIGKILL'); // Ensure it's killed + } + } else { + console.log('Toolbox server process was not running or already handled.'); + } + + if (toolsFilePath) { + try { + console.log(`Removing temporary tools file: ${toolsFilePath}`); + await fs.remove(toolsFilePath); + } catch (error) { + console.error( + `Failed to remove temporary tools file ${toolsFilePath}:`, + error, + ); + } + } + customGlobal.__TOOLBOX_SERVER_PROCESS__ = undefined; + customGlobal.__TOOLS_FILE_PATH__ = undefined; + customGlobal.__GOOGLE_CLOUD_PROJECT__ = undefined; + + console.log('Jest Global Teardown: Completed.'); +} diff --git a/packages/toolbox-adk/test/e2e/test.e2e.ts b/packages/toolbox-adk/test/e2e/test.e2e.ts new file mode 100644 index 00000000..244b27de --- /dev/null +++ b/packages/toolbox-adk/test/e2e/test.e2e.ts @@ -0,0 +1,365 @@ +// Copyright 2025 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 {ToolboxClient} from '../../src/toolbox_adk/client.js'; +import {ToolboxTool} from '../../src/toolbox_adk/tool.js'; + +import {AxiosError} from 'axios'; +import {CustomGlobal} from './types.js'; +import {ToolContext} from '@google/adk'; + +import {authTokenGetter} from './utils.js'; + +describe('ToolboxClient E2E Tests', () => { + let commonToolboxClient: ToolboxClient; + let getNRowsTool: ToolboxTool; + const testBaseUrl = 'http://localhost:5000'; + const projectId = (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__; + + const mockToolContext = {} as ToolContext; + + beforeAll(async () => { + commonToolboxClient = new ToolboxClient(testBaseUrl); + }); + + beforeEach(async () => { + getNRowsTool = await commonToolboxClient.loadTool('get-n-rows'); + expect(getNRowsTool.name).toBe('get-n-rows'); + }); + + describe('invokeTool', () => { + it('should invoke the getNRowsTool', async () => { + const response = await getNRowsTool.runAsync({ + args: {num_rows: '2'}, + toolContext: mockToolContext, + }); + expect(typeof response).toBe('string'); + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + }); + + it('should invoke the getNRowsTool with missing params', async () => { + await expect( + getNRowsTool.runAsync({args: {}, toolContext: mockToolContext}), + ).rejects.toThrow( + /Argument validation failed for tool "get-n-rows":\s*- num_rows: Required/, + ); + }); + + it('should invoke the getNRowsTool with wrong param type', async () => { + await expect( + getNRowsTool.runAsync({ + args: {num_rows: 2}, + toolContext: mockToolContext, + }), + ).rejects.toThrow( + /Argument validation failed for tool "get-n-rows":\s*- num_rows: Expected string, received number/, + ); + }); + }); + + describe('loadToolset', () => { + const specificToolsetTestCases = [ + { + name: 'my-toolset', + expectedLength: 1, + expectedTools: ['get-row-by-id'], + }, + { + name: 'my-toolset-2', + expectedLength: 2, + expectedTools: ['get-n-rows', 'get-row-by-id'], + }, + ]; + + specificToolsetTestCases.forEach(testCase => { + it(`should successfully load the specific toolset "${testCase.name}"`, async () => { + const loadedTools = await commonToolboxClient.loadToolset( + testCase.name, + ); + + expect(Array.isArray(loadedTools)).toBe(true); + expect(loadedTools.length).toBe(testCase.expectedLength); + + const loadedToolNames = new Set( + loadedTools.map((tool: ToolboxTool) => tool.name), + ); + expect(loadedToolNames).toEqual(new Set(testCase.expectedTools)); + + for (const tool of loadedTools) { + expect(typeof tool).toBe('object'); + expect(tool).toBeInstanceOf(ToolboxTool); + + expect(tool).toBeInstanceOf(ToolboxTool); + const declaration = tool._getDeclaration(); + expect(declaration).toBeDefined(); + + expect(testCase.expectedTools).toContain(declaration!.name); + expect(declaration!.parameters).toBeDefined(); + } + }); + }); + + it('should successfully load the default toolset (all tools)', async () => { + const loadedTools = await commonToolboxClient.loadToolset(); // Load the default toolset (no name provided) + expect(Array.isArray(loadedTools)).toBe(true); + expect(loadedTools.length).toBeGreaterThan(0); + const getNRowsToolFromSet = loadedTools.find( + (tool: ToolboxTool) => tool.name === 'get-n-rows', + ) as ToolboxTool; + + expect(getNRowsToolFromSet).toBeDefined(); + const declaration = getNRowsToolFromSet._getDeclaration(); + expect(declaration).toBeDefined(); + expect(declaration?.name).toBe('get-n-rows'); + expect(declaration?.parameters).toBeDefined(); + + const loadedToolNames = new Set( + loadedTools.map((tool: ToolboxTool) => tool.name), + ); + const expectedDefaultTools = new Set([ + 'get-row-by-content-auth', + 'get-row-by-email-auth', + 'get-row-by-id-auth', + 'get-row-by-id', + 'get-n-rows', + 'search-rows', + 'process-data', + ]); + expect(loadedToolNames).toEqual(expectedDefaultTools); + }); + + it('should throw an error when trying to load a non-existent toolset', async () => { + await expect( + commonToolboxClient.loadToolset('non-existent-toolset'), + ).rejects.toThrow('Request failed with status code 404'); + }); + }); + describe('bindParams', () => { + it('should successfully bind a parameter with bindParam and invoke', async () => { + const newTool = getNRowsTool.bindParam('num_rows', '3'); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockToolContext, + }); // Invoke with no args + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); + + it('should successfully bind parameters with bindParams and invoke', async () => { + const newTool = getNRowsTool.bindParams({num_rows: '3'}); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockToolContext, + }); // Invoke with no args + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); + + it('should successfully bind a synchronous function value', async () => { + const newTool = getNRowsTool.bindParams({num_rows: () => '1'}); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockToolContext, + }); + expect(response).toContain('row1'); + expect(response).not.toContain('row2'); + }); + + it('should successfully bind an asynchronous function value', async () => { + const asyncNumProvider = async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return '1'; + }; + + const newTool = getNRowsTool.bindParams({num_rows: asyncNumProvider}); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockToolContext, + }); + expect(response).toContain('row1'); + expect(response).not.toContain('row2'); + }); + + it('should successfully bind parameters at load time', async () => { + const tool = await commonToolboxClient.loadTool('get-n-rows', null, { + num_rows: '3', + }); + const response = await tool.runAsync({ + args: {}, + toolContext: mockToolContext, + }); + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); + + it('should throw an error when re-binding an existing parameter', () => { + const newTool = getNRowsTool.bindParam('num_rows', '1'); + expect(() => { + newTool.bindParam('num_rows', '2'); + }).toThrow( + "Cannot re-bind parameter: parameter 'num_rows' is already bound in tool 'get-n-rows'.", + ); + }); + + it('should throw an error when binding a non-existent parameter', () => { + expect(() => { + getNRowsTool.bindParam('non_existent_param', '2'); + }).toThrow( + "Unable to bind parameter: no parameter named 'non_existent_param' in tool 'get-n-rows'.", + ); + }); + }); + + describe('Auth E2E Tests', () => { + let authToken1: string; + let authToken2: string; + let authToken1Getter: () => string; + let authToken2Getter: () => string; + + beforeAll(async () => { + if (!projectId) { + throw new Error( + 'GOOGLE_CLOUD_PROJECT is not defined. Cannot run Auth E2E tests.', + ); + } + authToken1 = await authTokenGetter(projectId, 'sdk_testing_client1'); + authToken2 = await authTokenGetter(projectId, 'sdk_testing_client2'); + + authToken1Getter = () => authToken1; + authToken2Getter = () => authToken2; + }); + + it('should fail when running a tool that does not require auth with auth provided', async () => { + await expect( + commonToolboxClient.loadTool('get-row-by-id', { + 'my-test-auth': authToken2Getter, + }), + ).rejects.toThrow( + "Validation failed for tool 'get-row-by-id': unused auth tokens: my-test-auth", + ); + }); + + it('should fail when running a tool requiring auth without providing auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + await expect( + tool.runAsync({args: {id: '2'}, toolContext: mockToolContext}), + ).rejects.toThrow( + 'One or more of the following authn services are required to invoke this tool: my-test-auth', + ); + }); + + it('should fail when running a tool with incorrect auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': authToken2Getter, + }); + try { + await authTool.runAsync({ + args: {id: '2'}, + toolContext: mockToolContext, + }); + } catch (error) { + expect((error as AxiosError).isAxiosError).toBe(true); + const axiosError = error as AxiosError; + expect(axiosError.response?.status).toBe(401); + expect(axiosError.response?.data).toEqual( + expect.objectContaining({ + error: + 'tool invocation not authorized. Please make sure your specify correct auth headers', + }), + ); + } + }); + + it('should succeed when running a tool with correct auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': authToken1Getter, + }); + const response = await authTool.runAsync({ + args: {id: '2'}, + toolContext: mockToolContext, + }); + expect(response).toContain('row2'); + }); + + it('should succeed when running a tool with correct async auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + const getAsyncToken = async () => { + return authToken1Getter(); + }; + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': getAsyncToken, + }); + const response = await authTool.runAsync({ + args: {id: '2'}, + toolContext: mockToolContext, + }); + expect(response).toContain('row2'); + }); + + it('should fail when a tool with a param requiring auth is run without auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-email-auth'); + await expect( + tool.runAsync({args: {}, toolContext: mockToolContext}), + ).rejects.toThrow( + 'One or more of the following authn services are required to invoke this tool: my-test-auth', + ); + }); + + it('should succeed when a tool with a param requiring auth is run with correct auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-email-auth', { + 'my-test-auth': authToken1Getter, + }); + const response = await tool.runAsync({ + args: {}, + toolContext: mockToolContext, + }); + expect(response).toContain('row4'); + expect(response).toContain('row5'); + expect(response).toContain('row6'); + }); + + it('should fail when a tool with a param requiring auth is run with insufficient auth claims', async () => { + expect.assertions(3); + + const tool = await commonToolboxClient.loadTool( + 'get-row-by-content-auth', + { + 'my-test-auth': authToken1Getter, + }, + ); + try { + await tool.runAsync({args: {}, toolContext: mockToolContext}); + } catch (error) { + expect((error as AxiosError).isAxiosError).toBe(true); + const axiosError = error as AxiosError; + expect(axiosError.response?.data).toEqual( + expect.objectContaining({ + error: + 'provided parameters were invalid: error parsing authenticated parameter "data": no field named row_data in claims', + }), + ); + } + }); + }); +}); diff --git a/packages/toolbox-adk/test/e2e/types.ts b/packages/toolbox-adk/test/e2e/types.ts new file mode 100644 index 00000000..c207831d --- /dev/null +++ b/packages/toolbox-adk/test/e2e/types.ts @@ -0,0 +1,23 @@ +// Copyright 2025 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 {ChildProcess} from 'child_process'; + +// Used in jest global setup and teardown +export type CustomGlobal = typeof globalThis & { + __TOOLS_FILE_PATH__?: string; + __TOOLBOX_SERVER_PROCESS__?: ChildProcess; + __SERVER_TEARDOWN_INITIATED__?: boolean; + __GOOGLE_CLOUD_PROJECT__?: string; +}; diff --git a/packages/toolbox-adk/test/e2e/utils.ts b/packages/toolbox-adk/test/e2e/utils.ts new file mode 100644 index 00000000..333f11a4 --- /dev/null +++ b/packages/toolbox-adk/test/e2e/utils.ts @@ -0,0 +1,147 @@ +// Copyright 2025 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 * as os from 'os'; +import fs from 'fs-extra'; +import {GoogleAuth} from 'google-auth-library'; + +import * as tmp from 'tmp'; +import {SecretManagerServiceClient} from '@google-cloud/secret-manager'; +import {Storage} from '@google-cloud/storage'; + +/** + * Gets environment variables. + */ +export function getEnvVar(key: string): string { + const value = process.env[key]; + if (value === undefined) { + throw new Error(`Must set env var ${key}`); + } + return value; +} + +/** + * Accesses the payload of a given secret version from Secret Manager. + */ +export async function accessSecretVersion( + projectId: string, + secretId: string, + versionId = 'latest', +): Promise { + const client = new SecretManagerServiceClient(); + const [version] = await client.accessSecretVersion({ + name: `projects/${projectId}/secrets/${secretId}/versions/${versionId}`, + }); + const payload = version.payload?.data?.toString(); + if (!payload) { + throw new Error(`No payload for secret ${secretId}`); + } + return payload; +} + +/** + * Creates a temporary file with the given content. + * Returns the path to the temporary file. + */ +export async function createTmpFile(content: string): Promise { + return new Promise((resolve, reject) => { + tmp.file( + {postfix: '.tmp'}, + ( + err: Error | null, + filePath: string, + _fd: number, + cleanupCallback: () => void, + ) => { + if (err) { + return reject(err); + } + fs.writeFile(filePath, content, 'utf-8') + .then(() => resolve(filePath)) + .catch(writeErr => { + cleanupCallback(); + reject(writeErr); + }); + }, + ); + }); +} + +/** + * Downloads a blob from a GCS bucket. + */ +export async function downloadBlob( + bucketName: string, + sourceBlobName: string, + destinationFileName: string, +): Promise { + const storage = new Storage(); + await storage.bucket(bucketName).file(sourceBlobName).download({ + destination: destinationFileName, + }); + console.log(`Blob ${sourceBlobName} downloaded to ${destinationFileName}.`); +} + +/** + * Constructs the GCS path to the toolbox binary. + */ +export function getToolboxBinaryGcsPath(toolboxVersion: string): string { + const system = os.platform().toLowerCase(); // 'darwin', 'linux', 'windows' + const arch = os.arch(); + let archForPath: string; // 'amd64', 'arm64' + + if (system === 'darwin' && arch === 'arm64') { + archForPath = 'arm64'; + } else { + archForPath = 'amd64'; + } + const osSystemForPath = system === 'win32' ? 'windows' : system; + return `v${toolboxVersion}/${osSystemForPath}/${archForPath}/toolbox`; +} + +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Retrieves an OIDC authentication token from the metadata server. + * This function is intended to be run on a Google Cloud environment (e.g., Compute Engine). + * + * @param {string} clientId The target audience for the OIDC token. + * @returns {Promise} A promise that resolves to the authentication token. + */ +export async function getAuthToken(clientId: string): Promise { + // The GoogleAuth library automatically detects the environment (e.g., GCE). + const auth = new GoogleAuth(); + // Get an OIDC token client for the specified audience. + const client = await auth.getIdTokenClient(clientId); + // Fetch the token. The library handles credential refreshing automatically. + const token = await client.idTokenProvider.fetchIdToken(clientId); + return token; +} + +/** + * Pytest fixture equivalent for auth_token1. + * Fetches the client ID from Secret Manager and then gets the auth token. + * + * @param {string} projectId The Google Cloud project ID. + * @returns {Promise} A promise that resolves to the first auth token. + */ +export async function authTokenGetter( + projectId: string, + clientName: string, +): Promise { + const clientId = await accessSecretVersion(projectId, clientName); + return getAuthToken(clientId); +}