diff --git a/muse-admin/apps/web-antd/e2e/global-setup.ts b/muse-admin/apps/web-antd/e2e/global-setup.ts index cb3023f0..04df6680 100644 --- a/muse-admin/apps/web-antd/e2e/global-setup.ts +++ b/muse-admin/apps/web-antd/e2e/global-setup.ts @@ -72,6 +72,9 @@ async function globalSetup(): Promise { // 复位 market 写命令子域 fixture(详见函数注释)。 await resetMarketFixture(client); + + // 复位 jobs/source-event 写命令子域 fixture(详见函数注释)。 + await resetJobsFixture(client); } finally { await client.end(); } @@ -181,4 +184,113 @@ async function resetMarketFixture(client: Client): Promise { ); } +/** jobs/source-event e2e 锚点 id(高位固定 id,避开切片库基础种子的 1~29)。 */ +const JOB_RETRY_FIXTURE_ID = 90_001; +const JOB_CANCEL_FIXTURE_ID = 90_002; +const SOURCE_EVENT_FIXTURE_ID = 90_003; +/** 重试 fixture 的 retry group;重试命令派生子 job 会复用同一 group,reset 据此清理上一轮派生行。 */ +const JOB_RETRY_FIXTURE_GROUP = 'retry-group-e2e-fixture-retry'; +/** 来源事件 fixture 的业务事件 id;来源重试派生的传播 job 在 input_summary.sourceEventId 里带它,reset 据此清理。 */ +const SOURCE_EVENT_FIXTURE_EVENT_ID = 'evt-e2e-fixture-src'; + +/** + * 复位 jobs/source-event 子域 e2e fixture(幂等、tenant_id=1 限定、直连真实 muse_ai_* 表)。 + * + * 这三条命令(任务重试 / 任务取消 / 来源传播重试)后端都是纯 DB 状态机、无外部副作用(已活体核验: + * 合法输入 code:0、非法输入返回业务错误码而非 500),但切片库基础种子里没有可命令的行 + * (jobs 现有行全部 retryable=false,source-event 表为空),故本前置造稳定锚点。 + * + * 锚点设计: + * - 任务重试锚点 id=90001:status=failed + retryable=true,input_summary 不含 userInstructionLength/Hash + * (否则后端要求 runtime payload 行,缺则重试被拒)。e2e 真打 retry → 后端**新插**一行 + * operation_id=adminRetryJob、retry_group_id 同组的 queued job;反查据此断言"本轮真的派生了重试 job"。 + * - 任务取消锚点 id=90002:status=**running**(刻意不用 queued)。切片库有后台 AI runtime worker 会在 ~3s 内 + * 把 queued job 流转成 failed(实测),而 e2e 导航+渲染+填表+提交远超 3s,queued 锚点会在点取消前被 worker + * 抢成 failed → 取消按钮 disabled 假红。running 状态 worker 不接管(视作已有 worker 持有),实测长期稳定, + * 且前端 canCancelJob 同样放行 running。e2e 真打 cancel → 后端把该行改成 cancelled;反查据此断言。 + * - 来源事件锚点 id=90003:action_policy=needs_recheck(前端 canRetrySourceEvent 只放行此策略)。e2e 真打 + * 来源重试 → 后端**新插**一行 job_type=source_event_retry 的 queued job(不改 source_event 行);反查据此断言。 + * + * 为什么要先清派生行:重试 / 来源重试每轮都会 insert 一条新 job(retry group 唯一索引在子 job 流转成 + * 终态后即释放,故第二轮能再插);命令幂等表 muse_ai_command 也按 commandId 累积。前端每次用 + * `job-retry-${Date.now()}-…` 这类带时间戳的唯一 commandId,不会命中幂等回放,但派生 job 与命令行会越积越多, + * 让"本轮新增一行"反查计数失真。故 reset 先按锚点专属标记删净上一轮派生 job + 命令行,再把三条锚点写回初态。 + * + * id 列是 GENERATED ALWAYS IDENTITY,显式写锚点 id 必须带 OVERRIDING SYSTEM VALUE。 + */ +async function resetJobsFixture(client: Client): Promise { + // 1) 清上一轮派生行:重试派生子 job(operation=adminRetryJob 且同 retry group;不动同组的源锚点 + // operation=createAiTask)、来源重试派生的传播 job(按 input_summary.sourceEventId 锚定)。 + const clearRetryChildren = await client.query( + `DELETE FROM muse_ai_job + WHERE tenant_id = 1 AND operation_id = 'adminRetryJob' AND retry_group_id = $1`, + [JOB_RETRY_FIXTURE_GROUP], + ); + const clearSourceRetryJobs = await client.query( + `DELETE FROM muse_ai_job + WHERE tenant_id = 1 AND job_type = 'source_event_retry' + AND input_summary->>'sourceEventId' = $1`, + [SOURCE_EVENT_FIXTURE_EVENT_ID], + ); + // 2) 清这三条锚点相关的命令幂等行(按 target 精确限定,避免误删其他命令审计)。 + const clearCommands = await client.query( + `DELETE FROM muse_ai_command + WHERE tenant_id = 1 + AND ((operation_id IN ('adminRetryJob', 'adminCancelJob') AND target_id IN ($1, $2)) + OR (operation_id = 'adminRetrySourceEvent' AND target_id = $3))`, + [JOB_RETRY_FIXTURE_ID, JOB_CANCEL_FIXTURE_ID, SOURCE_EVENT_FIXTURE_ID], + ); + + // 3) 重置三条锚点为初态(先删后插,保证 input_summary / status / retryable 等列回到期望值)。 + await client.query(`DELETE FROM muse_ai_job WHERE id IN ($1, $2)`, [ + JOB_RETRY_FIXTURE_ID, + JOB_CANCEL_FIXTURE_ID, + ]); + await client.query(`DELETE FROM muse_ai_source_event WHERE id = $1`, [ + SOURCE_EVENT_FIXTURE_ID, + ]); + // 任务重试锚点:failed + retryable,input_summary 必须"干净"(不含用户指令长度/哈希)。 + await client.query( + `INSERT INTO muse_ai_job + (id, job_id, job_type, operation_id, actor_user_id, owner_user_id, status, retry_count, + retryable, retry_group_id, correlation_id, input_summary, tenant_id, creator, updater, + deleted, create_time, update_time) + OVERRIDING SYSTEM VALUE + VALUES ($1, 'job-e2e-fixture-retry', 'ai_task_runtime', 'createAiTask', 1, 1, 'failed', 0, + true, $2, 'corr-e2e-fixture-retry', '{"e2eFixture": true, "taskType": "continuation"}'::jsonb, + 1, '', '', false, now(), now())`, + [JOB_RETRY_FIXTURE_ID, JOB_RETRY_FIXTURE_GROUP], + ); + // 任务取消锚点:running(避开 worker 把 queued 抢成 failed)。 + await client.query( + `INSERT INTO muse_ai_job + (id, job_id, job_type, operation_id, actor_user_id, owner_user_id, status, retry_count, + retryable, correlation_id, input_summary, tenant_id, creator, updater, + deleted, create_time, update_time, started_at) + OVERRIDING SYSTEM VALUE + VALUES ($1, 'job-e2e-fixture-cancel', 'ai_task_runtime', 'createAiTask', 1, 1, 'running', 0, + false, 'corr-e2e-fixture-cancel', '{"e2eFixture": true}'::jsonb, + 1, '', '', false, now(), now(), now())`, + [JOB_CANCEL_FIXTURE_ID], + ); + // 来源事件锚点:needs_recheck 策略,stale 状态。 + await client.query( + `INSERT INTO muse_ai_source_event + (id, source_event_id, source_owner, source_type, source_id, source_revision, owner_user_id, + status, action_policy, event_summary, tenant_id, creator, updater, deleted, + create_time, update_time) + OVERRIDING SYSTEM VALUE + VALUES ($1, $2, 'knowledge', 'knowledge_base', '777', '3', 1, 'stale', 'needs_recheck', + '{"affectedScope": "e2e fixture scope", "affectedBindings": 2, "propagationStatus": "pending"}'::jsonb, + 1, '', '', false, now(), now())`, + [SOURCE_EVENT_FIXTURE_ID, SOURCE_EVENT_FIXTURE_EVENT_ID], + ); + + console.log( + `[admin e2e globalSetup] reset jobs fixture: clearRetryChildren=${clearRetryChildren.rowCount} ` + + `clearSourceRetryJobs=${clearSourceRetryJobs.rowCount} clearCommands=${clearCommands.rowCount} ` + + `(已写回锚点 job ${JOB_RETRY_FIXTURE_ID}/${JOB_CANCEL_FIXTURE_ID}、source-event ${SOURCE_EVENT_FIXTURE_ID})`, + ); +} + export default globalSetup; diff --git a/muse-admin/apps/web-antd/e2e/muse-admin-governance.spec.ts b/muse-admin/apps/web-antd/e2e/muse-admin-governance.spec.ts index 598a4795..c0125778 100644 --- a/muse-admin/apps/web-antd/e2e/muse-admin-governance.spec.ts +++ b/muse-admin/apps/web-antd/e2e/muse-admin-governance.spec.ts @@ -75,6 +75,55 @@ async function queryAppealFacts( }); } +// jobs/source-event 子域 e2e 锚点 id(与 global-setup resetJobsFixture 保持一致)。 +const JOB_RETRY_FIXTURE_ID = 90_001; +const JOB_CANCEL_FIXTURE_ID = 90_002; +const SOURCE_EVENT_FIXTURE_ID = 90_003; +const JOB_RETRY_FIXTURE_GROUP = 'retry-group-e2e-fixture-retry'; +const SOURCE_EVENT_FIXTURE_EVENT_ID = 'evt-e2e-fixture-src'; + +/** 读取某个 muse_ai_job 的真实状态(tenant_id=1),用于取消命令后的 DB 反查。 */ +async function queryJobStatus(jobId: number): Promise { + return withMarketDb(async (client) => { + const result = await client.query( + `SELECT status FROM muse_ai_job WHERE tenant_id = 1 AND deleted = false AND id = $1`, + [jobId], + ); + return result.rows[0]?.status ?? ''; + }); +} + +/** + * 统计某 retry group 下"重试派生子 job"行数(operation_id=adminRetryJob,tenant_id=1)。 + * globalSetup 已清上一轮派生行,故真打一次重试后此计数应为 1,用于"重试真的派生了一条新 job"DB 断言。 + */ +async function countAdminRetryChildJobs(retryGroupId: string): Promise { + return withMarketDb(async (client) => { + const result = await client.query( + `SELECT count(*)::int AS c FROM muse_ai_job + WHERE tenant_id = 1 AND operation_id = 'adminRetryJob' AND retry_group_id = $1`, + [retryGroupId], + ); + return result.rows[0]?.c ?? 0; + }); +} + +/** + * 统计某来源事件派生的"来源传播重试 job"行数(job_type=source_event_retry,tenant_id=1)。 + * 按 input_summary.sourceEventId 锚定;globalSetup 已清上一轮派生行,真打一次后应为 1。 + */ +async function countSourceRetryJobs(sourceEventId: string): Promise { + return withMarketDb(async (client) => { + const result = await client.query( + `SELECT count(*)::int AS c FROM muse_ai_job + WHERE tenant_id = 1 AND job_type = 'source_event_retry' + AND input_summary->>'sourceEventId' = $1`, + [sourceEventId], + ); + return result.rows[0]?.c ?? 0; + }); +} + // 运行时确认的真实 Pinia 持久化 key:namespace=`${VITE_APP_NAMESPACE}-${VITE_APP_VERSION}-${env}`, // 而 VITE_APP_NAMESPACE 未在任何 .env 定义 → 运行时取值为字符串 'undefined';VITE_APP_VERSION=package.json 版本 5.7.0; // dev 环境 env='dev';persist 后缀 store id='core-access'(见 packages/stores setup.ts `${namespace}-${storeKey}`)。 @@ -83,10 +132,6 @@ const ACCESS_STORE_KEY = 'undefined-5.7.0-dev-core-access'; const runtimeIssuesByPage = new WeakMap(); -// 暂 skip 的其他子域(jobs/market/governance/ai/knowledge)用例仍引用此写命令 payload 收集器以编译; -// 这些子域转真后端时再恢复填充逻辑(届时改为真 POST 后从 DB/响应核验,而非 mock 拦截 payload)。 -const museAdminPayloadsByPage = new WeakMap[]>(); - const isIgnoredRuntimeIssue = (message: string) => /ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i.test( message, @@ -259,71 +304,91 @@ test('账号治理页对真后端 account 子域渲染脱敏摘要', async ({ pa expectNoUnhandledMuseAdminApis(page); }); -test.skip('任务重试只发送当前 OpenAPI 支持的 commandId', async ({ page }) => { +// 任务重试真命令(转真后端 + 真 PG 反查)。 +// 锚点失败任务 90001(globalSetup 复位:status=failed、retryable=true、input_summary 不含用户指令哈希)。 +// 真打重试 → 后端校验 retryable 后新插一条 operation_id=adminRetryJob、同 retry group 的 queued job。 +// 反假绿核心:真 PG 反查"本轮真的派生了一条重试子 job"(不只看 UI toast)。 +// +// 契约差异(mock vs 真):retry 端点请求体只接受 { commandId }(JobRetryReqVO 仅 commandId 字段), +// reason 仅作 UI 人工复核留痕、不外发——与前端 submitJobAction 一致;旧 skip 用例靠 mock 拦截 payload +// 断言这一点,现改为真打 + DB 派生行反查,等价且更强(真后端真的接受了该请求并落库)。 +test('任务重试对锚点失败任务真打命令并派生重试 job', async ({ page }) => { + // 前置真值核验:锚点 90001 必须是 failed(globalSetup 已复位),否则前端"重试"按钮逻辑前提不成立。 + expect(await queryJobStatus(JOB_RETRY_FIXTURE_ID)).toBe('failed'); + await page.goto('/muse/jobs'); - await page - .getByRole('row', { name: /1001/ }) - .getByRole('button', { name: '重试' }) - .click(); + + // 锚点失败任务行(按 jobId 定位)。点"重试"前前端会先拉详情确认 retryable=true 才开弹窗。 + const jobRow = page.getByRole('row', { name: new RegExp(`${JOB_RETRY_FIXTURE_ID}`) }); + await jobRow.getByRole('button', { name: '重试' }).click(); + + // 重试前必须完成四项重验勾选 + 填可审计原因(前端硬门禁)。 await page.getByLabel('owner 和当前操作者权限已重验').check(); await page.getByLabel('来源状态、市场状态和版本已重验').check(); await page.getByLabel('授权快照和运行权限包已重验').check(); await page .getByLabel('幂等键、retry group 和旧上下文复用风险已重验') .check(); - await page - .locator('.ant-modal textarea') - .fill('人工复核留痕,不发送到 retry 接口'); + await page.locator('.ant-modal textarea').fill('e2e 真后端重试,已完成四项重验'); await page.locator('.ant-modal-footer .ant-btn-primary').click(); - const retryPayload = museAdminPayloadsByPage - .get(page) - ?.find( - (item) => item.method === 'POST' && item.path === '/jobs/1001/retry', - )?.payload; + // UI 反馈:成功 toast(命令 code:0)。 + await expect(page.getByText('任务重试命令已提交')).toBeVisible(); + + // 反假绿核心:真 PG 反查本轮真的派生了一条 adminRetryJob 子 job(后台 worker 随后可能把它流转成其他终态, + // 但 operation_id/retry_group_id 标记不变,故按"存在一条派生行"断言,不对其后续 status 较真)。 + await expect + .poll(() => countAdminRetryChildJobs(JOB_RETRY_FIXTURE_GROUP), { + timeout: 10_000, + }) + .toBe(1); - expect(retryPayload).toEqual({ - commandId: expect.stringMatching(/^job-retry-/), - }); expectNoUnhandledMuseAdminApis(page); }); -test.skip('任务取消和来源传播重试提交 commandId 与 reason', async ({ page }) => { +// 任务取消 + 来源传播重试真命令(转真后端 + 真 PG 反查)。 +// 取消锚点 90002(globalSetup 复位为 running——刻意非 queued:切片库后台 worker 会在 ~3s 内把 queued 抢成 +// failed,e2e 操作耗时远超 3s,running 状态 worker 不接管、长期稳定,且前端 canCancelJob 同样放行 running)。 +// 来源事件锚点 90003(action_policy=needs_recheck,前端 canRetrySourceEvent 只放行此策略)。 +// +// 契约差异(mock vs 真):cancel 端点接受 { commandId, reason }(JobCancelReqVO 两字段);source-event retry +// 接受 { commandId, reason }(SourceEventRetryReqVO,commandId @NotBlank)。旧 skip 用例靠 mock 拦截 payload +// 断言字段,现改为真打 + DB 反查(取消落 status=cancelled;来源重试派生 source_event_retry job),等价且更强。 +test('任务取消与来源传播重试真打命令并落库', async ({ page }) => { + // 前置真值核验:取消锚点 90002 必须是 running(globalSetup 已复位),前端"取消"按钮才放行。 + expect(await queryJobStatus(JOB_CANCEL_FIXTURE_ID)).toBe('running'); + await page.goto('/muse/jobs'); - await page - .getByRole('row', { name: /1002/ }) - .getByRole('button', { name: '取消' }) - .click(); + + // 取消锚点运行中任务(按 jobId 定位)。cancel 弹窗无需重验勾选,只需可审计原因。 + const jobRow = page.getByRole('row', { name: new RegExp(`${JOB_CANCEL_FIXTURE_ID}`) }); + await jobRow.getByRole('button', { name: '取消' }).click(); const jobActionDialog = page.getByRole('dialog', { name: '任务治理操作' }); - await jobActionDialog.locator('textarea').fill('上游召回,停止排队任务'); + await jobActionDialog.locator('textarea').fill('e2e 真后端取消运行中任务'); await jobActionDialog.locator('.ant-modal-footer .ant-btn-primary').click(); + // UI 反馈 + 真 PG 反查:任务真的落为 cancelled 终态。 + await expect(page.getByText('任务取消命令已提交')).toBeVisible(); + await expect + .poll(() => queryJobStatus(JOB_CANCEL_FIXTURE_ID), { timeout: 10_000 }) + .toBe('cancelled'); + + // 切到来源事件 tab,对 needs_recheck 锚点事件真打传播重试。 await page.getByRole('tab', { name: '来源事件' }).click(); - await page - .getByRole('row', { name: /2001/ }) - .getByRole('button', { name: '重试传播' }) - .click(); + const sourceRow = page.getByRole('row', { name: new RegExp(`${SOURCE_EVENT_FIXTURE_ID}`) }); + await sourceRow.getByRole('button', { name: '重试传播' }).click(); const sourceRetryDialog = page.getByRole('dialog', { name: '重试来源传播' }); - await sourceRetryDialog.locator('textarea').fill('来源状态已复核,重新传播'); + await sourceRetryDialog.locator('textarea').fill('e2e 来源状态已复核,重新传播'); await sourceRetryDialog.locator('.ant-modal-footer .ant-btn-primary').click(); - const payloads = museAdminPayloadsByPage.get(page) ?? []; - const cancelPayload = payloads.find( - (item) => item.method === 'POST' && item.path === '/jobs/1002/cancel', - )?.payload; - const sourceRetryPayload = payloads.find( - (item) => - item.method === 'POST' && item.path === '/source-events/2001/retry', - )?.payload; + // UI 反馈 + 真 PG 反查:本轮真的派生了一条 source_event_retry job(后端不改 source-event 行,只投影传播 job)。 + await expect(page.getByText('来源传播重试已提交')).toBeVisible(); + await expect + .poll(() => countSourceRetryJobs(SOURCE_EVENT_FIXTURE_EVENT_ID), { + timeout: 10_000, + }) + .toBe(1); - expect(cancelPayload).toEqual({ - commandId: expect.stringMatching(/^job-cancel-/), - reason: '上游召回,停止排队任务', - }); - expect(sourceRetryPayload).toEqual({ - commandId: expect.stringMatching(/^source-retry-/), - reason: '来源状态已复核,重新传播', - }); expectNoUnhandledMuseAdminApis(page); }); @@ -453,6 +518,19 @@ test('市场申诉以 maintained 结论真打处理命令并落库', async ({ pa expectNoUnhandledMuseAdminApis(page); }); +// 断层标注(诚实记录,保留 skip):这条"核心治理配置全页巡航"用例当前**不能**转真后端。 +// 它的断言混了两类:① 系统边界静态文案(各页 chrome,与数据无关,真后端能渲染);② 一批 mock 期数据 +// 锚点——story.draft 元结构、"故事链路"功能链路、"事实校验"保护节点、"候选生成 Prompt"、"系统故事智能体"、 +// "系统世界观库"、new-api 的 user-e2e/usage-e2e。这些数据锚点在 muse_slice_live **不存在**,且其中: +// - 功能链路 / 保护节点:切片库**根本没有 muse_function_chain / muse_protection_node 表** +// (端点真返 total=0 仅因无后端支撑表),"故事链路""事实校验"断言无表可灌,无法真连; +// - AI Prompt(/ai/prompts total=0)、全局知识库(/knowledge/global-kbs total=0):切片库未灌对应种子; +// - 元结构现有 worldview/setting 而非 story.draft;系统智能体现有"活体测试智能体"而非"系统故事智能体"。 +// 用例还做数据依赖的交互(点"查看详情"跳 story.draft、点"保护节点"看"事实校验"、切 tab 看"系统故事智能体"), +// 这些行在缺数据(且保护节点/功能链路缺表)时无法定位 → 无法硬转。faithfully 转需跨 6 个子系统伪造数据, +// 远超本批"3 个 skip 转真连"范围,且功能链路/保护节点连表都没有、本质是后端切片支撑断层。 +// 故保留 skip。account/market/jobs/source-event 子域的系统边界已分别由各自真连用例覆盖;此页"全页巡航" +// 待后端补齐功能链路/保护节点表 + 切片灌全 AI Prompt/全局知识库种子后再转真连。下方 body 留作"完整覆盖应断言什么"的记录。 test.skip('管理员可进入核心治理配置页面并看到系统边界', async ({ page }) => { await page.goto('/muse/governance/meta-schemas'); await expect(page.getByText('元结构定义').first()).toBeVisible();