Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 104 additions & 11 deletions __tests__/ut/commands/list_test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down
26 changes: 26 additions & 0 deletions __tests__/ut/core/base_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ',
Expand Down
71 changes: 70 additions & 1 deletion __tests__/ut/utils/utils_functions_test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions src/commands-help/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <endpoint>', '[Optional] Specify the fc endpoint, e.g. http://192.168.1.1:8080'],
],
},
};
40 changes: 37 additions & 3 deletions src/subCommands/list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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));
}
}
41 changes: 41 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,3 +388,44 @@ async function isZipFile(filePath: string): Promise<boolean> {
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;
}
Loading