oh-my-muse/muse-studio/e2e/global-setup.ts
zizi 6dcabf69fb feat(solo): 收口治理入口并裁剪 studio 表面
完成 2.0.0 S4 的外部 AI、admin、account 与 solo SQL 收口,并推进 S5 studio 市场/handoff/旧编辑器入口裁剪。S5 的前端单测、构建与 route-only e2e 已留证;MSW-off 创作主线 e2e 仍因本地后端活体启动口径待修正,已在总账标为未完成验收。
2026-07-08 11:45:26 +08:00

745 lines
43 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Client as PgClient } from 'pg';
/**
* Playwright globalSetup —— 活体 e2e 每轮 fixture 自动重置(可入库,替代一次性 /tmp 种子脚本)。
*
* WHY:整套 e2e 关 MSW 直连真实 muse-server + 真 PostgreSQL(muse_slice_live);多条旅程会"消费"种子状态
* (采纳推进 block revision、确认消费草稿、确认安全事件、申诉状态流转),故每轮须把这些 fixture 复位为可重跑态,
* 否则二次跑必红(历史用一次性 `/tmp/*.java`,每会话丢失需反推——本文件即其可入库版)。
*
* 连接凭据从环境变量读取(`MUSE_POSTGRES_*`,见 ~/.config/muse-repo/infra.env;**绝不入库**):
* 跑前先 `set -a; . ~/.config/muse-repo/infra.env; set +a`。缺凭据时直接报错(本套件仅活体跑,无 DB 不可能真绿)。
* 如确知库已就绪、想跳过重置:置 `E2E_SKIP_SEED=1`。
*
* 说明:仅"复位/补充"每轮消费的 fixture,假定基础数据(work3/block3/suggestion3、安全事件、市场资产/申诉、账户/智能体)
* 已存在于目标库(由基础种子建立);其中 block3/suggestion3/安全事件按 spec 既有 id/内容定位复位,不重建
* (muse_ai_suggestion.id 为 GENERATED ALWAYS,无法强制还原原 id)。
*/
async function globalSetup(): Promise<void> {
if (process.env.E2E_SKIP_SEED === '1') {
console.log('[e2e globalSetup] E2E_SKIP_SEED=1 → 跳过 fixture 重置');
return;
}
const host = process.env.MUSE_POSTGRES_HOST;
const password = process.env.MUSE_POSTGRES_PASSWORD;
if (!host || !password) {
throw new Error(
'[e2e globalSetup] 缺少 MUSE_POSTGRES_* 环境变量。活体 e2e 需直连真实 PG 复位每轮 fixture,' +
'请先 `set -a; . ~/.config/muse-repo/infra.env; set +a` 再跑(或置 E2E_SKIP_SEED=1 跳过)。'
);
}
const { Client } = await import('pg');
const client: PgClient = new Client({
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,
ssl: false,
connectionTimeoutMillis: 10_000,
});
await client.connect();
try {
const enableMarketFixture = process.env.MUSE_STUDIO_E2E_MARKET !== 'false';
let marketFixtureDetails = 'market/handoff fixtures skipped';
// 0) AI 真生成额度:每轮把 test1(user1) 的 ai_calls 配额复位为可消费态。
// WHY:AI 生成 e2e 会真调用 Account 额度预扣,额度属于会被测试消费的状态;若不复位,
// 历史运行耗尽或基础种子缺失都会让 POST /ai/tasks 在业务入口返回「账户额度不足」,形成夹具假红。
await client.query(
`INSERT INTO muse_member_quota
(account_user_id, quota_type, total_amount, used_amount, reset_cycle, last_reset_at, next_reset_at,
revision, creator, updater, deleted, tenant_id)
VALUES (1,'ai_calls',1000,0,'manual',CURRENT_TIMESTAMP,NULL,1,'e2e','e2e',FALSE,1)
ON CONFLICT (tenant_id, account_user_id, quota_type)
DO UPDATE SET total_amount=EXCLUDED.total_amount,
used_amount=0,
reset_cycle=EXCLUDED.reset_cycle,
last_reset_at=CURRENT_TIMESTAMP,
next_reset_at=NULL,
revision=1,
updater='e2e',
update_time=CURRENT_TIMESTAMP,
deleted=FALSE`
);
// 1) knowledge-graph:作品 1 下 2 个 Canonical 实体 + 1 条关系(幂等:先删 marker 再插)。
await client.query("DELETE FROM muse_knowledge_relation WHERE tenant_id=1 AND relation_type='huoti_related'");
await client.query("DELETE FROM muse_knowledge_draft WHERE tenant_id=1 AND draft_payload->>'name'='huotituopusuqing'");
await client.query(
"DELETE FROM muse_knowledge_entity WHERE tenant_id=1 AND normalized_name IN ('huotituopusuqing','huotituopuqingzhou')"
);
const ent = async (name: string, desc: string): Promise<number> => {
const r = await client.query(
`INSERT INTO muse_knowledge_entity
(work_id, entity_type, normalized_name, scope, description, status, source_status, source_action_policy, revision, tenant_id)
VALUES (1,'character',$1,'global',$2,'active','active','allowed',1,1) RETURNING id`,
[name, desc]
);
return r.rows[0].id as number;
};
const id1 = await ent('huotituopusuqing', '活体图谱实体·苏青(e2e graph 种子)');
const id2 = await ent('huotituopuqingzhou', '活体图谱实体·青舟(e2e graph 种子)');
await client.query(
`INSERT INTO muse_knowledge_relation
(work_id, source_entity_id, target_entity_id, relation_type, description, source_status, source_action_policy, revision, tenant_id)
VALUES (1,$1,$2,'huoti_related','活体图谱关系·师徒(e2e graph 种子)','active','allowed',1,1)`,
[id1, id2]
);
// 2) knowledge-confirm:一个"可确认"的 pending 草稿(唯一名,避免与既有 Canonical 实体撞唯一身份被新 dup 预检拦成 conflict)。
await client.query(
"DELETE FROM muse_knowledge_draft WHERE tenant_id=1 AND status='pending' AND draft_payload->>'name' LIKE 'queren%'"
);
const draftName = 'queren' + Date.now();
await client.query(
`INSERT INTO muse_knowledge_draft
(work_id, draft_type, draft_payload, status, source_status, source_action_policy, source_snapshot_id, authorization_snapshot_id, revision, tenant_id)
VALUES (1,'entity',$1::jsonb,'pending','active','allowed',8001,8002,1,1)`,
[JSON.stringify({ entityType: 'character', name: draftName, scope: 'global' })]
);
// 2b) knowledge-draft-actions:作品 2 下两个固定 name 的 pending 草稿,供 recheck/ignore e2e。
// 用 work2 与 confirm 的 work1 隔离(避免同作品多草稿时按钮定位歧义);recheck 与 ignore 各用独立草稿
// (recheck 可能改草稿态,与 ignore 互不影响)。固定 name 幂等:先按 name 删(不论状态:重跑时可能已 ignored)再插。
for (const draftActionName of ['recheck-e2e', 'ignore-e2e']) {
await client.query(
"DELETE FROM muse_knowledge_draft WHERE tenant_id=1 AND work_id=2 AND draft_payload->>'name'=$1",
[draftActionName]
);
await client.query(
`INSERT INTO muse_knowledge_draft
(work_id, draft_type, draft_payload, status, source_status, source_action_policy, source_snapshot_id, authorization_snapshot_id, revision, tenant_id)
VALUES (2,'entity',$1::jsonb,'pending','active','allowed',8001,8002,1,1)`,
[JSON.stringify({ entityType: 'character', name: draftActionName, scope: 'global' })]
);
}
// 2c) 实体/关系 CRUD:作品 2 下三个 Canonical 实体——jia 供编辑实体名、yi/bing 供新建关系(各自独立避免互相影响)。
// 用 work2 与 knowledge-graph 的 work1 fixture 隔离;复位删 work2 全部关系 + 按 description marker 删实体
// (编辑会改 normalized_name 故不能按名删,description marker 不被编辑触碰)。
await client.query('DELETE FROM muse_knowledge_relation WHERE tenant_id=1 AND work_id=2');
await client.query(
"DELETE FROM muse_knowledge_entity WHERE tenant_id=1 AND work_id=2 AND description LIKE 'e2e-crud-marker:%'"
);
const crudEnt = async (entName: string): Promise<void> => {
await client.query(
`INSERT INTO muse_knowledge_entity
(work_id, entity_type, normalized_name, scope, description, status, source_status, source_action_policy, revision, tenant_id)
VALUES (2,'character',$1,'global',$2,'active','active','allowed',1,1)`,
[entName, 'e2e-crud-marker:' + entName]
);
};
await crudEnt('crudshiti-jia');
await crudEnt('crudshiti-yi');
await crudEnt('crudshiti-bing');
// 3) accept-suggestion + source-attribution 只读 fixture:复位 block3(work3)→rev1 + 重建来源归因为 user_original。
// WHY(source-attribution 反污染,根治偶发红):source-attribution.spec 读「某 Block 当前 revision 的来源」并断言
// 「用户原创」。它原指向 /workspace/1(=block1),但 block1 被 accept-suggestion(写 ai_suggestion 来源)、
// block-autosave、block-structure 三个 spec 在前序消费(三者按字母序均先于 source-attribution 跑),其当前 revision
// 的 source_type 由「最后一个写 block1 的 spec」决定 → 非确定(实测 block1 顶层 revision 在 user_original/ai_suggestion
// 间漂移,顶层落 ai_suggestion 时 source-attribution 渲染「AI 生成」而非「用户原创」→ 偶发红)。globalSetup 每轮只跑
// 一次(在所有 spec 之前),无法撤销 spec 跑中途的污染,故根治法是让 source-attribution 改读「无任何 spec 会写」的
// pristine Block:work3/block3(grep 全 spec 确认无人导航 /workspace/3、无人写 block3;此处每轮把 block3 复位 rev1 +
// 重建唯一一条 user_original 来源,使该读断言恒确定真)。block3 的 user_original 是真实初态(用户原创正文),断言未放宽。
await client.query('DELETE FROM muse_content_block_source_attribution WHERE tenant_id=1 AND block_id=3');
const blk = await client.query(
"UPDATE muse_content_block SET revision=1, content_text='活体原始正文待采纳', word_count=9 WHERE tenant_id=1 AND id=3"
);
// 重建 block3 rev1 的 user_original 来源(source-attribution.spec 读此并断言「用户原创」);幂等:上面已先删 block3 全部归因。
await client.query(
`INSERT INTO muse_content_block_source_attribution
(work_id, block_id, revision, source_type, source_object_id, source_status, tenant_id)
VALUES (3,3,1,'user_original','3','active',1)`
);
await client.query("UPDATE muse_ai_suggestion SET status='pending', source_status='active' WHERE tenant_id=1 AND id=3");
if (blk.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到 block id=3(source-attribution 只读 fixture 需基础种子,需先建基础数据)');
}
// 4) account-security-ack:把"待确认演练"事件复位为未确认 + 清其确认记录(已确认态由 ack 记录派生)。
const evs = await client.query(
"SELECT id FROM muse_member_security_event WHERE tenant_id=1 AND account_user_id=1 AND device_info->>'description'='活体安全事件·待确认演练'"
);
for (const row of evs.rows) {
await client.query('DELETE FROM muse_account_security_event_ack WHERE tenant_id=1 AND event_id=$1', [row.id]);
await client.query(
'UPDATE muse_member_security_event SET acknowledged=false, acknowledged_at=NULL WHERE tenant_id=1 AND id=$1',
[row.id]
);
}
if (evs.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到"待确认演练"安全事件(account-security-ack 基础种子缺失)');
}
if (enableMarketFixture) {
// 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端
// APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒
// MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致
// (见 MarketAppealServiceImpl.requireWithinDeadline / reviewRejectionRelation 取 request.reviewedAt)。
await client.query(
"UPDATE muse_market_publish_request SET reviewed_at = NOW() WHERE tenant_id=1 AND status IN ('rejected','compliance_blocked')"
);
// 5) market 申诉补充材料:把当前用户最早的一条申诉置 supplementing(canSupplement→展示「补充材料」)。
const ap = await client.query(
"UPDATE muse_market_appeal SET status='supplementing' WHERE tenant_id=1 AND id=(SELECT min(id) FROM muse_market_appeal WHERE tenant_id=1 AND user_id=1)"
);
if (ap.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到当前用户的申诉(market 补充材料用例需基础申诉种子)');
}
}
// 6) knowledge-bindings:作品 1 一条<生效>的来源绑定读模型投影(此前 global-setup 未覆盖、基础种子缺失,
// 本轮固化为可入库;幂等:先删同 projection_id 再插,id 为 identity 自增故重跑不冲突)。
await client.query(
"DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND projection_id='e2e-seed-w1-kb4001'"
);
await client.query(
`INSERT INTO muse_knowledge_source_binding_projection
(projection_id, source_owner, source_type, source_id, owner_user_id, work_id, kb_id, binding_id,
status, action_policy, projection_summary, tenant_id)
VALUES ('e2e-seed-w1-kb4001','market','market_kb','4001',1,1,4001,5001,'active','allowed','{}'::jsonb,1)`
);
// 7) meta-projection:种 2 个 active MetaSchema(setting/worldview,target_type=novel_work)+字段+版本级可见性,
// 并把 work1 绑到 setting——供 B5 planning 编辑器 e2e 真跑(按 schema 字段填值→usedFieldKeys→快照)。
// 幂等:先按 schema_key 删子表(field/visibility/version)再删 schema,重跑不冲突(id 为 IDENTITY)。
const metaSchemaKeys = ['setting', 'worldview'];
await client.query(
`DELETE FROM muse_meta_field WHERE tenant_id=1 AND schema_version_id IN
(SELECT id FROM muse_meta_schema_version WHERE tenant_id=1 AND schema_key = ANY($1))`,
[metaSchemaKeys]
);
await client.query(
`DELETE FROM muse_meta_visibility_policy WHERE tenant_id=1 AND schema_version_id IN
(SELECT id FROM muse_meta_schema_version WHERE tenant_id=1 AND schema_key = ANY($1))`,
[metaSchemaKeys]
);
await client.query('DELETE FROM muse_meta_schema_version WHERE tenant_id=1 AND schema_key = ANY($1)', [metaSchemaKeys]);
await client.query('DELETE FROM muse_meta_schema WHERE tenant_id=1 AND schema_key = ANY($1)', [metaSchemaKeys]);
// 种一个 active schema(含字段 + 版本级可见性),返回 schemaId(id 为 IDENTITY 故 RETURNING)。
const seedSchema = async (
domain: string,
schemaKey: string,
displayName: string,
fields: Array<{ key: string; name: string; type: string; required: boolean; enumValues?: string[] }>
): Promise<number> => {
const s = await client.query(
`INSERT INTO muse_meta_schema (schema_key, domain, scope, target_type, display_name, projection_version, status, tenant_id)
VALUES ($1,$2,'work','novel_work',$3,1,'active',1) RETURNING id`,
[schemaKey, domain, displayName]
);
const schemaId = s.rows[0].id as number;
const v = await client.query(
`INSERT INTO muse_meta_schema_version (schema_id, schema_key, version_no, status, active_flag, tenant_id)
VALUES ($1,$2,1,'active',TRUE,1) RETURNING id`,
[schemaId, schemaKey]
);
const versionId = v.rows[0].id as number;
await client.query('UPDATE muse_meta_schema SET active_version_id=$1 WHERE id=$2', [versionId, schemaId]);
let sort = 0;
for (const f of fields) {
await client.query(
`INSERT INTO muse_meta_field (schema_version_id, field_key, display_name, field_type, is_required, enum_values, sort_order, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,1)`,
[versionId, f.key, f.name, f.type, f.required, f.enumValues ? JSON.stringify(f.enumValues) : null, sort++]
);
}
// 版本级可见性:可见 + 可编辑(供用户填),其余保守 false。
await client.query(
`INSERT INTO muse_meta_visibility_policy
(schema_version_id, ui_visible, ai_context, user_editable, user_searchable, exportable, tenant_id)
VALUES ($1,TRUE,FALSE,TRUE,FALSE,FALSE,1)`,
[versionId]
);
return schemaId;
};
const settingSchemaId = await seedSchema('content', 'setting', '故事设定', [
{ key: 'time_period', name: '时代背景', type: 'enum', required: true, enumValues: ['古代', '现代', '未来'] },
{ key: 'world_overview', name: '世界观概述', type: 'text', required: false },
]);
await seedSchema('world', 'worldview', '世界观', [
{ key: 'magic_system', name: '魔法体系', type: 'text', required: false },
]);
// work1 绑 setting(其 target_type=novel_work → 投影列出同 targetType 的 setting+worldview)。
await client.query('UPDATE muse_content_work SET work_schema_id=$1 WHERE tenant_id=1 AND id=1', [settingSchemaId]);
// 8) block split/merge:复位 work1 chapter1 为单 Block——删 split 残留的多余 Block(保留 order_no 最小的首 Block),
// 供 D block 结构编辑 e2e 幂等重跑(split→2→merge→1,中途失败的残留也清掉)。
await client.query(
`DELETE FROM muse_content_block WHERE tenant_id=1 AND work_id=1 AND chapter_id=1
AND id <> (SELECT id FROM muse_content_block WHERE tenant_id=1 AND work_id=1 AND chapter_id=1 ORDER BY order_no, id LIMIT 1)`
);
// 9) knowledge 停用/恢复:复位自建 KB(id=1)status='draft'——供 F disable/restore e2e 幂等重跑
// (e2e 会 disable→'disabled'、restore→'searchable',含中途残留态一并归位到初始 draft)。
const kbReset = await client.query(
"UPDATE muse_knowledge_base SET status='draft' WHERE tenant_id=1 AND id=1"
);
if (kbReset.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到 KB id=1(knowledge 停用/恢复用例需基础 KB 种子)');
}
// 9b) knowledge 停用 ≥2 绑定回归锚点:确保 KB id=1 至少有 2 条未删 binding(binding id=1=work1 + 本条 work2),
// 使 F disable/restore e2e 真正走「KB 绑 ≥2 作品→传播停用」路径——这正是历史 500 bug 的触发条件
// (旧 propagateKbBlocked 给同 KB 全部 binding 写同一 commandId,撞 uk_muse_knowledge_binding_command 唯一约束)。
// 后端修复后传播只翻 binding_status、不再覆盖 command_id,本锚点用于守住该回归不复发。
// 选 work2(owner=1、未占 (work,kb) uk):work1+kb1 已被 installed binding(§10,id=1)占,work4+kb1 由 handoff-knowledge
// spec 运行时占用,故必须避开二者,选 work2。幂等:先按 (work2,kb1) 删再插(释放 uk_muse_knowledge_binding_work_kb),
// command_id 用本条自身创建命令(与 binding id=1 不同值),模拟真实「各 binding 各自创建命令」语义。
await client.query('DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=2 AND kb_id=1');
await client.query(
`INSERT INTO muse_knowledge_binding
(work_id, kb_id, binding_type, binding_scope, binding_status, source_snapshot_id, command_id, revision, tenant_id)
VALUES (2,1,'user_kb','search','active','source-user_kb-1-v1','e2e-seed-kb1-binding-work2',1,1)`
);
// 10) 已安装知识库停用/恢复:复位 installed binding(id=1)binding_status='active'——供 G installed disable/restore e2e
// 幂等重跑(开局 active→先显示停用;e2e disable→'disabled'、restore→'active' 均归位)。
const installedReset = await client.query(
"UPDATE muse_knowledge_binding SET binding_status='active' WHERE tenant_id=1 AND id=1"
);
if (installedReset.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到 installed binding id=1(已安装 KB 停用/恢复用例需基础 binding 种子)');
}
// 11) AgentList:清理 agent-create 历次累积的自建智能体(uniqueName=`活体智能体-e2e-${Date.now()}` 不幂等、
// 每跑新建一个)。否则列表被 e2e agent 淹没,后端默认分页按 updatedAt 倒序把种子「活体测试智能体」
// 挤出第一页,致 live-read「智能体列表渲染真实 Agent」断言 timeout。只删 e2e 前缀、保留种子。
// E2 智能体生命周期 e2e 会为每个临时 agent 写版本行;清理时必须先删子事实,避免残留版本污染分页与 DB 核验。
await client.query(
`DELETE FROM muse_agent_slot_binding
WHERE tenant_id=1
AND agent_id IN (SELECT id FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%')`
);
await client.query(
`DELETE FROM muse_agent_version
WHERE tenant_id=1
AND agent_id IN (SELECT id FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%')`
);
const agentCleanup = await client.query(
"DELETE FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%'"
);
if (enableMarketFixture) {
// 12) handoff-knowledge:清理 e2e 正路兑现产生的 market_kb 绑定(work4+kb1),供兑现闭环幂等重跑。
// 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding(id=1,user_kb)占,故 e2e 用 work4 避 uk 冲突。
// 按 source_snapshot_id 格式(source-market_kb-{sourceId}-v*)精准删 binding;物理 DELETE 释放 uk_muse_knowledge_binding_work_kb。
await client.query(
"DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id LIKE 'source-market_kb-1-%'"
);
await client.query(
"DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND work_id=4 AND kb_id=1 AND source_type='market_kb'"
);
// 13) handoff-agent(E4):seed 发布者侧 user agent + asset1.source_id,供 market→agent handoff 真实物化 e2e。
// 旧形态是预置 agent_type=market 的裸 agent 并把 sourceAgentId 透传给目标侧E4 后目标侧只信 market
// assetId/token,后端按 asset.source_id 找发布者 agent,再物化成安装者本地 user agent。
const HANDOFF_AGENT_KEY = 'publisher:handoff:e2e';
const HANDOFF_SLOT_KEY = 'handoff-writer';
await client.query(
`DELETE FROM muse_agent_version
WHERE tenant_id=1
AND agent_id IN (
SELECT id FROM muse_agent
WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false
)`
);
await client.query(
"DELETE FROM muse_agent WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false"
);
await client.query('DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id=4 AND slot_key=$1', [
HANDOFF_SLOT_KEY,
]);
await client.query(
'DELETE FROM muse_agent_version WHERE tenant_id=1 AND agent_id IN (SELECT id FROM muse_agent WHERE tenant_id=1 AND agent_key=$1)',
[HANDOFF_AGENT_KEY]
);
await client.query('DELETE FROM muse_agent WHERE tenant_id=1 AND agent_key=$1', [HANDOFF_AGENT_KEY]);
const publisherAgent = await client.query(
`INSERT INTO muse_agent (agent_key, name, description, agent_type, owner_user_id, status, tenant_id)
VALUES ($1,'活体市场智能体-handoff','market agent handoff e2e 发布者来源','user',2,'active',1) RETURNING id`,
[HANDOFF_AGENT_KEY]
);
const publisherAgentId = publisherAgent.rows[0].id as number;
const publisherVersion = await client.query(
`INSERT INTO muse_agent_version (agent_id, version, config, status, tenant_id)
VALUES ($1,'1',$2::jsonb,'active',1) RETURNING id`,
[
publisherAgentId,
JSON.stringify({
modelKey: 'MiniMax-M2.5',
promptTemplateVersion: 'prompt-v1',
}),
]
);
await client.query('UPDATE muse_agent SET current_version_id=$1 WHERE id=$2', [
publisherVersion.rows[0].id,
publisherAgentId,
]);
await client.query(
`UPDATE muse_market_asset
SET asset_type='agent',
source_id=$1,
publisher_id=2,
category='general',
tags=coalesce(tags, '{}'::jsonb) || $2::jsonb,
update_time=CURRENT_TIMESTAMP
WHERE tenant_id=1 AND id=1 AND deleted=false`,
[
publisherAgentId,
JSON.stringify({
sourceOwner: 'agent',
sourceType: 'agent',
sourceRevision: '1',
}),
]
);
// work4 槽位初始绑现有 agent1(被兑现替换成安装者本地 user agent);precheck revision 从 1 起。
await client.query(
`INSERT INTO muse_agent_slot_binding (work_id, slot_key, agent_id, agent_version, status, revision, tenant_id)
VALUES (4,$1,1,'1','active',1,1)`,
[HANDOFF_SLOT_KEY]
);
// 14) handoff-content(P3):清理 e2e 正路兑现产生的 asset_use precheck(work4),供兑现闭环幂等重跑。
// asset_use 不绑目标实体(单表 precheck 即使用凭证),物理删释放 (tenant_id,command_id) uk;asset 1 + work4 无需额外 seed。
await client.query('DELETE FROM muse_content_work_asset_use_precheck WHERE tenant_id=1 AND work_id=4');
}
// 15a) AI 真生成主链:复位 work1 writing.continuation 槽位到固定起点(agent1、revision=1、active)。
// accept-suggestion / ai-generation 均依赖 work1 解析到 active Agent该槽位不能被其它 e2e 写坏。
await client.query(
"DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id=1 AND slot_key='writing.continuation'"
);
await client.query(
`INSERT INTO muse_agent_slot_binding (work_id, slot_key, agent_id, agent_version, status, revision, binding_source, tenant_id)
VALUES (1,'writing.continuation',1,'1','active',1,'user',1)`
);
// 15b) agent-slot-bind(UI e2e):使用 work2 的同名槽位做「替换 + 解绑」写侧验证。
// WHY:该用例最终会把槽位解绑为 archived若复用 work1会在全量 e2e 中污染后续 ai-generation 主链。
// work2 与知识草稿/实体用例共享作品但不共享 agent 槽位,写入边界互不影响。
// AgentPage 的作品下拉只读作品列表第一页;历史 work-schema e2e 会累积大量新作品,故每轮刷新 work2 的
// 标题与 update_time保证它稳定出现在第一页供 UI 选择。
await client.query(
"UPDATE muse_content_work SET title='槽位绑定验证作品', update_time=CURRENT_TIMESTAMP WHERE tenant_id=1 AND id=2"
);
await client.query(
"DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id=2 AND slot_key='writing.continuation'"
);
await client.query(
`INSERT INTO muse_agent_slot_binding (work_id, slot_key, agent_id, agent_version, status, revision, binding_source, tenant_id)
VALUES (2,'writing.continuation',1,'1','active',1,'user',1)`
);
if (enableMarketFixture) {
// 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的
// 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链,
// UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装,
// 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的
// authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。
const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e';
let installE2eAssetId: number;
const existed = await client.query(
"SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false",
[MARKET_INSTALL_ASSET]
);
if (existed.rowCount && existed.rows[0]) {
installE2eAssetId = existed.rows[0].id as number;
} else {
// 资产首建:listed agent + 一条 published 版本,并回填 current_version_id(详情/安装需可见且有版本)。
const a = await client.query(
`INSERT INTO muse_market_asset
(name, description, asset_type, category, publisher_id, listing_status, license_type, status, creator, updater, tenant_id)
VALUES ($1,'安装 e2e 专用资产','agent','general',2,'listed','standard','active','2','2',1) RETURNING id`,
[MARKET_INSTALL_ASSET]
);
installE2eAssetId = a.rows[0].id as number;
const v = await client.query(
`INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id)
VALUES ($1,'1.0.0','首发','published','2','2',1) RETURNING id`,
[installE2eAssetId]
);
await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE id=$2', [
v.rows[0].id,
installE2eAssetId,
]);
}
// 复位 user1 在该资产上的获取/安装/命令到干净起点(未获取、未安装),令安装按钮渲染条件每轮可复现。
await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [
installE2eAssetId,
]);
await client.query(
'DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1',
[installE2eAssetId]
);
// UI 随机 commandId 的 purchase/install 命令记录按 target_id 清(target_id 存资产 id 字符串),避免幂等回放。
await client.query("DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1", [
String(installE2eAssetId),
]);
// 17) 复位种子资产 asset1「活体市场资产·测试」为上架态(listing_status='listed'、status='active')。
const asset1Reset = await client.query(
"UPDATE muse_market_asset SET listing_status='listed', status='active' WHERE tenant_id=1 AND id=1 AND deleted=false"
);
if (asset1Reset.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到 asset id=1「活体市场资产·测试」(handoff/market 系列正路用例需该种子资产)');
}
// 18) handoff-knowledge / market-handoff-precheck:seed 一个专用 listed knowledge_base 资产。
const MARKET_KB_HANDOFF_ASSET = '活体市场知识库·handoff e2e';
const handoffDataset = await client.query(
`SELECT kb.id AS kb_id, rb.ragflow_dataset_id
FROM muse_knowledge_ragflow_binding rb
JOIN muse_knowledge_base kb
ON kb.id=rb.kb_id AND kb.tenant_id=rb.tenant_id AND kb.deleted=false
WHERE rb.tenant_id=1
AND rb.deleted=false
AND rb.document_id IS NULL
AND rb.status='active'
AND rb.ragflow_dataset_id IS NOT NULL
AND kb.name='端到端检索验证KB'
ORDER BY rb.id DESC
LIMIT 1`
);
if (handoffDataset.rowCount === 0) {
throw new Error('[e2e globalSetup] 缺少稳定 active RAGFlow dataset: 端到端检索验证KB');
}
const handoffPublicDatasetId = String(handoffDataset.rows[0].ragflow_dataset_id);
let handoffPublisherKbId: number;
const handoffPublisherKb = await client.query(
"SELECT id FROM muse_knowledge_base WHERE tenant_id=1 AND name='E3 market handoff publisher KB' AND deleted=false ORDER BY id DESC LIMIT 1"
);
if (handoffPublisherKb.rowCount && handoffPublisherKb.rows[0]) {
handoffPublisherKbId = Number(handoffPublisherKb.rows[0].id);
} else {
const kb = await client.query(
`INSERT INTO muse_knowledge_base
(name, description, kb_type, owner_user_id, status, active_version, command_id, revision, creator, updater, tenant_id)
VALUES ('E3 market handoff publisher KB','市场 KB handoff e2e 发布者侧来源占位','user',2,'active',1,
'e2e-market-kb-handoff-publisher',1,'2','2',1)
RETURNING id`
);
handoffPublisherKbId = Number(kb.rows[0].id);
}
const handoffTags = JSON.stringify({
publicForkDatasetId: handoffPublicDatasetId,
forkStatus: 'ready',
forkAssetVersion: '1.0.0',
sourceOwner: 'knowledge',
sourceType: 'knowledge_base',
actionPolicy: 'allowed',
});
let handoffKbAssetId: number;
let handoffKbVersionId: number | null = null;
const handoffAsset = await client.query(
"SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false ORDER BY id DESC LIMIT 1",
[MARKET_KB_HANDOFF_ASSET]
);
if (handoffAsset.rowCount && handoffAsset.rows[0]) {
handoffKbAssetId = Number(handoffAsset.rows[0].id);
handoffKbVersionId = handoffAsset.rows[0].current_version_id == null
? null
: Number(handoffAsset.rows[0].current_version_id);
await client.query(
`UPDATE muse_market_asset
SET description='知识库 handoff e2e 专用资产',
asset_type='knowledge_base',
category='knowledge',
source_id=$2,
publisher_id=2,
listing_status='listed',
license_type='standard',
status='active',
tags=$3::jsonb,
updater='2',
update_time=CURRENT_TIMESTAMP
WHERE tenant_id=1 AND id=$1`,
[handoffKbAssetId, handoffPublisherKbId, handoffTags]
);
} else {
const asset = await client.query(
`INSERT INTO muse_market_asset
(name, description, asset_type, category, source_id, publisher_id, listing_status, license_type, status,
tags, creator, updater, tenant_id)
VALUES ($1,'知识库 handoff e2e 专用资产','knowledge_base','knowledge',$2,2,'listed','standard','active',
$3::jsonb,'2','2',1)
RETURNING id`,
[MARKET_KB_HANDOFF_ASSET, handoffPublisherKbId, handoffTags]
);
handoffKbAssetId = Number(asset.rows[0].id);
}
if (handoffKbVersionId == null) {
const version = await client.query(
`INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id)
VALUES ($1,'1.0.0','知识库 handoff e2e 首发','published','2','2',1)
RETURNING id`,
[handoffKbAssetId]
);
handoffKbVersionId = Number(version.rows[0].id);
await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE tenant_id=1 AND id=$2', [
handoffKbVersionId,
handoffKbAssetId,
]);
} else {
await client.query(
`UPDATE muse_market_asset_version
SET version='1.0.0',
change_note='知识库 handoff e2e 首发',
status='published',
updater='2',
update_time=CURRENT_TIMESTAMP
WHERE tenant_id=1 AND id=$1`,
[handoffKbVersionId]
);
}
// 复位 user1 对该知识资产的消费态,确保 purchase→bind-precheck→handoff→knowledge bind 每轮从干净起点执行。
const handoffSourceSnapshotId = `source-market_kb-${handoffKbAssetId}-v1`;
await client.query(
'DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id=$1',
[handoffSourceSnapshotId]
);
await client.query(
'DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id=$1',
[handoffSourceSnapshotId]
);
const handoffInstalledKbs = await client.query(
`SELECT id FROM muse_knowledge_base
WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=$1 AND kb_type='installed_ref' AND deleted=false`,
[handoffKbAssetId]
);
for (const row of handoffInstalledKbs.rows) {
const kbId = Number(row.id);
await client.query('DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_ragflow_call WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_processing_task WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_ragflow_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query(
`DELETE FROM muse_knowledge_document_version
WHERE tenant_id=1
AND document_id IN (SELECT id FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1)`,
[kbId]
);
await client.query('DELETE FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_base WHERE tenant_id=1 AND id=$1', [kbId]);
}
await client.query(
'DELETE FROM muse_knowledge_bind_precheck WHERE tenant_id=1 AND owner_user_id=1 AND work_id=4 AND source_snapshot_id=$1',
[handoffSourceSnapshotId]
);
await client.query('DELETE FROM muse_market_handoff_event WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [
handoffKbAssetId,
]);
await client.query('DELETE FROM muse_market_authorization_summary WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [
handoffKbAssetId,
]);
await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [
handoffKbAssetId,
]);
await client.query('DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [
handoffKbAssetId,
]);
await client.query('DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1', [handoffKbAssetId]);
marketFixtureDetails = `market-install assetId=${installE2eAssetId}/asset1-listed/market-kb-handoff assetId=${handoffKbAssetId}/handoff installed_ref KB ${handoffInstalledKbs.rowCount ?? 0}`;
}
// 19) 清理 work1/work2 除 writing.continuation 外的多余 active 槽位(去污)。
// WHY:agent-slot-bind.spec 假设 work2 仅一个开放槽位(writing.continuation,§15b 每轮复位),据此用
// getByText('revision 1') 唯一定位该槽位卡片。若 work1 残留了别的 active 槽位(实测有 writing.expansion、
// 同为 revision 1),则页面出现 2 个「revision 1」节点 → strict 模式命中歧义、该断言红。§15a/§15b 只管
// continuation 槽位,未清其它,故此处补一刀删净 work1/work2 非 continuation 槽位(物理 DELETE 释放唯一约束名额)。
const workSlotCleanup = await client.query(
"DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id IN (1,2) AND slot_key<>'writing.continuation'"
);
// 20) E3 knowledge-retrieval:创建一个 work3 专用的「已绑定但无 active dataset」KB。
// WHY:不能复用业务种子 KB 做 no_dataset 断言——一旦有人给该 KB 上传资料并生成 dataset,用例就从稳定真证变偶发红。
// 本 fixture 只清理固定 e2e marker 的 KB/binding/projection/ragflow 行,再重建绑定与投影,刻意不建 dataset 级
// muse_knowledge_ragflow_binding,从而稳定触发 RetrievalApiImpl 的 no_dataset 门。
const E3_NO_DATASET_KB = 'E3 no_dataset e2e KB';
const E3_UPLOAD_KB = 'E3 upload e2e KB';
const cleanupE3Kb = async (name: string): Promise<number> => {
const existingKb = await client.query(
'SELECT id FROM muse_knowledge_base WHERE tenant_id=1 AND name=$1',
[name]
);
for (const row of existingKb.rows) {
const kbId = Number(row.id);
await client.query('DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_ragflow_call WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_processing_task WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_ragflow_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query(
`DELETE FROM muse_knowledge_document_version
WHERE tenant_id=1
AND document_id IN (SELECT id FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1)`,
[kbId]
);
await client.query('DELETE FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1', [kbId]);
await client.query('DELETE FROM muse_knowledge_base WHERE tenant_id=1 AND id=$1', [kbId]);
}
return existingKb.rowCount ?? 0;
};
const cleanedNoDatasetKb = await cleanupE3Kb(E3_NO_DATASET_KB);
const noDatasetKb = await client.query(
`INSERT INTO muse_knowledge_base
(name, description, kb_type, owner_user_id, status, active_version, command_id, revision, creator, updater, tenant_id)
VALUES ($1,'E3 主动检索 no_dataset e2e 专用夹具','user',1,'active',1,'e2e-e3-no-dataset-kb',1,'1','1',1)
RETURNING id`,
[E3_NO_DATASET_KB]
);
const noDatasetKbId = Number(noDatasetKb.rows[0].id);
const noDatasetBinding = await client.query(
`INSERT INTO muse_knowledge_binding
(work_id, kb_id, binding_type, binding_scope, binding_status, source_snapshot_id, authorization_snapshot_id,
source_version, target_version, command_id, revision, creator, updater, tenant_id)
VALUES (3,$1,'user_kb','search','active','source-user_kb-e3-no-dataset-v1','auth-e3-no-dataset',
1,1,'e2e-e3-no-dataset-binding',1,'1','1',1)
RETURNING id`,
[noDatasetKbId]
);
await client.query(
`INSERT INTO muse_knowledge_source_binding_projection
(projection_id, source_owner, source_type, source_id, source_revision, owner_user_id, work_id, kb_id, binding_id,
status, action_policy, source_snapshot_id, authorization_snapshot_id, projection_summary, creator, updater, tenant_id)
VALUES ('e2e-e3-no-dataset-w3','knowledge','user_kb',$1,'1',1,3,$2,$3,'active','allowed',
'source-user_kb-e3-no-dataset-v1','auth-e3-no-dataset',$4::jsonb,'1','1',1)`,
[
String(noDatasetKbId),
noDatasetKbId,
Number(noDatasetBinding.rows[0].id),
JSON.stringify({ targetOwner: 'knowledge', sourceName: E3_NO_DATASET_KB, purposes: ['search'] }),
]
);
// 21) E3 knowledge-upload:创建一个干净自建 KB,供 UI 上传资料主流程 e2e 使用。
// 上传用例会真打后端/RAGFlow,本节每轮物理清理该 marker KB 的文档、任务和 dataset binding,保证可重跑。
const cleanedUploadKb = await cleanupE3Kb(E3_UPLOAD_KB);
const uploadKb = await client.query(
`INSERT INTO muse_knowledge_base
(name, description, kb_type, owner_user_id, status, active_version, command_id, revision, creator, updater, tenant_id)
VALUES ($1,'E3 Studio 上传资料 e2e 专用夹具','user',1,'active',1,'e2e-e3-upload-kb',1,'1','1',1)
RETURNING id`,
[E3_UPLOAD_KB]
);
const uploadKbId = Number(uploadKb.rows[0].id);
console.log(`[e2e globalSetup] 已复位每轮 fixture(ai-quota/graph/confirm-draft/accept/security-ack/binding/meta-schema/block/kb-status/installed-kb/agent-slot-bind/work-slot-dedup/e3 noDatasetKbId=${noDatasetKbId} uploadKbId=${uploadKbId}/${marketFixtureDetails});清理 e2e 累积 agent ${agentCleanup.rowCount} 条、work1/work2 多余槽位 ${workSlotCleanup.rowCount} 条、E3 no_dataset KB ${cleanedNoDatasetKb} 条、E3 upload KB ${cleanedUploadKb}`);
} finally {
await client.end();
}
}
export default globalSetup;