From 8c4547518148b953867a1166b2f1e102c76fcb21 Mon Sep 17 00:00:00 2001 From: Baocang Nie <16043697+baocang@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:31:26 +0800 Subject: [PATCH] + feat: support automatic pagination with --all --- CHANGES.md | 2 +- docs/cli-usage.md | 8 +- skills/zentao-cli/SKILL.md | 7 +- skills/zentao-tour/roles/executive.md | 20 +-- skills/zentao-tour/roles/pm.md | 6 +- src/commands/module-handler.ts | 12 +- src/commands/register-modules.ts | 2 +- src/mcp/tools.ts | 3 + src/modules/executor.ts | 150 +++++++++++++++++--- src/types/commands.ts | 2 +- tests/executor.test.ts | 190 +++++++++++++++++++++++++- tests/mcp-e2e.test.ts | 1 + tests/skill-contract.test.ts | 4 +- 13 files changed, 364 insertions(+), 43 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 577ca24..f24f7f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ - **Skill 命令契约**: 对齐 `zentao-api` 当前模块 schema,修正列表作用域、创建字段、状态流转必填参数、过滤 OR 语义和分页说明。 - **帮助命令**: 后缀 `help` 语法改为鉴权前处理,并仅展示当前动作实际支持的分页和结果选项。 -- **`--all` 显式失败**: 在自动翻页尚未实现时返回明确错误,避免静默把默认页误当全量数据。 +- **`--all` 自动翻页**: 列表操作支持自动读取全部分页,并在聚合后统一应用过滤、搜索、排序、限制和字段摘取。 ### 🚀 优化与重构 (Refactor) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 015de0d..1b26a0f 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -637,9 +637,10 @@ $ zentao ls product --sort=name:asc * `--page=`:指定页码,默认值为 1 * `--recPerPage=`:指定分页大小,默认值为 20 -* `--limit=`:截取当前返回页的数据;仅非负有限数值生效,小数向下取整 +* `--all`:自动翻页获取全部数据;不能与 `--page` 或 `--format=raw` 同时使用 +* `--limit=`:限制返回数量;仅非负有限数值生效,小数向下取整 -CLI 当前不会自动翻页。仅当该列表操作的 `--help` 显示 `--page` / `--recPerPage` 时才可使用这两个参数;需要全量数据且返回了 pager 时,重复调整 `--page`,直到已读取条数覆盖总数。 +仅当列表操作的 `--help` 显示相应选项时,才可使用 `--page`、`--recPerPage` 或 `--all`。使用 `--all` 时,CLI 会先聚合全部分页,再统一应用过滤、搜索、排序、`--limit` 和 `--pick`。 ```bash # 获取禅道产品信息,并分页获取 @@ -651,6 +652,9 @@ $ zentao product --page=1 --recPerPage=100 | 2 | 产品2 | 已显示 2 项,共 2 项,当前第 1 页,每页 100 条 + +# 自动获取全部产品,并在完整结果上排序和限制数量 +$ zentao product --all --sort=id:desc --limit=10 ``` ## 设置默认配置 diff --git a/skills/zentao-cli/SKILL.md b/skills/zentao-cli/SKILL.md index f2a82e2..2b687a6 100644 --- a/skills/zentao-cli/SKILL.md +++ b/skills/zentao-cli/SKILL.md @@ -188,10 +188,11 @@ zentao bug --product=1 --sort=pri:asc,severity:asc ```bash zentao bug --product=1 --page=1 --recPerPage=50 -zentao bug --product=1 --limit=10 # 只取当前返回页的前 10 条 +zentao bug --product=1 --all # 自动获取全部分页 +zentao bug --product=1 --all --limit=10 # 获取全部后只保留前 10 条 ``` -CLI 不会自动翻页。仅当该列表操作的 `--help` 显示 `--page` / `--recPerPage` 时才可使用这两个参数;部分列表接口不支持服务端分页参数。需要全量数据且返回了 pager 时,重复调整 `--page`,直到已读取条数覆盖总数。`--limit` 只在当前返回页上截取。 +仅当列表操作的 `--help` 显示相应选项时,才可使用 `--page`、`--recPerPage` 或 `--all`。`--all` 会先聚合全部分页,再统一应用过滤、搜索、排序、`--limit` 和字段摘取;不能与 `--page` 或 `--format=raw` 同时使用。 ## 常用操作示例 @@ -236,7 +237,7 @@ zentao help # 查看所有命令 | 用户意图 | CLI 命令 | |---------|---------| -| 所有产品/项目/项目集 | `zentao product` / `zentao project --browseType=all` / `zentao program`,全量结果按 pager 逐页读取 | +| 所有产品/项目/项目集 | `zentao product --all` / `zentao project --browseType=all --all` / `zentao program --all` | | 进行中的项目 | `zentao project --browseType=doing` | | 某产品的 Bug | `zentao bug --product=` | | 某执行的任务 | `zentao task --executionID=` | diff --git a/skills/zentao-tour/roles/executive.md b/skills/zentao-tour/roles/executive.md index cdeb5cd..3089d9d 100644 --- a/skills/zentao-tour/roles/executive.md +++ b/skills/zentao-tour/roles/executive.md @@ -28,11 +28,11 @@ zentao project --browseType=doing --pick=id,name,begin,end,progress 让他扫一眼,问:"有没有哪条看着不对劲?进度慢的、日期要到的?"——挑出一个深入看: ```bash -zentao execution --status=all --filter='project=' --pick=id,name,status --page=<页码> --recPerPage=100 -zentao task --executionID=<执行ID> --status=all --pick=id,status --page=<页码> --recPerPage=100 --format=json +zentao execution --status=all --filter='project=' --pick=id,name,status --all +zentao task --executionID=<执行ID> --status=all --pick=id,status --all --format=json ``` -执行列表没有项目作用域,所以用 `--filter` 在当前页本地筛选;必须扫完全局执行列表的每一页,才能收集某项目的全部执行 ID。再对每个执行按任务 pager 逐页读取,本地聚合"wait/doing/done 各多少",用一句话汇报给用户。 +执行列表没有项目作用域,所以用 `--all` 获取全部执行后再过滤,收集某项目的全部执行 ID。再对每个执行获取全部任务,本地聚合"wait/doing/done 各多少",用一句话汇报给用户。 ## 如果他关心产品健康度 @@ -43,11 +43,11 @@ zentao product --pick=id,name,status 挑他在意的那个产品: ```bash -zentao story --product= --browseType=allstory --pick=id,pri,stage,plan --page=<页码> --recPerPage=100 --format=json -zentao bug --product= --browseType=all --pick=id,severity,pri,status --page=<页码> --recPerPage=100 --format=json +zentao story --product= --browseType=allstory --pick=id,pri,stage,plan --all --format=json +zentao bug --product= --browseType=all --pick=id,severity,pri,status --all --format=json ``` -根据 pager 逐页读完再做简单统计(高优先级未处理需求数、严重 Bug 数),用两句话告诉用户:"《XXX》当前有 N 条高优需求还没排期,严重 Bug M 条——主要堆在这几个 severity 上。" +获取全部结果后再做简单统计(高优先级未处理需求数、严重 Bug 数),用两句话告诉用户:"《XXX》当前有 N 条高优需求还没排期,严重 Bug M 条——主要堆在这几个 severity 上。" ## 如果他关心发布与版本 @@ -85,10 +85,10 @@ zentao ticket --productID= --browseType=all --pick=id,title,status,pri | 关注点 | 命令 | |--------|------| | 进行中的项目 | `zentao project --browseType=doing --pick=id,name,progress,begin,end` | -| 项目下的执行 | `zentao execution --status=all --filter='project=' --pick=id,name,status --page=<页码> --recPerPage=100`| -| 任务状态聚合 | `zentao task --executionID= --status=all --pick=status --page=<页码> --recPerPage=100 --format=json` | -| 产品下需求概览 | `zentao story --product= --browseType=allstory --pick=id,pri,stage,plan --page=<页码> --recPerPage=100 --format=json` | -| 产品下 Bug 概览 | `zentao bug --product= --browseType=all --pick=id,severity,pri,status --page=<页码> --recPerPage=100 --format=json` | +| 项目下的执行 | `zentao execution --status=all --filter='project=' --pick=id,name,status --all`| +| 任务状态聚合 | `zentao task --executionID= --status=all --pick=status --all --format=json` | +| 产品下需求概览 | `zentao story --product= --browseType=allstory --pick=id,pri,stage,plan --all --format=json` | +| 产品下 Bug 概览 | `zentao bug --product= --browseType=all --pick=id,severity,pri,status --all --format=json` | | 即将 / 最近发布 | `zentao release --productID= --pick=id,name,date,status` | | 版本 | `zentao build --project= --pick=id,name,date` | | 用户反馈 | `zentao feedback --productID= --browseType=all --pick=id,title,status,pri` | diff --git a/skills/zentao-tour/roles/pm.md b/skills/zentao-tour/roles/pm.md index 526cc6b..95fd92f 100644 --- a/skills/zentao-tour/roles/pm.md +++ b/skills/zentao-tour/roles/pm.md @@ -102,10 +102,10 @@ zentao story update --plan=<计划ID> 全部挂完后,顺手列一张表给用户看成果(**不要**加"小结"这种字眼): ```bash -zentao story --product=<产品ID> --pick=id,title,pri,plan --page=<页码> --recPerPage=100 +zentao story --product=<产品ID> --pick=id,title,pri,plan --all ``` -根据 pager 逐页读完后,再给用户看完整结果。 +获取全部结果后,再给用户看完整结果。 像朋友一样指着说:"喏,你看这几条都绑在《MVP 首发》上了——从一个空白的点子到这张表,其实你已经走完了产品经理最核心的一条线:**产品 → 需求 → 计划**。研发同事打开禅道就能按这个打工。" @@ -139,5 +139,5 @@ zentao story --product=<产品ID> --pick=id,title,pri,plan --page=<页码> --rec | 建需求 | `zentao story create --productID= --title= --pri= --spec=` | | 改需求所属计划 | `zentao story update --plan=` | | 建计划 | `zentao productplan create --productID= --title= --begin= --end=` | -| 查看产品下所有需求 | `zentao story --product= --pick=id,title,pri,plan --page=<页码> --recPerPage=100`,按 pager 逐页读取 | +| 查看产品下所有需求 | `zentao story --product= --pick=id,title,pri,plan --all` | | 查看参数 | `zentao --help` | diff --git a/src/commands/module-handler.ts b/src/commands/module-handler.ts index 349cc19..ad2ce8e 100644 --- a/src/commands/module-handler.ts +++ b/src/commands/module-handler.ts @@ -295,7 +295,11 @@ export function showModuleHelp(mod: ModuleDefinition): void { if (listParams.some((param) => param.name === 'recPerPage')) { commonOpts.push({ name: 'recPerPage', placeholder: 'number', description: '每页条数,适用于 list 操作' }); } - commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制当前返回结果数量,适用于 list 操作' }); + if (listParams.some((param) => param.name === 'pageID') + && listParams.some((param) => param.name === 'recPerPage')) { + commonOpts.push({ name: 'all', description: '自动翻页获取全部数据,不可与 --page 或 --format=raw 同时使用' }); + } + commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制返回结果数量,适用于 list 操作' }); } if (createAction || updateAction || actions.length > 0) { commonOpts.push({ name: 'data', placeholder: 'json', description: '请求数据(JSON 格式),适用于 create/update/状态流转操作' }); @@ -395,7 +399,11 @@ export function showModuleActionHelp(mod: ModuleDefinition, action: ModuleAction if (actionParams.some((param) => param.name === 'recPerPage')) { commonOpts.push({ name: 'recPerPage', placeholder: 'number', description: '每页条数' }); } - commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制当前返回结果数量' }); + if (actionParams.some((param) => param.name === 'pageID') + && actionParams.some((param) => param.name === 'recPerPage')) { + commonOpts.push({ name: 'all', description: '自动翻页获取全部数据,不可与 --page 或 --format=raw 同时使用' }); + } + commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制返回结果数量' }); } else if (action.type === 'get') { commonOpts.push( { name: 'pick', placeholder: 'fields', description: '摘取指定字段(逗号分隔),仅输出指定的字段' }, diff --git a/src/commands/register-modules.ts b/src/commands/register-modules.ts index fa6574d..0d2c020 100644 --- a/src/commands/register-modules.ts +++ b/src/commands/register-modules.ts @@ -17,7 +17,7 @@ export function addDataOptions(cmd: Command): Command { .option('--search-fields ', '搜索字段(逗号分隔)') .option('--page ', '页码') .option('--recPerPage ', '每页条数') - .option('--all', '尚未支持自动翻页,请使用 --page 和 --recPerPage') + .option('--all', '自动翻页获取全部数据') .option('--limit ', '限制获取数量') .option('--data ', 'JSON 数据') .option('--params ', 'API 调用参数') diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 3f79b08..2da9ac7 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -58,6 +58,7 @@ function buildInputSchema(mod: ModuleDefinition) { searchFields: z.string().optional().describe('搜索字段(逗号分隔),配合 search 使用'), page: z.number().optional().describe('页码'), recPerPage: z.number().optional().describe('每页条数'), + all: z.boolean().optional().describe('对支持分页的列表操作自动获取全部数据,不能与 page 同时使用'), }; } @@ -75,6 +76,7 @@ interface ToolInput { searchFields?: string; page?: number; recPerPage?: number; + all?: boolean; } async function handleProfileTool(auth: AuthProvider): Promise { @@ -149,6 +151,7 @@ async function handleModuleTool( searchFields: input.searchFields, page: input.page != null ? String(input.page) : undefined, recPerPage: input.recPerPage != null ? String(input.recPerPage) : undefined, + all: input.all, format: 'json', yes: true, }; diff --git a/src/modules/executor.ts b/src/modules/executor.ts index be4f24d..8ed8fe3 100644 --- a/src/modules/executor.ts +++ b/src/modules/executor.ts @@ -1,4 +1,4 @@ -import { request } from 'zentao-api'; +import { processData, request } from 'zentao-api'; import type { ZentaoClient } from '../api/index.js'; import { mapSdkError } from '../errors.js'; import type { @@ -34,6 +34,20 @@ function parseFields(fields?: string): string[] | undefined { return parsed && parsed.length > 0 ? parsed : undefined; } +interface ValidPager { + total: number; + page: number; + recPerPage: number; +} + +function isValidPager(value: unknown, expectedPage: number): value is ValidPager { + if (!value || typeof value !== 'object') return false; + const pager = value as Partial; + return pager.page === expectedPage + && Number.isInteger(pager.total) && Number(pager.total) >= 0 + && Number.isInteger(pager.recPerPage) && Number(pager.recPerPage) > 0; +} + /** * 执行模块级 CRUD 或扩展操作。 * @@ -53,10 +67,16 @@ export async function executeModuleCommand( if (!action) { throw new ZentaoError('E2005', { module: module.name }); } - if (options.all) { + if (options.all && action.type !== 'list') { throw new ZentaoError('E2009', { option: 'all', - reason: '尚未支持自动翻页,请使用 --page 和 --recPerPage 逐页获取', + reason: '仅列表操作支持自动翻页', + }); + } + if (options.all && options.page !== undefined) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '不能与 --page 同时使用', }); } @@ -64,33 +84,131 @@ export async function executeModuleCommand( const requestName = `${module.name}/${normalizeActionName(actionName)}`; const fields = parseFields(options.pick); const rawOutput = (options.format ?? config.defaultOutputFormat ?? 'markdown') === 'raw'; + if (options.all && rawOutput) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '不能与 --format=raw 同时使用', + }); + } + if (options.all) { + const paramNames = new Set((action.params ?? []).map((param) => param.name)); + if (!paramNames.has('pageID') || !paramNames.has('recPerPage')) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '当前列表操作不支持分页', + }); + } + delete params.page; + params.pageID = 1; + } const shouldProcess = !rawOutput; const processList = shouldProcess && action.type === 'list'; const processSingle = shouldProcess && action.type === 'get'; + const requestOptions = { + client, + autoFill: action.type === 'update', + throwOnFail: true, + recPerPage: options.recPerPage, + timeout: options.timeout, + insecure: options.insecure, + }; + let response; try { response = await request(requestName, params, { - client, - autoFill: action.type === 'update', - throwOnFail: true, - recPerPage: options.recPerPage, - timeout: options.timeout, - insecure: options.insecure, + ...requestOptions, raw: rawOutput, - convert: processList && config.htmlToMarkdown !== false + convert: processList && !options.all && config.htmlToMarkdown !== false ? convertHtmlFieldsInArray : undefined, convertSingle: processSingle && config.htmlToMarkdown !== false ? convertHtmlFields : undefined, - filter: processList ? options.filter : undefined, - search: processList ? options.search : undefined, - searchFields: processList ? parseFields(options.searchFields) : undefined, - sort: processList ? options.sort : undefined, - limit: processList ? options.limit : undefined, - pick: processList || processSingle ? fields : undefined, + filter: processList && !options.all ? options.filter : undefined, + search: processList && !options.all ? options.search : undefined, + searchFields: processList && !options.all ? parseFields(options.searchFields) : undefined, + sort: processList && !options.all ? options.sort : undefined, + limit: processList && !options.all ? options.limit : undefined, + pick: !options.all && (processList || processSingle) ? fields : undefined, }); + + if (options.all) { + if (!isValidPager(response.pager, 1) || !Array.isArray(response.data)) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '服务端响应缺少有效的分页信息', + }); + } + + const records = [...response.data] as Record[]; + let pager = response.pager; + const expectedTotal = pager.total; + const expectedPageSize = pager.recPerPage; + if (records.length === 0 && pager.total > 0) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '第 1 页为空,无法确认已获取全部数据', + }); + } + if (records.length > expectedTotal) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '服务端返回的记录数超过分页总数', + }); + } + + while (records.length < expectedTotal) { + const nextPage = pager.page + 1; + const pageResponse = await request(requestName, { ...params, pageID: nextPage }, requestOptions); + if (!isValidPager(pageResponse.pager, nextPage) || !Array.isArray(pageResponse.data)) { + throw new ZentaoError('E2009', { + option: 'all', + reason: `第 ${nextPage} 页响应缺少有效的分页信息`, + }); + } + if (pageResponse.pager.total !== expectedTotal + || pageResponse.pager.recPerPage !== expectedPageSize) { + throw new ZentaoError('E2009', { + option: 'all', + reason: `第 ${nextPage} 页的分页总数或页大小发生变化`, + }); + } + if (pageResponse.data.length === 0) { + throw new ZentaoError('E2009', { + option: 'all', + reason: `第 ${nextPage} 页为空,无法确认已获取全部数据`, + }); + } + records.push(...pageResponse.data as Record[]); + pager = pageResponse.pager; + } + if (records.length !== expectedTotal) { + throw new ZentaoError('E2009', { + option: 'all', + reason: '服务端返回的记录数与分页总数不一致', + }); + } + + const data = processData(records, { + convert: config.htmlToMarkdown !== false ? convertHtmlFieldsInArray : undefined, + filter: options.filter, + search: options.search, + searchFields: parseFields(options.searchFields), + sort: options.sort, + limit: options.limit, + pick: fields, + }); + response = { + ...response, + data, + pager: { + total: expectedTotal, + page: 1, + recPerPage: expectedTotal > 0 ? expectedTotal : expectedPageSize, + }, + }; + } } catch (error) { throw mapSdkError(error); } diff --git a/src/types/commands.ts b/src/types/commands.ts index 1065bf9..7fcf663 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -44,7 +44,7 @@ export interface ModuleActionOptions extends GlobalOptions { /** 每页条数,适用于 resultType 为 list 的模块操作 */ recPerPage?: string; - /** Reserved option that currently fails explicitly instead of implying automatic pagination. */ + /** 是否自动翻页获取全部列表数据 */ all?: boolean; /** 限制获取数量,适用于 resultType 为 list 的模块操作 */ diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 47099f9..4efa3ff 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -129,23 +129,207 @@ describe('module executor (zentao-api request pipeline)', () => { expect(result.data).toEqual([{ id: 1, desc: '

