diff --git a/__tests__/ut/commands/list_test.ts b/__tests__/ut/commands/list_test.ts index 4dfebc69..236c943d 100644 --- a/__tests__/ut/commands/list_test.ts +++ b/__tests__/ut/commands/list_test.ts @@ -1,7 +1,8 @@ import List from '../../../src/subCommands/list'; import FC from '../../../src/resources/fc'; import { IInputs } from '../../../src/interface'; -import { tableShow } from '../../../src/utils'; +import { isAppCenter, tableShow } from '../../../src/utils'; +import logger from '../../../src/logger'; // Mock dependencies jest.mock('../../../src/resources/fc'); @@ -12,17 +13,24 @@ jest.mock('../../../src/logger', () => ({ error: jest.fn(), warn: jest.fn(), log: jest.fn(), + write: 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}` - ); - }), -})); +jest.mock('../../../src/utils', () => { + const actual = jest.requireActual('../../../src/utils'); + return { + 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}` + ); + }), + MAX_DEFAULT_RENDER_LINES: actual.MAX_DEFAULT_RENDER_LINES, + estimateRenderLines: actual.estimateRenderLines, + isDefaultRenderOutput: actual.isDefaultRenderOutput, + }; +}); describe('List', () => { let list: List; @@ -237,6 +245,91 @@ describe('List', () => { }); }); + describe('run - output too large for the default renderer', () => { + // 每个函数约 6 个字段,2 万个函数 > MAX_DEFAULT_RENDER_LINES(5w) 行 + const hugeFunctionsArray = Array.from({ length: 20000 }, (_v, i) => ({ + functionName: `test-func-${i}`, + runtime: 'nodejs18', + handler: 'index.handler', + memorySize: 128, + state: 'Active', + lastModifiedTime: '2024-01-01T00:00:00Z', + })); + + let originalArgv: string[]; + + beforeEach(() => { + originalArgv = process.argv; + // clearAllMocks 不会清掉 mockReturnValue,这里显式回到默认值 + (isAppCenter as jest.Mock).mockReturnValue(false); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should print raw JSON instead of returning it when the default output format is used', async () => { + process.argv = ['node', 's', 'cli', 'fc3', 'list']; + mockInputs.args = []; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(hugeFunctionsArray); + + const result = await list.run(); + expect(result).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('20000 functions')); + expect(logger.write).toHaveBeenCalledWith( + JSON.stringify({ functions: hugeFunctionsArray }, null, 2), + ); + }); + + it('should return the result untouched when an output format is specified', async () => { + process.argv = ['node', 's', 'cli', 'fc3', 'list', '-o', 'json']; + mockInputs.args = []; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(hugeFunctionsArray); + + const result = await list.run(); + expect(result).toEqual({ functions: hugeFunctionsArray }); + expect(logger.write).not.toHaveBeenCalled(); + }); + + it('should return the result untouched when it is small enough to render', async () => { + process.argv = ['node', 's', 'cli', 'fc3', 'list']; + mockInputs.args = []; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(mockFunctionsArray); + + const result = await list.run(); + expect(result).toEqual({ functions: mockFunctionsArray }); + expect(logger.write).not.toHaveBeenCalled(); + }); + + it('should return the result untouched for programmatic app center callers', async () => { + process.argv = ['node', 's', 'cli', 'fc3', 'list']; + (isAppCenter as jest.Mock).mockReturnValue(true); + mockInputs.args = []; + list = new List(mockInputs); + mockFcSdk.listFunctions = jest.fn().mockResolvedValue(hugeFunctionsArray); + + const result = await list.run(); + expect(result).toEqual({ functions: hugeFunctionsArray }); + expect(logger.write).not.toHaveBeenCalled(); + }); + + it('should also guard the single page path', async () => { + process.argv = ['node', 's', 'cli', 'fc3', 'list']; + mockInputs.args = ['--limit', '20000']; + list = new List(mockInputs); + mockFcSdk.listFunctionsPage = jest + .fn() + .mockResolvedValue({ functions: hugeFunctionsArray, nextToken: 'next' }); + + const result = await list.run(); + expect(result).toBeUndefined(); + expect(logger.write).toHaveBeenCalled(); + }); + }); + describe('run - error handling', () => { it('should propagate auto-pagination API errors', async () => { mockInputs.args = []; diff --git a/__tests__/ut/core/base_test.ts b/__tests__/ut/core/base_test.ts index f86b96f9..d69f141b 100644 --- a/__tests__/ut/core/base_test.ts +++ b/__tests__/ut/core/base_test.ts @@ -86,6 +86,32 @@ describe('Base', () => { // Logger is mocked, so we can't verify specific calls }); + it('should take endpoint from command line args', async () => { + mockInputs.args = ['--endpoint', 'http://127.0.0.1:8080']; + + await base.handlePreRun(mockInputs, false); + + expect(mockInputs.props.endpoint).toBe('http://127.0.0.1:8080'); + }); + + it('should let command line endpoint win over yaml props', async () => { + mockInputs.props.endpoint = 'https://fcv3.cn-hangzhou.aliyuncs.com'; + mockInputs.args = ['--endpoint', 'http://127.0.0.1:8080']; + + await base.handlePreRun(mockInputs, false); + + expect(mockInputs.props.endpoint).toBe('http://127.0.0.1:8080'); + }); + + it('should keep yaml endpoint when no endpoint arg is given', async () => { + mockInputs.props.endpoint = 'https://fcv3.cn-hangzhou.aliyuncs.com'; + mockInputs.args = []; + + await base.handlePreRun(mockInputs, false); + + expect(mockInputs.props.endpoint).toBe('https://fcv3.cn-hangzhou.aliyuncs.com'); + }); + it('should trim image whitespace for custom container', async () => { mockInputs.props.customContainerConfig = { image: ' test-image:latest ', diff --git a/__tests__/ut/utils/utils_functions_test.ts b/__tests__/ut/utils/utils_functions_test.ts index bfad0735..508ca4de 100644 --- a/__tests__/ut/utils/utils_functions_test.ts +++ b/__tests__/ut/utils/utils_functions_test.ts @@ -1,4 +1,11 @@ -import { isAuto, isAutoVpcConfig, sleep } from '../../../src/utils/index'; +import { + MAX_DEFAULT_RENDER_LINES, + estimateRenderLines, + isAuto, + isAutoVpcConfig, + isDefaultRenderOutput, + sleep, +} from '../../../src/utils/index'; import { computeLocalAuto } from '../../../src/resources/fc/impl/utils'; import log from '../../../src/logger'; log._set(console); @@ -147,6 +154,68 @@ describe('Utils functions', () => { }); }); + describe('isDefaultRenderOutput', () => { + it('should return true when no output format flag is present', () => { + expect(isDefaultRenderOutput(['cli', 'fc3', 'list', '--region', 'cn-hangzhou'])).toBe(true); + }); + + it('should return false for -o/--output-format/--output/--output-file', () => { + expect(isDefaultRenderOutput(['list', '-o', 'json'])).toBe(false); + expect(isDefaultRenderOutput(['list', '--output-format', 'yaml'])).toBe(false); + expect(isDefaultRenderOutput(['list', '--output', 'raw'])).toBe(false); + expect(isDefaultRenderOutput(['list', '--output-file', './out.json'])).toBe(false); + }); + + it('should recognize flags written as --flag=value', () => { + expect(isDefaultRenderOutput(['list', '--output-format=json'])).toBe(false); + }); + + it('should not confuse a value that looks like a flag name', () => { + expect(isDefaultRenderOutput(['list', '--prefix', 'output'])).toBe(true); + }); + }); + + describe('estimateRenderLines', () => { + it('should count one line per scalar field', () => { + expect(estimateRenderLines({ a: 1, b: 'x', c: null })).toBe(3); + }); + + it('should count nested objects and arrays', () => { + // functionName + nasConfig + nasConfig.groupId + nasConfig.mountPoints + // + 2 mount points, each with a separator line + expect( + estimateRenderLines({ + functionName: 'f', + nasConfig: { groupId: 1, mountPoints: [{ mountDir: '/mnt' }, { mountDir: '/data' }] }, + }), + ).toBe(8); + }); + + it('should count scalars in an array as one line each', () => { + expect(estimateRenderLines(['a', 'b', 'c'])).toBe(3); + }); + + it('should count the separator line prettyjson adds per object in an array', () => { + // prettyjson 对 [{ a: 1 }, { a: 2 }] 输出 4 行,每个元素的字段 1 行 + 分隔 1 行 + expect(estimateRenderLines([{ a: 1 }, { a: 2 }])).toBe(4); + expect(estimateRenderLines([[1, 2, 3]])).toBe(4); + }); + + it('should exceed the threshold for a listing that breaks the default renderer', () => { + const functions = Array.from({ length: 20000 }, (_v, i) => ({ + functionName: `f-${i}`, + runtime: 'nodejs18', + handler: 'index.handler', + })); + expect(estimateRenderLines({ functions })).toBeGreaterThan(MAX_DEFAULT_RENDER_LINES); + }); + + it('should stay under the threshold for a normal listing', () => { + const functions = Array.from({ length: 100 }, (_v, i) => ({ functionName: `f-${i}` })); + expect(estimateRenderLines({ functions })).toBeLessThan(MAX_DEFAULT_RENDER_LINES); + }); + }); + describe('sleep', () => { it('should resolve after specified time', async () => { const start = Date.now(); diff --git a/src/base.ts b/src/base.ts index 6926ca04..aad46cf1 100644 --- a/src/base.ts +++ b/src/base.ts @@ -2,6 +2,7 @@ /* eslint-disable require-atomic-updates */ /* eslint-disable no-await-in-loop */ import _ from 'lodash'; +import { parseArgv } from '@serverless-devs/utils'; import { IInputs, INasConfig } from './interface'; // eslint-disable-next-line @typescript-eslint/no-shadow import log from './logger'; @@ -30,6 +31,13 @@ export default class Base { // 在运行方法之前运行 async handlePreRun(inputs: IInputs, needCredential: boolean) { log._set(this.logger); + // --endpoint 只出现在命令行参数里(yaml 模式下走 props.endpoint), + // s cli 模式没有 yaml,必须从 argv 取,命令行优先级高于 yaml + const argvEndpoint = _.get(parseArgv(inputs.args || [], { string: ['endpoint'] }), 'endpoint'); + if (!_.isEmpty(argvEndpoint)) { + log.debug(`use endpoint from command line: ${argvEndpoint}`); + _.set(inputs, 'props.endpoint', argvEndpoint); + } // fc组件镜像 trim 左右空格 const image = _.get(inputs, 'props.customContainerConfig.image'); if (!_.isEmpty(image)) { diff --git a/src/commands-help/list.ts b/src/commands-help/list.ts index 7785ad3d..2979998d 100644 --- a/src/commands-help/list.ts +++ b/src/commands-help/list.ts @@ -21,6 +21,7 @@ Example: '[Optional] Specify the next token for pagination, only works with --limit', ], ['--table', '[Optional] Specify if output the result as table format'], + ['--endpoint ', '[Optional] Specify the fc endpoint, e.g. http://192.168.1.1:8080'], ], }, }; diff --git a/src/subCommands/list/index.ts b/src/subCommands/list/index.ts index 2f484b11..4423deea 100644 --- a/src/subCommands/list/index.ts +++ b/src/subCommands/list/index.ts @@ -3,7 +3,19 @@ import { IInputs, IRegion, checkRegion } from '../../interface'; import logger from '../../logger'; import _ from 'lodash'; import FC from '../../resources/fc'; -import { getUserAgent, tableShow } from '../../utils'; +import { + MAX_DEFAULT_RENDER_LINES, + estimateRenderLines, + getUserAgent, + isAppCenter, + isDefaultRenderOutput, + tableShow, +} from '../../utils'; + +export interface IListResult { + functions?: unknown[]; + nextToken?: string; +} const LIST_TABLE_KEYS = [ 'functionName', @@ -56,7 +68,7 @@ export default class List { tableShow(body.functions || [], LIST_TABLE_KEYS); return; } - return body; + return this.output(body); } const functions = await this.fcSdk.listFunctions(prefix); @@ -65,6 +77,28 @@ export default class List { tableShow(functions || [], LIST_TABLE_KEYS); return; } - return { functions }; + return this.output({ functions }); + } + + /** + * 函数数量很多时,CLI 内核默认的 prettyjson 渲染器会因为参数个数超限抛 + * RangeError: Maximum call stack size exceeded,这里直接打印 JSON 兜底。 + * 只有真实 CLI 走 prettyjson 渲染,app center 等程序化调用方依赖返回值,原样返回。 + */ + private output(result: IListResult): IListResult | undefined { + if ( + isAppCenter() || + !isDefaultRenderOutput() || + estimateRenderLines(result) <= MAX_DEFAULT_RENDER_LINES + ) { + return result; + } + + logger.warn( + `Got ${ + (result.functions || []).length + } functions, too large for the default output format. Printing raw JSON instead, use --limit/--next-token to paginate, --table for a summary, or -o json/yaml to pick the output format.`, + ); + logger.write(JSON.stringify(result, null, 2)); } } diff --git a/src/utils/index.ts b/src/utils/index.ts index 25b59347..f8cca2d0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -388,3 +388,44 @@ async function isZipFile(filePath: string): Promise { return false; } } + +// s CLI 默认输出格式下,内核用 prettyjson 渲染组件返回值。prettyjson 会把整个返回值 +// 拍平成一个字符串数组,再用 `push.apply(lines, subLines)` 回灌,参数个数超过 V8 上限时抛 +// RangeError: Maximum call stack size exceeded(本机 Node 22 实测 12w 个参数可以、13w 抛错)。 +// estimateRenderLines 是下界估算,用 prettyjson 1.2.5 实测:list 返回值偏低约 7%, +// 最坏的结构形状(字段值是空数组/空对象)偏低 25%,即 5w 行阈值对应实际最多约 6.2w 行, +// 距离 12w 的上限仍有充足余量。 +export const MAX_DEFAULT_RENDER_LINES = 50000; + +/** + * 返回值是否会走 CLI 内核的默认渲染器 (prettyjson)。 + * 指定 -o/--output-format/--output 时内核用 JSON/YAML 序列化,指定 --output-file 时写文件, + * 都不经过 prettyjson。 + */ +export function isDefaultRenderOutput(argv: string[] = process.argv.slice(2)): boolean { + const outputFlags = ['-o', '--output-format', '--output', '--output-file']; + return !argv.some((arg) => outputFlags.includes(arg.split('=')[0])); +} + +/** + * 估算 prettyjson 渲染 data 需要的行数:每个字段一行,嵌套对象/数组的字段各自再算一行, + * 数组里的对象/数组元素额外算一行(prettyjson 会给它们多输出一行分隔)。 + */ +export function estimateRenderLines(data: any): number { + if (_.isArray(data)) { + return _.sum( + data.map((item) => + _.isArray(item) || _.isPlainObject(item) ? estimateRenderLines(item) + 1 : 1, + ), + ); + } + if (_.isPlainObject(data)) { + return _.sum( + Object.values(data).map( + (value) => + 1 + (_.isArray(value) || _.isPlainObject(value) ? estimateRenderLines(value) : 0), + ), + ); + } + return 1; +}