PMFlow/frontend/e2e/feishu-project-full-flow.sp...

911 lines
32 KiB
TypeScript
Raw 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 { 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<ReturnType<typeof chromium.launchPersistentContext>>;
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<string, unknown>;
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<string, unknown> | 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<ReturnType<typeof chromium.launchPersistentContext>>,
): Promise<boolean> {
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<LaunchOutcome> {
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<void> {
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<T>(config: RuntimeConfig, payload: Record<string, unknown>): Promise<T> {
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<T>(
label: string,
timeoutMs: number,
pollIntervalMs: number,
probe: () => Promise<T>,
predicate: (value: T) => boolean,
): Promise<T> {
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<ElementHandle<HTMLElement> | null> {
const candidates = await page.locator('textarea, [contenteditable="true"]').elementHandles();
let bestHandle: ElementHandle<HTMLElement> | null = null;
let bestBottom = -1;
for (const candidate of candidates) {
const asHtml = candidate as ElementHandle<HTMLElement>;
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<ElementHandle<HTMLElement>> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<FeishuChatCandidate[]> {
return page.evaluate((inputDesiredName) => {
const normalizedDesiredName = String(inputDesiredName || "").trim().toLowerCase();
const feedState = (
globalThis as typeof globalThis & {
__feedStore?: {
getState?: () => {
previews?: Record<string, Record<string, unknown>>;
};
};
}
).__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<FeishuChatCandidate[]> {
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<void> {
await page.evaluate(async ({ activationChatId }) => {
const messengerApi = (
globalThis as typeof globalThis & {
imsdk_vendors_lib?: {
larkBizApi?: {
common_biz_messenger?: {
requestOpenChatSubPage?: (payload: { chatId: string }) => Promise<unknown>;
};
};
};
}
).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<FeishuChatCandidate> {
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<FeishuChatCandidate | null> {
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<string, unknown> = {
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<DraftSnapshot | null>(
"initial draft work item",
config.draftTimeoutMs,
config.pollIntervalMs,
() => runDbProbe<DraftSnapshot | null>(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<DraftSnapshot | null>(
`draft revision round ${index + 1}`,
config.draftTimeoutMs,
config.pollIntervalMs,
() => runDbProbe<DraftSnapshot | null>(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<ProjectArtifacts>(
"project artifacts after confirm",
config.artifactTimeoutMs,
config.pollIntervalMs,
() => runDbProbe<ProjectArtifacts>(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();
}
});