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 { 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 => { 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 => { 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 => { 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 => { 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;