import { chromium, expect, test, type ElementHandle, type Page, type TestInfo } from "@playwright/test"; import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const CURRENT_FILE = fileURLToPath(import.meta.url); const FRONTEND_ROOT = path.resolve(path.dirname(CURRENT_FILE), ".."); const PROJECT_ROOT = path.resolve(FRONTEND_ROOT, ".."); const DEFAULT_CHROME_EXECUTABLE = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const DEFAULT_CHROME_PROFILE_ROOT = path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome"); const DEFAULT_PYTHON = path.join(PROJECT_ROOT, ".venv", "bin", "python"); const POLL_INTERVAL_MS = 2_000; // This real browser flow already stores targeted screenshots and a JSON summary. // Disabling trace/video here avoids flaky teardown inside Playwright artifact collection. test.use({ trace: "off", video: "off" }); type RuntimeConfig = { artifactTimeoutMs: number; browserExecutable: string; chatName?: string; chatUrl?: string; draftTimeoutMs: number; feishuBaseUrl: string; headless: boolean; pollIntervalMs: number; profileDirectory?: string; pythonPath: string; sendShortcut: string; slowMoMs: number; tenantId: number; testTimeoutMs: number; userDataDir: string; }; type LaunchOutcome = { context: Awaited>; launchMode: "direct" | "cloned_profile"; userDataDir: string; }; type DraftSnapshot = { draft_title?: string | null; draft_version: number; id: number; project_id?: number | null; signal_id?: number | null; state: string; }; type ProjectArtifacts = { charter?: { content: Record; id: number; status: string; title: string; } | null; project?: { description?: string | null; id: number; name: string; status: string; title?: string | null; } | null; signal_source_count: number; task_summary: { assigned_count: number; tasks: Array<{ ai_recommended_assignees?: Record | null; assignee_id?: number | null; id: number; required_skill_tag_ids: number[]; title: string; }>; total_count: number; }; work_item?: { draft_version: number; id: number; project_id?: number | null; signal_id?: number | null; state: string; } | null; }; type FeishuChatCandidate = { activationChatId: string; chatType: number; displayName: string; feedId: string; isBot: boolean; isP2P: boolean; type: number; }; const DB_HELPER = ` import asyncio import json import sys from pathlib import Path from sqlalchemy import Text, and_, cast, select PROJECT_ROOT = Path(sys.argv[1]) BACKEND_PATH = PROJECT_ROOT / "backend" if str(BACKEND_PATH) not in sys.path: sys.path.insert(0, str(BACKEND_PATH)) from app.core.database import AsyncSessionLocal from app.models import IngestEvent, Project, SignalSourceRegistry, Task, TruthNode, WorkItem payload = json.loads(sys.stdin.read()) mode = str(payload.get("mode") or "").strip() tenant_id = int(payload.get("tenant_id") or 1) def _row_work_item(row: WorkItem) -> dict: draft_content = row.draft_content if isinstance(row.draft_content, dict) else {} return { "id": int(row.id), "state": str(row.state or ""), "draft_version": int(row.draft_version or 0), "project_id": int(row.project_id) if row.project_id is not None else None, "signal_id": int(row.signal_id) if row.signal_id is not None else None, "draft_title": str(draft_content.get("title") or draft_content.get("project_title") or "") or None, } async def _find_draft() -> dict | None: token = str(payload.get("token") or "").strip() like_pattern = f"%{token}%" async with AsyncSessionLocal() as db: row = ( await db.execute( select(WorkItem) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) .where( and_( WorkItem.tenant_id == tenant_id, IngestEvent.tenant_id == tenant_id, WorkItem.source_system == "feishu", cast(IngestEvent.payload, Text).ilike(like_pattern), ) ) .order_by(WorkItem.id.desc()) .limit(1) ) ).scalar_one_or_none() if row is None: return None return _row_work_item(row) async def _get_work_item() -> dict | None: work_item_id = int(payload["work_item_id"]) async with AsyncSessionLocal() as db: row = ( await db.execute( select(WorkItem).where( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == work_item_id, ) ) ) ).scalar_one_or_none() if row is None: return None return _row_work_item(row) async def _get_project_artifacts() -> dict: work_item_id = int(payload["work_item_id"]) async with AsyncSessionLocal() as db: work_item = ( await db.execute( select(WorkItem).where( and_( WorkItem.tenant_id == tenant_id, WorkItem.id == work_item_id, ) ) ) ).scalar_one_or_none() if work_item is None: return {"work_item": None} project = None if work_item.project_id is not None: project = ( await db.execute( select(Project).where( and_( Project.tenant_id == tenant_id, Project.id == int(work_item.project_id), ) ) ) ).scalar_one_or_none() charter = None if work_item.project_id is not None: charter = ( await db.execute( select(TruthNode) .where( and_( TruthNode.tenant_id == tenant_id, TruthNode.project_id == int(work_item.project_id), TruthNode.node_type == "charter", ) ) .order_by(TruthNode.id.desc()) .limit(1) ) ).scalar_one_or_none() signal_source_rows = [] task_rows = [] if work_item.project_id is not None: signal_source_rows = ( await db.execute( select(SignalSourceRegistry).where( and_( SignalSourceRegistry.tenant_id == tenant_id, SignalSourceRegistry.project_id == int(work_item.project_id), ) ) ) ).scalars().all() task_rows = ( await db.execute( select(Task) .where( and_( Task.tenant_id == tenant_id, Task.project_id == int(work_item.project_id), ) ) .order_by(Task.id.asc()) ) ).scalars().all() return { "work_item": { "id": int(work_item.id), "state": str(work_item.state or ""), "draft_version": int(work_item.draft_version or 0), "project_id": int(work_item.project_id) if work_item.project_id is not None else None, "signal_id": int(work_item.signal_id) if work_item.signal_id is not None else None, }, "project": ( { "id": int(project.id), "name": str(project.name or ""), "title": str(project.title or "") or None, "description": str(project.description or "") or None, "status": str(project.status or ""), } if project is not None else None ), "charter": ( { "id": int(charter.id), "title": str(charter.title or ""), "status": str(charter.status or ""), "content": charter.content if isinstance(charter.content, dict) else {}, } if charter is not None else None ), "signal_source_count": len(signal_source_rows), "task_summary": { "total_count": len(task_rows), "assigned_count": len([row for row in task_rows if row.assignee_id is not None]), "tasks": [ { "id": int(row.id), "title": str(row.title or ""), "assignee_id": int(row.assignee_id) if row.assignee_id is not None else None, "required_skill_tag_ids": [int(item) for item in (row.required_skill_tag_ids or [])], "ai_recommended_assignees": row.ai_recommended_assignees if isinstance(row.ai_recommended_assignees, dict) else None, } for row in task_rows ], }, } async def main() -> None: if mode == "find_draft": result = await _find_draft() elif mode == "get_work_item": result = await _get_work_item() elif mode == "get_project_artifacts": result = await _get_project_artifacts() else: raise ValueError(f"unsupported mode: {mode}") print(json.dumps(result, ensure_ascii=False)) asyncio.run(main()) `; function envText(name: string): string | undefined { const value = process.env[name]; if (value === undefined) { return undefined; } const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; } function envNumber(name: string, fallback: number): number { const value = envText(name); if (!value) { return fallback; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function envBoolean(name: string, fallback: boolean): boolean { const value = envText(name); if (!value) { return fallback; } if (["1", "true", "yes", "on"].includes(value.toLowerCase())) { return true; } if (["0", "false", "no", "off"].includes(value.toLowerCase())) { return false; } return fallback; } function resolvePythonPath(): string { const configured = envText("PMFLOW_E2E_PYTHON"); if (configured) { return configured; } if (fs.existsSync(DEFAULT_PYTHON)) { return DEFAULT_PYTHON; } return "python3"; } function readConfig(): RuntimeConfig { return { artifactTimeoutMs: envNumber("PMFLOW_E2E_ARTIFACT_TIMEOUT_MS", 10 * 60 * 1000), browserExecutable: envText("FEISHU_BROWSER_EXECUTABLE") ?? DEFAULT_CHROME_EXECUTABLE, chatName: envText("FEISHU_CHAT_NAME"), chatUrl: envText("FEISHU_CHAT_URL"), draftTimeoutMs: envNumber("PMFLOW_E2E_DRAFT_TIMEOUT_MS", 4 * 60 * 1000), feishuBaseUrl: envText("FEISHU_WEB_URL") ?? "https://feishu.cn/messages/", headless: envBoolean("PLAYWRIGHT_HEADLESS", false), pollIntervalMs: envNumber("PMFLOW_E2E_POLL_INTERVAL_MS", POLL_INTERVAL_MS), profileDirectory: envText("FEISHU_PROFILE_DIRECTORY") ?? "Default", pythonPath: resolvePythonPath(), sendShortcut: envText("FEISHU_SEND_SHORTCUT") ?? "Enter", slowMoMs: envNumber("PLAYWRIGHT_SLOW_MO_MS", 100), tenantId: envNumber("PMFLOW_TENANT_ID", 1), testTimeoutMs: envNumber("PMFLOW_E2E_TEST_TIMEOUT_MS", 15 * 60 * 1000), userDataDir: envText("FEISHU_USER_DATA_DIR") ?? DEFAULT_CHROME_PROFILE_ROOT, }; } function cloneChromeProfile(config: RuntimeConfig): string { const cloneRoot = path.join(FRONTEND_ROOT, ".playwright-profiles", `chrome-clone-${Date.now()}`); fs.mkdirSync(cloneRoot, { recursive: true }); const entriesToCopy = ["Local State", config.profileDirectory ?? "Default"]; for (const entryName of entriesToCopy) { const sourcePath = path.join(config.userDataDir, entryName); if (!fs.existsSync(sourcePath)) { continue; } const targetPath = path.join(cloneRoot, entryName); fs.cpSync(sourcePath, targetPath, { recursive: true, filter: (source) => { const basename = path.basename(source); return !basename.startsWith("Singleton") && !basename.endsWith(".lock"); }, }); } return cloneRoot; } async function isContextUsable( context: Awaited>, ): Promise { try { const page = context.pages()[0] ?? (await context.newPage()); await page.waitForTimeout(250); return !page.isClosed(); } catch { return false; } } async function launchFeishuContext(config: RuntimeConfig): Promise { const launchOptions = { args: config.profileDirectory ? [`--profile-directory=${config.profileDirectory}`] : [], executablePath: config.browserExecutable, headless: config.headless, slowMo: config.slowMoMs, viewport: { width: 1440, height: 960 }, } as const; try { const context = await chromium.launchPersistentContext(config.userDataDir, launchOptions); if (!(await isContextUsable(context))) { await context.close().catch(() => undefined); const clonedUserDataDir = cloneChromeProfile(config); const clonedContext = await chromium.launchPersistentContext(clonedUserDataDir, launchOptions); return { context: clonedContext, launchMode: "cloned_profile", userDataDir: clonedUserDataDir, }; } return { context, launchMode: "direct", userDataDir: config.userDataDir, }; } catch (error) { const message = String(error instanceof Error ? error.message : error); if (!message.includes("ProcessSingleton")) { throw error; } const clonedUserDataDir = cloneChromeProfile(config); const context = await chromium.launchPersistentContext(clonedUserDataDir, launchOptions); return { context, launchMode: "cloned_profile", userDataDir: clonedUserDataDir, }; } } async function captureScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { const targetPath = testInfo.outputPath(`${name}.png`); await page.screenshot({ fullPage: true, path: targetPath }); await testInfo.attach(name, { body: await fs.promises.readFile(targetPath), contentType: "image/png", }); } async function runDbProbe(config: RuntimeConfig, payload: Record): Promise { const stdout = execFileSync( config.pythonPath, ["-c", DB_HELPER, PROJECT_ROOT], { cwd: PROJECT_ROOT, env: process.env, input: JSON.stringify(payload), maxBuffer: 10 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", }, ); return JSON.parse(stdout.trim()) as T; } async function pollUntil( label: string, timeoutMs: number, pollIntervalMs: number, probe: () => Promise, predicate: (value: T) => boolean, ): Promise { const deadline = Date.now() + timeoutMs; let lastValue: T | undefined; while (Date.now() < deadline) { lastValue = await probe(); if (predicate(lastValue)) { return lastValue; } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } throw new Error(`${label} timed out after ${timeoutMs}ms. Last value: ${JSON.stringify(lastValue, null, 2)}`); } async function pickBottomComposerOrNull(page: Page): Promise | null> { const candidates = await page.locator('textarea, [contenteditable="true"]').elementHandles(); let bestHandle: ElementHandle | null = null; let bestBottom = -1; for (const candidate of candidates) { const asHtml = candidate as ElementHandle; const box = await asHtml.boundingBox(); if (!box || box.width < 60 || box.height < 16) { continue; } const visible = await asHtml.isVisible(); if (!visible) { continue; } const bottom = box.y + box.height; if (bottom > bestBottom) { bestBottom = bottom; bestHandle = asHtml; } } return bestHandle; } async function pickBottomComposer(page: Page): Promise> { const bestHandle = await pickBottomComposerOrNull(page); if (!bestHandle) { throw new Error("No visible Feishu message composer was found."); } return bestHandle; } async function waitForVisibleComposer(page: Page, timeoutMs = 12_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await pickBottomComposerOrNull(page)) { return true; } await page.waitForTimeout(500); } return false; } async function sendChatMessage(page: Page, message: string, sendShortcut: string): Promise { const composer = await pickBottomComposer(page); const isTextArea = await composer.evaluate((node) => node.tagName.toLowerCase() === "textarea"); await composer.click({ force: true }); const selectAllShortcut = process.platform === "darwin" ? "Meta+A" : "Control+A"; await page.keyboard.press(selectAllShortcut).catch(() => undefined); await page.keyboard.press("Backspace").catch(() => undefined); if (isTextArea) { await composer.fill(message); } else { await page.keyboard.type(message, { delay: 18 }); } await page.keyboard.press(sendShortcut); } async function dismissSoftOverlays(page: Page): Promise { await page.keyboard.press("Escape").catch(() => undefined); const softButtons = ["知道了", "我知道了", "稍后", "下次再说"]; for (const label of softButtons) { const button = page.getByRole("button", { name: label }).first(); if (await button.isVisible().catch(() => false)) { await button.click().catch(() => undefined); } } } async function assertLoggedIn(page: Page, testInfo: TestInfo): Promise { const loginSignals = [ "text=扫码登录", "text=密码登录", "text=Log In With QR Code", "text=Switch to Lark to log in", "text=欢迎使用 Lark", "text=切换至飞书登录", "input[type='password']", ]; for (const selector of loginSignals) { const matched = await page.locator(selector).first().isVisible().catch(() => false); if (matched) { await captureScreenshot(page, testInfo, "feishu-login-required"); throw new Error("Feishu web is not logged in. Please provide a logged-in Chrome profile via FEISHU_USER_DATA_DIR / FEISHU_PROFILE_DIRECTORY."); } } } async function readChatCandidates(page: Page, desiredName?: string): Promise { return page.evaluate((inputDesiredName) => { const normalizedDesiredName = String(inputDesiredName || "").trim().toLowerCase(); const feedState = ( globalThis as typeof globalThis & { __feedStore?: { getState?: () => { previews?: Record>; }; }; } ).__feedStore?.getState?.(); const previews = Object.values(feedState?.previews || {}); const matches = previews .map((item) => { const displayName = String(item.name || "").trim(); const feedId = String(item.id || item.feedId || item.chatId || "").trim(); const activationChatId = String(item.chatId || item.id || item.feedId || "").trim(); const normalizedName = displayName.toLowerCase(); const exactDesiredMatch = normalizedDesiredName.length > 0 && normalizedName === normalizedDesiredName; const fuzzyDesiredMatch = normalizedDesiredName.length > 0 && normalizedName.includes(normalizedDesiredName); const pmflowMatch = /pmflow/i.test(displayName); return { activationChatId, chatType: Number(item.chatType || 0), displayName, exactDesiredMatch, feedId, fuzzyDesiredMatch, isBot: Boolean(item.isBot), isP2P: Boolean(item.isP2P), pmflowMatch, rankTime: Number(item.rankTime || 0), type: Number(item.type || 0), }; }) .filter((item) => item.displayName.length > 0 && item.activationChatId.length > 0) .filter((item) => item.type === 1 || item.exactDesiredMatch || item.fuzzyDesiredMatch || item.pmflowMatch) .sort((left, right) => { if (left.exactDesiredMatch !== right.exactDesiredMatch) { return left.exactDesiredMatch ? -1 : 1; } if (left.fuzzyDesiredMatch !== right.fuzzyDesiredMatch) { return left.fuzzyDesiredMatch ? -1 : 1; } if (left.pmflowMatch !== right.pmflowMatch) { return left.pmflowMatch ? -1 : 1; } if (left.isP2P !== right.isP2P) { return left.isP2P ? 1 : -1; } if (left.isBot !== right.isBot) { return left.isBot ? 1 : -1; } return right.rankTime - left.rankTime; }) .slice(0, 8); return matches.map(({ activationChatId, chatType, displayName, feedId, isBot, isP2P, type }) => ({ activationChatId, chatType, displayName, feedId, isBot, isP2P, type, })); }, desiredName); } async function waitForChatCandidates(page: Page, desiredName?: string, timeoutMs = 12_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const candidates = await readChatCandidates(page, desiredName); if (candidates.length > 0) { return candidates; } await page.waitForTimeout(1_000); } return []; } async function primeMessengerConversationList(page: Page, candidate: FeishuChatCandidate): Promise { await page.evaluate(async ({ activationChatId }) => { const messengerApi = ( globalThis as typeof globalThis & { imsdk_vendors_lib?: { larkBizApi?: { common_biz_messenger?: { requestOpenChatSubPage?: (payload: { chatId: string }) => Promise; }; }; }; } ).imsdk_vendors_lib?.larkBizApi?.common_biz_messenger; await messengerApi?.requestOpenChatSubPage?.({ chatId: activationChatId }); }, { activationChatId: candidate.activationChatId }); await page.waitForTimeout(2_500); } async function activateChatCandidate( page: Page, candidates: FeishuChatCandidate[], testInfo: TestInfo, ): Promise { for (const candidate of candidates) { await primeMessengerConversationList(page, candidate); const locators = [ page.getByText(candidate.displayName, { exact: true }).first(), page.getByText(candidate.displayName, { exact: false }).first(), ]; for (const locator of locators) { if (!(await locator.isVisible().catch(() => false))) { continue; } await locator.click(); if (await waitForVisibleComposer(page)) { return candidate; } } } await captureScreenshot(page, testInfo, "feishu-chat-activation-failed"); throw new Error(`Unable to activate any Feishu chat candidate: ${JSON.stringify(candidates, null, 2)}`); } async function openTargetConversation(page: Page, config: RuntimeConfig, testInfo: TestInfo): Promise { const entryUrl = config.chatUrl ?? config.feishuBaseUrl; await page.goto(entryUrl, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(2_000); await dismissSoftOverlays(page); const switchToLark = page.getByText("Switch to Lark to log in").first(); if (await switchToLark.isVisible().catch(() => false)) { await switchToLark.click(); await page.waitForTimeout(2_000); await dismissSoftOverlays(page); } await assertLoggedIn(page, testInfo); if (await waitForVisibleComposer(page, 2_000)) { return null; } const chatCandidates = await waitForChatCandidates(page, config.chatName); if (chatCandidates.length > 0) { return activateChatCandidate(page, chatCandidates, testInfo); } if (!config.chatName) { await captureScreenshot(page, testInfo, "feishu-chat-candidates-not-found"); throw new Error("Unable to detect a target Feishu chat from the messenger store. Configure FEISHU_CHAT_NAME to target a specific conversation."); } const searchSelectors = [ 'input[placeholder*="搜索"]', 'input[placeholder*="Search"]', '[role="searchbox"]', 'textarea[placeholder*="搜索"]', ]; let searchFound = false; for (const selector of searchSelectors) { const locator = page.locator(selector).first(); if (await locator.isVisible().catch(() => false)) { await locator.click(); await locator.fill(config.chatName); searchFound = true; break; } } if (!searchFound) { const modK = process.platform === "darwin" ? "Meta+K" : "Control+K"; await page.keyboard.press(modK).catch(() => undefined); for (const selector of searchSelectors) { const locator = page.locator(selector).first(); if (await locator.isVisible().catch(() => false)) { await locator.fill(config.chatName); searchFound = true; break; } } } if (!searchFound) { await captureScreenshot(page, testInfo, "feishu-search-not-found"); throw new Error("Unable to find the Feishu conversation search box."); } const result = page.getByText(config.chatName, { exact: false }).first(); await result.waitFor({ state: "visible", timeout: 20_000 }); await result.click(); const composerReady = await waitForVisibleComposer(page); if (!composerReady) { await captureScreenshot(page, testInfo, "feishu-composer-not-found-after-search"); throw new Error(`Feishu opened "${config.chatName}" but no visible composer became available.`); } return null; } test("real Feishu web full project-creation flow with draft revisions and assignment", async ({}, testInfo) => { const config = readConfig(); test.setTimeout(config.testTimeoutMs); const summary: Record = { browserExecutable: config.browserExecutable, chatName: config.chatName ?? null, chatUrl: config.chatUrl ?? null, tenantId: config.tenantId, userDataDir: config.userDataDir, }; const token = `FeishuWebFlow-${Date.now()}`; const initialMessage = [ `最近我们做跨境直播复盘太慢了,想做个给运营团队用的小工具,这个需求先叫 ${token}。`, "第一版我希望它能看直播录屏和评论回放,自动切片,帮我们总结亮点和掉点,再给一份可执行复盘清单。", "先不要直接创建,我会再补充几轮信息,最后确认了再入库。", ].join(" "); const launchOutcome = await launchFeishuContext(config); summary.launchMode = launchOutcome.launchMode; summary.actualUserDataDir = launchOutcome.userDataDir; const { context } = launchOutcome; try { const page = context.pages()[0] ?? (await context.newPage()); const selectedChat = await openTargetConversation(page, config, testInfo); summary.selectedChat = selectedChat; await captureScreenshot(page, testInfo, "feishu-conversation-ready"); console.log(`Sending initial Feishu project message for token=${token}`); await sendChatMessage(page, initialMessage, config.sendShortcut); await page.waitForTimeout(3_000); await captureScreenshot(page, testInfo, "feishu-after-initial-message"); const draft = await pollUntil( "initial draft work item", config.draftTimeoutMs, config.pollIntervalMs, () => runDbProbe(config, { mode: "find_draft", tenant_id: config.tenantId, token }), (value) => Boolean(value && value.state === "draft_pending_confirm" && value.draft_version >= 1), ); expect(draft).not.toBeNull(); const workItemId = draft?.id as number; let currentDraftVersion = draft?.draft_version as number; summary.token = token; summary.workItemId = workItemId; summary.initialDraftVersion = currentDraftVersion; const reviseMessages = [ "主要就是给北美年营收 300 万美元以上的 DTC 品牌运营团队用。现在最麻烦的是三件事:直播结束后复盘太慢、好素材很难反复利用、达人投放归因也比较散。", "第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。", "数据先接这几类就够了:Shopify 订单和退款、TikTok Shop 直播数据、飞书群聊里的复盘记录。", "团队先按 4 个人配:1 个 PM、1 个前端、1 个后端、1 个 AI 工程。任务优先找懂直播数据分析、Python 后端和前端可视化的人。成功先看两个数:7 天内复盘时长下降 60%,高频问题复现率下降 30%。", ]; for (const [index, reviseMessage] of reviseMessages.entries()) { console.log(`Sending Feishu revision round ${index + 1} for work_item #${workItemId}`); await sendChatMessage(page, reviseMessage, config.sendShortcut); await page.waitForTimeout(2_500); await captureScreenshot(page, testInfo, `feishu-after-revise-${index + 1}`); const revisedDraft = await pollUntil( `draft revision round ${index + 1}`, config.draftTimeoutMs, config.pollIntervalMs, () => runDbProbe(config, { mode: "get_work_item", tenant_id: config.tenantId, work_item_id: workItemId }), (value) => Boolean(value && value.state === "draft_pending_confirm" && value.draft_version > currentDraftVersion), ); expect(revisedDraft).not.toBeNull(); currentDraftVersion = revisedDraft?.draft_version as number; summary[`revision_${index + 1}_draft_version`] = currentDraftVersion; } console.log(`Confirming work_item #${workItemId} from Feishu web`); await sendChatMessage(page, `work_item #${workItemId} 确认`, config.sendShortcut); await page.waitForTimeout(3_000); await captureScreenshot(page, testInfo, "feishu-after-confirm"); summary.confirmMode = "text_message_only"; const artifacts = await pollUntil( "project artifacts after confirm", config.artifactTimeoutMs, config.pollIntervalMs, () => runDbProbe(config, { mode: "get_project_artifacts", tenant_id: config.tenantId, work_item_id: workItemId }), (value) => Boolean( value.work_item && value.work_item.state === "succeeded" && value.work_item.project_id && value.charter && value.signal_source_count >= 1 && value.task_summary.total_count >= 1 && value.task_summary.assigned_count >= 1, ), ); summary.finalArtifacts = artifacts; await testInfo.attach("feishu-project-flow-summary", { body: Buffer.from(JSON.stringify(summary, null, 2), "utf-8"), contentType: "application/json", }); expect(artifacts.work_item?.state).toBe("succeeded"); expect(artifacts.project?.id).toBeTruthy(); expect( [artifacts.project?.title, artifacts.project?.name, artifacts.project?.description].filter(Boolean).join(" "), ).toContain(token); expect(artifacts.charter?.id).toBeTruthy(); expect(artifacts.signal_source_count).toBeGreaterThanOrEqual(1); expect(artifacts.task_summary.total_count).toBeGreaterThanOrEqual(1); expect(artifacts.task_summary.assigned_count).toBeGreaterThanOrEqual(1); expect( artifacts.task_summary.tasks.some( (task) => task.assignee_id !== null && task.assignee_id !== undefined && task.required_skill_tag_ids.length >= 0, ), ).toBeTruthy(); } finally { await context.close(); } });