Hello

' }]); }); - test('rejects the reserved --all option instead of silently returning one page', async () => { + test('fetches every page before applying list processing once', async () => { + const pages = [ + [ + { id: 1, status: 'active', pri: 1, desc: '

One

' }, + { id: 2, status: 'closed', pri: 5, desc: '

Two

' }, + ], + [ + { id: 3, status: 'active', pri: 4, desc: '

Three

' }, + { id: 4, status: 'active', pri: 2, desc: '

Four

' }, + ], + [{ id: 5, status: 'active', pri: 3, desc: '

Five

' }], + ]; + const { client, requests } = mockClient((req) => { + const query = req.options.query as Record; + const pageID = Number(query.pageID); + return { + status: 'success', + products: pages[pageID - 1], + pager: { recTotal: 5, recPerPage: 2, pageID }, + }; + }); + + const result = await executeModuleCommand( + client, + getModule('product')!, + 'list', + [], + { + all: true, + filter: ['status=active'], + sort: 'pri:desc', + limit: '2', + pick: 'id,desc', + }, + DEFAULT_CONFIG, + ); + + expect(requests.map((req) => Number((req.options.query as Record).pageID))) + .toEqual([1, 2, 3]); + expect(result.data).toEqual([ + { id: 3, desc: 'Three' }, + { id: 5, desc: 'Five' }, + ]); + expect(result.pager).toEqual({ recTotal: 5, recPerPage: 5, pageID: 1 }); + }); + + test('rejects --all with an explicit page before sending a request', async () => { + const { client, requests } = mockClient(() => ({ status: 'success' })); + + await expect(executeModuleCommand( + client, + getModule('product')!, + 'list', + [], + { all: true, page: '2' }, + DEFAULT_CONFIG, + )).rejects.toThrow('不能与 --page 同时使用'); + expect(requests).toHaveLength(0); + }); + + test('handles an empty paginated list without requesting another page', async () => { const { client, requests } = mockClient(() => ({ status: 'success', - products: [{ id: 1 }], + products: [], + pager: { recTotal: 0, recPerPage: 20, pageID: 1 }, })); + const result = await executeModuleCommand( + client, + getModule('product')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + ); + + expect(requests).toHaveLength(1); + expect(result.data).toEqual([]); + expect(result.pager).toEqual({ recTotal: 0, recPerPage: 20, pageID: 1 }); + }); + + test('rejects --all in raw mode before sending a request', async () => { + const { client, requests } = mockClient(() => ({ status: 'success' })); + await expect(executeModuleCommand( client, getModule('product')!, 'list', [], + { all: true, format: 'raw' }, + DEFAULT_CONFIG, + )).rejects.toThrow('不能与 --format=raw 同时使用'); + expect(requests).toHaveLength(0); + }); + + test('rejects --all for non-list and unpaged actions', async () => { + const { client, requests } = mockClient(() => ({ status: 'success' })); + + await expect(executeModuleCommand( + client, + getModule('user')!, + 'get', + ['1'], { all: true }, DEFAULT_CONFIG, - )).rejects.toThrow('尚未支持自动翻页'); + )).rejects.toThrow('仅列表操作支持自动翻页'); + await expect(executeModuleCommand( + client, + getModule('release')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + )).rejects.toThrow('当前列表操作不支持分页'); expect(requests).toHaveLength(0); }); + test('fails instead of returning partial data when a later page fails', async () => { + const { client, requests } = mockClient((req) => { + const pageID = Number((req.options.query as Record).pageID); + if (pageID === 2) throw new Error('page 2 failed'); + return { + status: 'success', + products: [{ id: 1 }], + pager: { recTotal: 2, recPerPage: 1, pageID }, + }; + }); + + await expect(executeModuleCommand( + client, + getModule('product')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + )).rejects.toThrow('page 2 failed'); + expect(requests).toHaveLength(2); + }); + + test('rejects missing or stalled pagination metadata', async () => { + const missing = mockClient(() => ({ status: 'success', products: [{ id: 1 }] })); + await expect(executeModuleCommand( + missing.client, + getModule('product')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + )).rejects.toThrow('缺少有效的分页信息'); + + const stalled = mockClient((req) => { + const pageID = Number((req.options.query as Record).pageID); + return { + status: 'success', + products: pageID === 1 ? [{ id: 1 }] : [], + pager: { recTotal: 2, recPerPage: 1, pageID }, + }; + }); + await expect(executeModuleCommand( + stalled.client, + getModule('product')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + )).rejects.toThrow('第 2 页为空'); + }); + + test('rejects pagination totals that change or do not match returned data', async () => { + const changing = mockClient((req) => { + const pageID = Number((req.options.query as Record).pageID); + return { + status: 'success', + products: [{ id: pageID }], + pager: { recTotal: pageID === 1 ? 2 : 1, recPerPage: 1, pageID }, + }; + }); + await expect(executeModuleCommand( + changing.client, + getModule('product')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + )).rejects.toThrow('分页总数或页大小发生变化'); + + const overflow = mockClient(() => ({ + status: 'success', + products: [{ id: 1 }, { id: 2 }], + pager: { recTotal: 1, recPerPage: 2, pageID: 1 }, + })); + await expect(executeModuleCommand( + overflow.client, + getModule('product')!, + 'list', + [], + { all: true }, + DEFAULT_CONFIG, + )).rejects.toThrow('记录数超过分页总数'); + }); + test('returns the original API response in raw mode without local processing', async () => { const { client } = mockClient(() => ({ status: 'success', diff --git a/tests/mcp-e2e.test.ts b/tests/mcp-e2e.test.ts index a7cd0b3..b5b9f22 100644 --- a/tests/mcp-e2e.test.ts +++ b/tests/mcp-e2e.test.ts @@ -38,6 +38,7 @@ describe('MCP server (stdio e2e smoke)', () => { const bugTool = tools.find(t => t.name === 'zentao_bug'); expect(bugTool?.annotations?.readOnlyHint).toBe(false); expect(bugTool?.annotations?.destructiveHint).toBe(true); + expect(bugTool?.inputSchema?.properties).toHaveProperty('all'); } finally { await client.close(); } diff --git a/tests/skill-contract.test.ts b/tests/skill-contract.test.ts index f970bd3..09dc52d 100644 --- a/tests/skill-contract.test.ts +++ b/tests/skill-contract.test.ts @@ -158,6 +158,8 @@ function allowedOptions(module: ModuleDefinition, action: ModuleAction): Set param.name === 'pageID')) allowed.add('page'); if (params.some((param) => param.name === 'recPerPage')) allowed.add('recPerPage'); + if (params.some((param) => param.name === 'pageID') + && params.some((param) => param.name === 'recPerPage')) allowed.add('all'); if (params.some((param) => param.role === 'path' && param.name.endsWith('ID'))) allowed.add('id'); if (params.some((param) => param.role === 'path' && param.name === 'scope')) { for (const scope of ['product', 'project', 'execution']) { @@ -254,7 +256,7 @@ describe('bundled Skill command contracts', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('--limit '); - expect(result.stdout).not.toContain('--all'); + expect(result.stdout).toContain('--all'); expect(result.stdout).toContain('zentao bug <操作> --help'); const unpaged = await runCliWithoutAuth(['release', '--help']);