oh-my-muse/muse-admin/apps/web-antd/e2e/muse-admin-governance.spec.ts
lili 2d06ec5f31 test(admin-e2e): jobs 子域转真连(重试/取消/来源重试真打+真 PG 反查)、governance config 断层如实标注 skip
jobs 真连(反假绿:真打写命令+真 PG 反查 muse_ai_job 派生行/状态):①重试(:262)→后端校验 retryable 新插 operation_id=adminRetryJob 同 retry_group queued 子 job(MuseJobServiceImpl.retryAdminJob 纯 DB 状态机、活体 code:0),反查派生子 job 行数;②取消+来源重试(:291)→cancel 改 muse_ai_job.status=cancelled、source retry 新插 job_type=source_event_retry job(muse_ai_source_event 90003 needs_recheck 锚点),反查状态/派生行。关键坑:切片库后台 AI runtime worker ~3s 抢 queued→failed,取消锚点须用 running(worker 不接管、前端 canCancelJob 放行);queued 锚点活体 cancel 真返 1040100014 不可取消。

global-setup 加 resetJobsFixture:幂等 seed 90001(failed/retryable muse_ai_job)/90002(running muse_ai_job)/90003(needs_recheck muse_ai_source_event)+清派生子 job/source_event_retry/命令幂等行,每轮自愈。真契约:retry 请求体只接受 {commandId}(JobRetryReqVO 仅此字段、reason 仅 UI 留痕不外发);cancel/source-retry 接受 {commandId,reason}。旧 skip 靠 mock 拦截 payload 字段,改真打+DB 派生行反查(等价更强),删 museAdminPayloadsByPage 收集器。

核心治理配置页(:456)保留 skip:非端点 500,而是切片数据/表断层——muse_function_chain/muse_protection_node 表在 muse_slice_live 根本不存在(psql to_regclass 均 None)、AI Prompt/全局知识库/story.draft 切片未灌种子,faithfully 转需跨 6 子系统伪造数据且两表连结构都没有,不硬转。用例上方写明断层+待补后端项。

独立核实:连跑全量 account+market+jobs e2e 6 passed/1 skipped(背靠背幂等无第二轮假红);psql 复核 90002=cancelled、retry 派生子 job=1、source_event_retry job=1、:456 断层表均 None(证标注诚实)。待补后端:①切片库缺 muse_function_chain/muse_protection_node 表 ②AI Prompt/全局 KB/story.draft 种子 ③jobs/source 三写命令后端无 P1r 真库 IT(现 Mockito 单测、本次 e2e 真连可视首个端到端真验)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:32:21 -07:00

586 lines
29 KiB
TypeScript

