From 6adf276fc54aeac6a57208438e4d51f230ecfef4 Mon Sep 17 00:00:00 2001 From: ls147258 Date: Mon, 4 May 2026 21:55:30 +0800 Subject: [PATCH] feat: list command defaults to auto-pagination when --limit is not specified When no --limit is passed, the list command now uses listFunctions which auto-paginates through all functions. When --limit is explicitly set, it still uses listFunctionsPage for single-page results with nextToken support. Also includes: - Input validation for --limit (must be a positive integer) - Extracted LIST_TABLE_KEYS constant to eliminate duplication - Increased listFunctions batch size from 2 to 100 for performance Change-Id: I54d5f7f36ec5c03117465d03dea0ce1f641e3420 Co-developed-by: Claude --- .gitignore | 5 +- __tests__/ut/commands/list_test.ts | 257 +++++++++++++++++++++++++++++ publish.yaml | 2 +- src/commands-help/index.ts | 2 + src/commands-help/list.ts | 26 +++ src/index.ts | 7 + src/resources/fc/impl/client.ts | 11 +- src/subCommands/list/index.ts | 70 ++++++++ 8 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 __tests__/ut/commands/list_test.ts create mode 100644 src/commands-help/list.ts create mode 100644 src/subCommands/list/index.ts diff --git a/.gitignore b/.gitignore index 00f398a5..6ad41d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,10 @@ __tests__/e2e/python/code/apt-archives __tests__/e2e/apt/code/package-lock.json .env_test +.env CLAUDE.md agent-prompt.md -AGENTS.md \ No newline at end of file +AGENTS.md +.qoder +docs/ \ No newline at end of file diff --git a/__tests__/ut/commands/list_test.ts b/__tests__/ut/commands/list_test.ts new file mode 100644 index 00000000..4dfebc69 --- /dev/null +++ b/__tests__/ut/commands/list_test.ts @@ -0,0 +1,257 @@ +import List from '../../../src/subCommands/list'; +import FC from '../../../src/resources/fc'; +import { IInputs } from '../../../src/interface'; +import { tableShow } from '../../../src/utils'; + +// Mock dependencies +jest.mock('../../../src/resources/fc'); +jest.mock('../../../src/logger', () => ({ + _set: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), +})); +jest.mock('../../../src/utils', () => ({ + tableShow: jest.fn(), + isAppCenter: jest.fn(), + getUserAgent: jest.fn((userAgent, command) => { + return ( + userAgent || + `Component:fc3;Nodejs:${process.version};OS:${process.platform}-${process.arch};command:${command}` + ); + }), +})); + +describe('List', () => { + let list: List; + let mockInputs: IInputs; + let mockFcSdk: jest.Mocked; + + const mockFunctionsArray = [ + { + functionName: 'test-func-1', + runtime: 'nodejs18', + handler: 'index.handler', + memorySize: 128, + state: 'Active', + lastModifiedTime: '2024-01-01T00:00:00Z', + }, + { + functionName: 'test-func-2', + runtime: 'python3.10', + handler: 'main.handler', + memorySize: 256, + state: 'Active', + lastModifiedTime: '2024-01-02T00:00:00Z', + }, + ]; + + const mockFunctionsPageBody = { + functions: mockFunctionsArray, + nextToken: 'next-token-123', + }; + + beforeEach(() => { + mockInputs = { + props: { + region: 'cn-hangzhou', + }, + credential: { + AccountID: 'test-account', + AccessKeyID: 'test-access-key-id', + AccessKeySecret: 'test-access-key-secret', + }, + args: [], + } as any; + + mockFcSdk = new FC(mockInputs.props.region, mockInputs.credential, {}) as jest.Mocked; + (FC as unknown as jest.Mock).mockImplementation(() => mockFcSdk); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create List instance with valid inputs', () => { + mockInputs.args = []; + list = new List(mockInputs); + expect(list).toBeInstanceOf(List); + }); + + it('should throw error when region is not specified', () => { + delete mockInputs.props.region; + mockInputs.args = []; + expect(() => new List(mockInputs)).toThrow('Region not specified, please specify --region'); + }); + + it('should use region from command line args over props', () => { + mockInputs.args = ['--region', 'cn-beijing']; + list = new List(mockInputs); + expect(list).toBeInstanceOf(List); + }); + }); + + describe('run - without limit (auto-pagination)', () => { + beforeEach(() => { + mockInputs.args = []; + list = new List(mockInputs); + }); + + it('should list all functions via auto-pagination when no limit specified', async () => { + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(mockFunctionsArray); + + const result = await list.run(); + expect(mockFcSdk.listFunctions).toHaveBeenCalledWith(undefined); + expect(mockFcSdk.listFunctionsPage).not.toHaveBeenCalled(); + expect(result).toEqual({ functions: mockFunctionsArray }); + }); + + it('should list all functions with prefix filter via auto-pagination', async () => { + mockInputs.args = ['--prefix', 'test']; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(mockFunctionsArray); + + const result = await list.run(); + expect(mockFcSdk.listFunctions).toHaveBeenCalledWith('test'); + expect(result).toEqual({ functions: mockFunctionsArray }); + }); + + it('should show table output without limit', async () => { + mockInputs.args = ['--table']; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(mockFunctionsArray); + + await list.run(); + expect(tableShow).toHaveBeenCalledWith(mockFunctionsArray, [ + 'functionName', + 'runtime', + 'handler', + 'memorySize', + 'state', + 'lastModifiedTime', + ]); + }); + + it('should handle empty functions list via auto-pagination', async () => { + mockFcSdk.listFunctions = jest.fn().mockResolvedValue([]); + + const result = await list.run(); + expect(result).toEqual({ functions: [] }); + }); + + it('should not call tableShow when --table is not specified', async () => { + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(mockFunctionsArray); + + await list.run(); + expect(tableShow).not.toHaveBeenCalled(); + }); + }); + + describe('run - with limit (single page)', () => { + it('should list functions with custom limit via single page', async () => { + mockInputs.args = ['--limit', '20']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockResolvedValue(mockFunctionsPageBody); + + const result = await list.run(); + expect(mockFcSdk.listFunctionsPage).toHaveBeenCalledWith(20, undefined, undefined); + expect(mockFcSdk.listFunctions).not.toHaveBeenCalled(); + expect(result).toEqual(mockFunctionsPageBody); + }); + + it('should reject --limit with value 0', async () => { + mockInputs.args = ['--limit', '0']; + list = new List(mockInputs); + + await expect(list.run()).rejects.toThrow('--limit must be a positive integer'); + }); + + it('should reject --limit with non-integer value', async () => { + mockInputs.args = ['--limit', 'abc']; + list = new List(mockInputs); + + await expect(list.run()).rejects.toThrow('--limit must be a positive integer'); + }); + + it('should list functions with prefix and limit', async () => { + mockInputs.args = ['--prefix', 'test', '--limit', '20']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockResolvedValue(mockFunctionsPageBody); + + const result = await list.run(); + expect(mockFcSdk.listFunctionsPage).toHaveBeenCalledWith(20, 'test', undefined); + expect(result).toEqual(mockFunctionsPageBody); + }); + + it('should list functions with nextToken for pagination', async () => { + mockInputs.args = ['--limit', '20', '--next-token', 'next-token-123']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockResolvedValue(mockFunctionsPageBody); + + const result = await list.run(); + expect(mockFcSdk.listFunctionsPage).toHaveBeenCalledWith(20, undefined, 'next-token-123'); + expect(result).toEqual(mockFunctionsPageBody); + }); + + it('should list functions with all query parameters', async () => { + mockInputs.args = ['--prefix', 'test', '--limit', '10', '--next-token', 'token-abc']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockResolvedValue(mockFunctionsPageBody); + + const result = await list.run(); + expect(mockFcSdk.listFunctionsPage).toHaveBeenCalledWith(10, 'test', 'token-abc'); + expect(result).toEqual(mockFunctionsPageBody); + }); + + it('should show table output with limit', async () => { + mockInputs.args = ['--limit', '20', '--table']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockResolvedValue(mockFunctionsPageBody); + + await list.run(); + expect(tableShow).toHaveBeenCalledWith(mockFunctionsArray, [ + 'functionName', + 'runtime', + 'handler', + 'memorySize', + 'state', + 'lastModifiedTime', + ]); + }); + + it('should handle table output with empty functions via single page', async () => { + mockInputs.args = ['--limit', '20', '--table']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockResolvedValue({ + functions: [], + }); + + await list.run(); + expect(tableShow).toHaveBeenCalledWith( + [], + ['functionName', 'runtime', 'handler', 'memorySize', 'state', 'lastModifiedTime'], + ); + }); + }); + + describe('run - error handling', () => { + it('should propagate auto-pagination API errors', async () => { + mockInputs.args = []; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockRejectedValue(new Error('API error')); + + await expect(list.run()).rejects.toThrow('API error'); + }); + + it('should propagate single page API errors', async () => { + mockInputs.args = ['--limit', '20']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest.fn().mockRejectedValue(new Error('API error')); + + await expect(list.run()).rejects.toThrow('API error'); + }); + }); +}); diff --git a/publish.yaml b/publish.yaml index 01b753f4..d28ee0f4 100644 --- a/publish.yaml +++ b/publish.yaml @@ -3,7 +3,7 @@ Type: Component Name: fc3 Provider: - 阿里云 -Version: 0.1.18 +Version: 0.1.19 Description: 阿里云函数计算全生命周期管理 HomePage: https://github.com/devsapp/fc3 Organization: 阿里云函数计算(FC) diff --git a/src/commands-help/index.ts b/src/commands-help/index.ts index a8fc14c2..977df2bd 100644 --- a/src/commands-help/index.ts +++ b/src/commands-help/index.ts @@ -16,6 +16,7 @@ import s2tos3 from './s2tos3'; import logs from './logs'; import session from './session'; import scaling from './scaling'; +import list from './list'; export default { deploy, @@ -36,4 +37,5 @@ export default { logs, session, scaling, + list, }; diff --git a/src/commands-help/list.ts b/src/commands-help/list.ts new file mode 100644 index 00000000..7785ad3d --- /dev/null +++ b/src/commands-help/list.ts @@ -0,0 +1,26 @@ +export default { + help: { + description: `List all functions. +Example: + $ s list + $ s list --prefix test --table + $ s cli fc3 list --prefix test --limit 20 --next-token xxx --region cn-hangzhou -a default`, + summary: 'List all functions', + option: [ + [ + '--region ', + '[C-Required] Specify the fc region, you can see all supported regions in https://help.aliyun.com/document_detail/2512917.html', + ], + ['--prefix ', '[Optional] Specify the prefix of function name'], + [ + '--limit ', + '[Optional] Specify the max number of functions to return per page, if not specified, all functions will be listed', + ], + [ + '--next-token ', + '[Optional] Specify the next token for pagination, only works with --limit', + ], + ['--table', '[Optional] Specify if output the result as table format'], + ], + }, +}; diff --git a/src/index.ts b/src/index.ts index cb372104..923eb320 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import Alias from './subCommands/alias'; import Concurrency from './subCommands/concurrency'; import SYaml2To3 from './subCommands/2to3'; import Logs from './subCommands/logs'; +import List from './subCommands/list'; import { SCHEMA_FILE_PATH } from './constant'; import { checkDockerIsOK, isAppCenter, isYunXiao } from './utils'; import { Model } from './subCommands/model'; @@ -183,6 +184,12 @@ export default class Fc extends Base { return await logs.run(); } + public async list(inputs: IInputs) { + await super.handlePreRun(inputs, true); + const list = new List(inputs); + return await list.run(); + } + public async model(inputs: IInputs) { await super.handlePreRun(inputs, false); const model = new Model(inputs); diff --git a/src/resources/fc/impl/client.ts b/src/resources/fc/impl/client.ts index 0c283e74..c0e564b7 100644 --- a/src/resources/fc/impl/client.ts +++ b/src/resources/fc/impl/client.ts @@ -418,12 +418,21 @@ export default class FC_Client { return body; } + async listFunctionsPage(limit: number, prefix?: string, nextToken?: string) { + const request = new ListFunctionsRequest({ limit, prefix, nextToken }); + const runtime = new RuntimeOptions({}); + const headers: { [key: string]: string } = {}; + const result = await this.fc20230330Client.listFunctionsWithOptions(request, headers, runtime); + const { body } = result.toMap(); + return body; + } + /** * list 接口实现模版 */ async listFunctions(prefix?: string): Promise { let nextToken = ''; - const limit = 2; + const limit = 100; const functions: any[] = []; while (true) { diff --git a/src/subCommands/list/index.ts b/src/subCommands/list/index.ts new file mode 100644 index 00000000..2f484b11 --- /dev/null +++ b/src/subCommands/list/index.ts @@ -0,0 +1,70 @@ +import { parseArgv } from '@serverless-devs/utils'; +import { IInputs, IRegion, checkRegion } from '../../interface'; +import logger from '../../logger'; +import _ from 'lodash'; +import FC from '../../resources/fc'; +import { getUserAgent, tableShow } from '../../utils'; + +const LIST_TABLE_KEYS = [ + 'functionName', + 'runtime', + 'handler', + 'memorySize', + 'state', + 'lastModifiedTime', +]; + +export default class List { + private region: IRegion; + private fcSdk: FC; + private opts: any; + + constructor(readonly inputs: IInputs) { + const opts = parseArgv(inputs.args, { + alias: { help: 'h' }, + boolean: ['help', 'table'], + string: ['region', 'prefix', 'limit', 'next-token'], + }); + + logger.debug(`list opts: ${JSON.stringify(opts)}`); + + const { region } = opts; + this.region = region || _.get(inputs, 'props.region', ''); + checkRegion(this.region); + + const userAgent = getUserAgent(inputs.userAgent, 'list'); + this.fcSdk = new FC(this.region, inputs.credential, { + endpoint: inputs.props.endpoint, + userAgent, + }); + + this.opts = opts; + } + + async run() { + const { limit, prefix } = this.opts; + const nextToken = this.opts['next-token']; + + if (limit) { + const parsedLimit = parseInt(limit, 10); + if (!parsedLimit || parsedLimit < 1) { + throw new Error('--limit must be a positive integer'); + } + const body = await this.fcSdk.listFunctionsPage(parsedLimit, prefix, nextToken); + + if (this.opts.table) { + tableShow(body.functions || [], LIST_TABLE_KEYS); + return; + } + return body; + } + + const functions = await this.fcSdk.listFunctions(prefix); + + if (this.opts.table) { + tableShow(functions || [], LIST_TABLE_KEYS); + return; + } + return { functions }; + } +}