911 lines
32 KiB
TypeScript
911 lines
32 KiB
TypeScript
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();
|
||
}
|
||
});
|