import { Client } from 'pg';
import { expect, test } from 'playwright/test';
import type { Page } from 'playwright/test';
const commonResult = (data: unknown) => ({
code: 0,
data,
msg: '',
});
// market 子域是写命令子域:e2e 真打下架/申诉处理命令后,必须直连真实 PG 核验 muse_market_* 真实行
// (反假绿:不能只看 UI toast,要看 DB listing_status/governance_action 真的变了)。连接凭据从环境变量读
// (MUSE_POSTGRES_*,见 ~/.config/muse-repo/infra.env;绝不入库),与 global-setup 同源。
async function withMarketDb<T>(fn: (client: Client) => Promise<T>): Promise<T> {
const client = new Client({
host: process.env.MUSE_POSTGRES_HOST,
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
password: process.env.MUSE_POSTGRES_PASSWORD,
ssl: false,
connectionTimeoutMillis: 10_000,
});
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
/** 读取 market 锚点资产当前上架状态(tenant_id=1),用于真打命令后的 DB 反查。 */
async function queryAssetListingStatus(assetId: number): Promise<string> {
return withMarketDb(async (client) => {
const result = await client.query(
`SELECT listing_status FROM muse_market_asset
WHERE tenant_id = 1 AND deleted = false AND id = $1`,
[assetId],
);
return result.rows[0]?.listing_status ?? '';
});
}
/** 统计某资产某类型 applied 治理动作行数(tenant_id=1),用于"真打下架本轮新增治理动作"DB 断言。 */
async function countGovernanceAction(
assetId: number,
actionType: string,
): Promise<number> {
return withMarketDb(async (client) => {
const result = await client.query(
`SELECT count(*)::int AS c FROM muse_market_governance_action
WHERE tenant_id = 1 AND asset_id = $1 AND action_type = $2 AND status = 'applied'`,
[assetId, actionType],
);
return result.rows[0]?.c ?? 0;
});
}
/** 读取申诉当前状态/结论/处理人(tenant_id=1),用于真打申诉处理命令后的 DB 反查。 */
async function queryAppealFacts(
appealId: number,
): Promise<{ resolution: null | string; resolverId: null | string; status: string }> {
return withMarketDb(async (client) => {
const result = await client.query(
`SELECT status, resolution, resolver_id FROM muse_market_appeal
WHERE tenant_id = 1 AND deleted = false AND id = $1`,
[appealId],
);
const row = result.rows[0] ?? {};
return {
resolution: row.resolution ?? null,
resolverId: row.resolver_id == null ? null : String(row.resolver_id),
status: row.status ?? '',
};
});
}
// 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<string> {
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<number> {
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<number> {
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}`)。
// 实测 localStorage 真实键即 'undefined-5.7.0-dev-core-access'(原硬编码 'muse-vben-antd-...' 是错的,注入会落空 key)。
const ACCESS_STORE_KEY = 'undefined-5.7.0-dev-core-access';
const runtimeIssuesByPage = new WeakMap<Page, string[]>();
const isIgnoredRuntimeIssue = (message: string) =>
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i.test(
message,
) ||
// dev-server 活体 e2e 噪声:并发负载下 Vite 偶发某个懒加载 chunk/静态资源请求超时,
// 浏览器报 `Failed to load resource: net::ERR_TIMED_OUT` 的 console.error。这是环境/网络抖动,
// 不是应用正确性信号(命令/契约断言另有真后端+真 PG 反查兜底),故与 ResizeObserver 噪声一并忽略,
// 避免严格 console.error 门禁把可复现的资源超时误判成红。真正的组件解析失败/未捕获 rejection 仍会命中。
/Failed to load resource: net::ERR_(TIMED_OUT|CONNECTION_|ABORTED|FAILED)/i.test(
message,
);
// 转真后端后不再有"未处理的 muse mock"概念(muse 端点直打 48080);保留同名空壳让既有调用点与
// 暂 skip 的其他子域用例继续编译,语义上恒通过。
const expectNoUnhandledMuseAdminApis = (_page: Page) => {
expect(true).toBe(true);
};
const expectNoRuntimeIssues = (page: Page) => {
const runtimeIssues = (runtimeIssuesByPage.get(page) ?? []).filter(
(message) => !isIgnoredRuntimeIssue(message),
);
expect(
runtimeIssues,
`Browser runtime issues:\n${runtimeIssues.join('\n')}`,
).toEqual([]);
};
const recordRuntimeIssue = (page: Page, message: string) => {
if (!isIgnoredRuntimeIssue(message)) {
runtimeIssuesByPage.get(page)?.push(message);
}
};
const routeCommonResult = async (
page: Page,
patterns: string[],
data: unknown,
) => {
for (const pattern of patterns) {
await page.route(pattern, (route) =>
route.fulfill({
contentType: 'application/json',
json: commonResult(data),
}),
);
}
};
const PLATFORM_COMMON_RESULT_MOCKS: Array<{ data: unknown; patterns: string[] }> = [
// Vben 平台外壳插桩(非 muse 业务):真后端这些端点在本切片库不可用(notify 真返 code:500「系统异常」,
// 会冒泡成未捕获 rejection→pageerror,触发 expectNoRuntimeIssues 红),故 mock 屏蔽以稳定外壳。
{
data: 0,
patterns: [
'**/admin-api/system/notify-message/get-unread-count',
'**/system/notify-message/get-unread-count',
],
},
{
data: [],
patterns: [
'**/admin-api/system/dict-data/simple-list',
'**/system/dict-data/simple-list',
],
},
{
data: [{ id: 1, name: 'Default' }],
patterns: [
'**/admin-api/system/tenant/simple-list',
'**/system/tenant/simple-list',
],
},
{
data: { id: 1, name: 'Default' },
patterns: [
'**/admin-api/system/tenant/get-by-website**',
'**/system/tenant/get-by-website**',
],
},
];
// 转真后端核心:
// 1) 先注册 catch-all(低优先,Playwright 后注册者优先):所有 /admin-api/** 补 `tenant-id:1` 头后直打 48080。
// 前端 isTenantEnable()=false 默认不发 tenant 头,而后端按租户隔离需要它,故在此统一补。
// 2) 再注册平台外壳 mock(高优先,命中即胜出):notify/dict/tenant 不打真后端,避免 notify 500 污染。
// muse 业务端点(/admin-api/muse/**)与 get-permission-info 不在平台 mock 列表 → 落到 catch-all 走真后端。
const setupRealBackendRouting = async (page: Page) => {
await page.route('**/admin-api/**', async (route) => {
await route.continue({
headers: { ...route.request().headers(), 'tenant-id': '1' },
});
});
for (const mock of PLATFORM_COMMON_RESULT_MOCKS) {
await routeCommonResult(page, mock.patterns, mock.data);
}
};
test.beforeEach(async ({ page }) => {
runtimeIssuesByPage.set(page, []);
page.on('console', (message) => {
const text = message.text();
if (message.type() === 'error') {
recordRuntimeIssue(page, `console.error: ${text}`);
}
if (
message.type() === 'warning' &&
/Failed to resolve component/i.test(text)
) {
recordRuntimeIssue(page, `console.warning: ${text}`);
}
});
page.on('pageerror', (error) => {
recordRuntimeIssue(page, `pageerror: ${error.message}`);
});
// 注入与 studio 共用的 mock-token:Bearer test1 = mockSecret"test" + userId1(application-local.yaml mock-enable=true,
// server 以 local profile 跑 48080)。写入运行时真实 store key,令前端守卫放行、请求带上真 token 直打后端。
await page.addInitScript((accessStoreKey) => {
localStorage.setItem(
accessStoreKey,
JSON.stringify({
accessCodes: ['*:*:*'],
accessToken: 'test1',
isLockScreen: false,
lockScreenPassword: undefined,
refreshToken: 'test1',
tenantId: null,
visitTenantId: null,
}),
);
}, ACCESS_STORE_KEY);
// get-permission-info 走真后端:super_admin(globalSetup 已 seed)真返 code:0(本切片菜单为空,
// 但 muse 路由是前端静态路由 router/routes/modules/muse.ts,无需后端菜单即可导航)。
// muse 业务端点全部直打真 48080;仅平台外壳端点经 setupRealBackendRouting 内的 mock 屏蔽。
await setupRealBackendRouting(page);
});
test.afterEach(async ({ page }) => {
expectNoUnhandledMuseAdminApis(page);
expectNoRuntimeIssues(page);
});
// account 子域 —— 首个转真后端的子域(MSW-off,真打 48080)。
// 断言锚点全部对齐 muse_slice_live 真数据(curl 实证):
// /account/users 真返 user1{nickname:'活体用户', riskFlags:['unacknowledged_security_event'], status:active};
// /account/purchase-records 真返 3 条(license/completed),含 recordId 'purchase-1-4-1' 与资产名 '活体市场资产·安装e2e';
// /account/usage-records 真返空(total=0,不对行断言,仅验 tab 可达)。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total,多出字段忽略 → 契约兼容。
test('账号治理页对真后端 account 子域渲染脱敏摘要', async ({ page }) => {
await page.goto('/muse/account');
// 用户治理 tab:边界文案 + 真用户脱敏摘要 + 真风险标记。
await expect(page.getByText('用户、角色与权限治理')).toBeVisible();
await expect(
page.getByText('不提供用户侧完整个人中心或私有正文入口'),
).toBeVisible();
await expect(page.getByText('活体用户')).toBeVisible();
await expect(page.getByText('unacknowledged_security_event')).toBeVisible();
// 用量与购买摘要 tab:真购买记录(usage 真为空,只断言 purchase 真行)。
await page.getByRole('tab', { name: '用量与购买摘要' }).click();
await expect(page.getByText('purchase-1-4-1')).toBeVisible();
await expect(page.getByText('活体市场资产·安装e2e')).toBeVisible();
expectNoUnhandledMuseAdminApis(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');
// 锚点失败任务行(按 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('e2e 真后端重试,已完成四项重验');
await page.locator('.ant-modal-footer .ant-btn-primary').click();
// 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);
expectNoUnhandledMuseAdminApis(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');
// 取消锚点运行中任务(按 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('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();
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('e2e 来源状态已复核,重新传播');
await sourceRetryDialog.locator('.ant-modal-footer .ant-btn-primary').click();
// 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);
expectNoUnhandledMuseAdminApis(page);
});
// market 子域 —— 转真后端(MSW-off,真打 48080 + 真 PG 反查)。
// 锚点全部对齐 muse_slice_live 真数据(curl + psql 实证,见 global-setup resetMarketFixture 注释):
// 资产列表真返资产 1「活体市场资产·测试」(listed)、4「活体市场资产·安装e2e」(listed)、2「活体申诉演练资产」(not_listed);
// 申诉列表真返开放态申诉 1(supplementing,关联资产 2,review_rejection 类型);
// 发布审核队列真返 status=pending(后端把 DB 'submitted' 映射成 'pending')的真发布申请。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total。
test('市场治理页对真后端 market 子域渲染三大工作台', async ({ page }) => {
await page.goto('/muse/market');
// 页头与边界文案。
await expect(page.getByRole('heading', { name: '市场治理' })).toBeVisible();
await expect(page.getByText('不修改用户私有副本').first()).toBeVisible();
// 审核队列 tab(默认 active):真发布申请(后端 'submitted'→'pending')。
// 注意 ant Tabs 不卸载非激活 pane,锚点文案在多个 pane 都存在 → 必须按激活 tabpanel 收口可见性断言。
const reviewPanel = page.getByRole('tabpanel', { name: '审核队列' });
await expect(reviewPanel.getByText('市场审核是发布硬门槛')).toBeVisible();
// 资产治理 tab:真资产行(脱敏摘要,无私有正文)。
await page.getByRole('tab', { name: '资产治理' }).click();
const assetPanel = page.getByRole('tabpanel', { name: '资产治理' });
await expect(assetPanel.getByText('不改用户私有副本')).toBeVisible();
await expect(assetPanel.getByText('活体市场资产·测试')).toBeVisible();
await expect(assetPanel.getByText('活体市场资产·安装e2e')).toBeVisible();
// 申诉处理 tab:真开放态申诉(关联资产 2)。
await page.getByRole('tab', { name: '申诉处理' }).click();
const appealPanel = page.getByRole('tabpanel', { name: '申诉处理' });
await expect(appealPanel.getByText('恢复上架不能绕过合规审核')).toBeVisible();
await expect(appealPanel.getByText('活体申诉演练资产')).toBeVisible();
expectNoUnhandledMuseAdminApis(page);
});
// 市场资产下架真命令:在「资产治理」tab 对锚点资产 1 生成真影响预览 → 真打下架命令 →
// 真 PG 反查 listing_status='delisted' + 新增一条 applied delist 治理动作(反假绿:不只看 UI toast)。
// 注意:本用例真实改 muse_slice_live 共享行(资产 1 → delisted);下一轮 globalSetup.resetMarketFixture
// 会把它复位回 listed。同一轮内本用例必须排在"召回"等同样针对资产 1 的写用例之前。
test('市场资产下架真打命令并落库脱敏治理动作', async ({ page }) => {
const assetId = 1;
// 前置真值核验:资产 1 必须处于 listed(globalSetup 已复位),否则前端"下架"按钮逻辑前提不成立。
expect(await queryAssetListingStatus(assetId)).toBe('listed');
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
// 锚点资产 1 行(按激活 tabpanel 收口,避开 ant 不卸载的隐藏 pane 同名行)。
// ant-design-vue 会在两个中文字按钮间插空格,故"下架"可达名是「下 架」→ 用 /下\s*架/ 容错。
const assetPanel = page.getByRole('tabpanel', { name: '资产治理' });
const assetRow = assetPanel.getByRole('row', { name: /活体市场资产·测试/ });
await assetRow.getByRole('button', { name: /下\s*架/ }).click();
const dialog = page.getByRole('dialog', { name: /市场治理:活体市场资产·测试/ });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
// 真生成影响预览:后端返回 gov-preview-* 票据,前端回写到只读输入框。
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(/^gov-preview-/);
// 填可审计原因并真打下架命令。
await dialog.locator('textarea').fill('e2e 真后端下架验证');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
// UI 反馈:成功 toast(命令 code:0)。
await expect(page.getByText('市场资产下架命令已提交')).toBeVisible();
// 反假绿核心:真 PG 反查资产真的下架 + 真的落了一条 applied delist 治理动作。
await expect
.poll(() => queryAssetListingStatus(assetId), { timeout: 10_000 })
.toBe('delisted');
expect(await countGovernanceAction(assetId, 'delist')).toBe(1);
expectNoUnhandledMuseAdminApis(page);
});
// 市场申诉处理真命令:在「申诉处理」tab 对开放态申诉 1(supplementing)生成真影响预览 →
// 以默认结论 maintained 真打处理命令 → 真 PG 反查申诉 status='maintained'、resolver_id=当前 admin(1)。
//
// restore 断层留意(诚实记录):本用例走 maintained 结论,后端不消费 restore 影响预览票据
// (MarketAppealServiceImpl 仅 restored/partially_restored 才走 restoreAssetByPreview→consumeReadyPreview)。
// appeal restore 票据一次性消费路径只有 mock-service 单测、无真 PG IT,且本 e2e 未覆盖 restore 分支
// (FE openAppealAction 默认 resolution=maintained,本用例不切到 restored)。restore 真后端覆盖缺口待补。
test('市场申诉以 maintained 结论真打处理命令并落库', async ({ page }) => {
const appealId = 1;
// 前置真值核验:申诉 1 必须为开放态 supplementing(globalSetup 已复位),前端"处理"按钮才放行。
const before = await queryAppealFacts(appealId);
expect(before.status).toBe('supplementing');
await page.goto('/muse/market');
await page.getByRole('tab', { name: '申诉处理' }).click();
// 申诉 1 关联资产「活体申诉演练资产」,行内唯一动作按钮"处理"(ant 中文字按钮带空格→/处\s*理/)。
// 按激活 tabpanel 收口,避开 ant 不卸载的隐藏 pane(资产 tab 也有同名资产行)。
const appealPanel = page.getByRole('tabpanel', { name: '申诉处理' });
const appealRow = appealPanel.getByRole('row', { name: /活体申诉演练资产/ });
await appealRow.getByRole('button', { name: /处\s*理/ }).click();
const dialog = page.getByRole('dialog', { name: /市场治理:活体申诉演练资产/ });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
// 真生成影响预览(申诉类型 review_rejection → 前端 actionType=review_rejection,后端接受)。
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(/^gov-preview-/);
// 结论默认 maintained(不切到 restored,避免触达未覆盖的 restore 票据消费路径)。
const auditReason = 'e2e 真后端维持原处理决定';
await dialog.locator('textarea').fill(auditReason);
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
await expect(page.getByText('市场申诉已处理')).toBeVisible();
// 反假绿核心:真 PG 反查申诉真的落为 maintained 终态、处理人写入当前 admin(user1)。
// 注意后端契约:muse_market_appeal.resolution 列存的是"处理理由"文本(与 P1rMarketAdminAppeal IT 一致),
// 不是结论枚举;maintained 结论体现在 status 列。故此处校验 status=maintained + resolution=本次理由。
await expect
.poll(async () => (await queryAppealFacts(appealId)).status, {
timeout: 10_000,
})
.toBe('maintained');
const after = await queryAppealFacts(appealId);
expect(after.resolution).toBe(auditReason);
expect(after.resolverId).toBe('1');
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();
await expect(page.getByText('story.draft')).toBeVisible();
await page.getByRole('button', { name: /查看详情/ }).click();
await expect(page).toHaveURL(/\/muse\/governance\/meta-schemas\/story\.draft$/);
await expect(page.getByText('草稿 JSON 编辑')).toBeVisible();
await page.goto('/muse/governance/meta-schemas');
await page.getByRole('button', { name: /新建草稿/ }).click();
await expect(page).toHaveURL(/\/muse\/governance\/meta-schemas\/__create__\?mode=create$/);
await expect(page.getByPlaceholder('例如 work.story')).toBeVisible();
await page.goto('/muse/governance/function-orchestration');
await expect(
page.getByText('开放槽位只表示可替换的非保护节点').first(),
).toBeVisible();
await expect(page.getByText('故事链路')).toBeVisible();
await page.getByRole('button', { name: '保护节点' }).first().click();
await expect(page.getByText('事实校验')).toBeVisible();
await page.goto('/muse/ai');
await expect(page.getByText('AI 配置域治理边界')).toBeVisible();
await expect(page.getByText('候选生成 Prompt')).toBeVisible();
await page.getByRole('tab', { name: '系统智能体' }).click();
await expect(page.getByText('用户私有 Agent 边界')).toBeVisible();
await expect(page.getByText('系统故事智能体')).toBeVisible();
await page.goto('/muse/knowledge');
await expect(page.getByRole('heading', { name: '全局知识管理' })).toBeVisible();
await expect(page.getByText('不读取用户私有正文')).toBeVisible();
await expect(page.getByText('系统世界观库').first()).toBeVisible();
await page.goto('/muse/market');
await expect(page.getByRole('heading', { name: '市场治理' })).toBeVisible();
await expect(page.getByText('待审资产').first()).toBeVisible();
await page.getByRole('tab', { name: '资产治理' }).click();
await expect(page.getByText('不改用户私有副本').first()).toBeVisible();
await expect(page.getByText('市场资产').first()).toBeVisible();
await page.getByRole('tab', { name: '申诉处理' }).click();
await expect(page.getByText('申诉人').first()).toBeVisible();
await page.goto('/muse/new-api');
await expect(
page.getByText('New-API 页面只展示治理和对账摘要'),
).toBeVisible();
await expect(page.getByText('不展示凭据')).toBeVisible();
await expect(page.getByRole('cell', { name: 'user-e2e' })).toBeVisible();
await page.getByRole('tab', { name: '调用归属' }).click();
await expect(page.getByText('usage-e2e')).toBeVisible();
expectNoUnhandledMuseAdminApis(page);
});