# Instructions - Following Playwright test failed. - Explain why, be concise, respect Playwright best practices. - Provide a snippet of code with the fix, if possible. # Test info - Name: feishu-project-full-flow.spec.ts >> real Feishu web full project-creation flow with draft revisions and assignment - Location: e2e/feishu-project-full-flow.spec.ts:785:1 # Error details ``` Error: initial draft work item timed out after 240000ms. Last value: null ``` # Test source ```ts 389 | } 390 | 391 | return cloneRoot; 392 | } 393 | 394 | async function isContextUsable( 395 | context: Awaited>, 396 | ): Promise { 397 | try { 398 | const page = context.pages()[0] ?? (await context.newPage()); 399 | await page.waitForTimeout(250); 400 | return !page.isClosed(); 401 | } catch { 402 | return false; 403 | } 404 | } 405 | 406 | async function launchFeishuContext(config: RuntimeConfig): Promise { 407 | const launchOptions = { 408 | args: config.profileDirectory ? [`--profile-directory=${config.profileDirectory}`] : [], 409 | executablePath: config.browserExecutable, 410 | headless: config.headless, 411 | slowMo: config.slowMoMs, 412 | viewport: { width: 1440, height: 960 }, 413 | } as const; 414 | 415 | try { 416 | const context = await chromium.launchPersistentContext(config.userDataDir, launchOptions); 417 | if (!(await isContextUsable(context))) { 418 | await context.close().catch(() => undefined); 419 | const clonedUserDataDir = cloneChromeProfile(config); 420 | const clonedContext = await chromium.launchPersistentContext(clonedUserDataDir, launchOptions); 421 | return { 422 | context: clonedContext, 423 | launchMode: "cloned_profile", 424 | userDataDir: clonedUserDataDir, 425 | }; 426 | } 427 | return { 428 | context, 429 | launchMode: "direct", 430 | userDataDir: config.userDataDir, 431 | }; 432 | } catch (error) { 433 | const message = String(error instanceof Error ? error.message : error); 434 | if (!message.includes("ProcessSingleton")) { 435 | throw error; 436 | } 437 | 438 | const clonedUserDataDir = cloneChromeProfile(config); 439 | const context = await chromium.launchPersistentContext(clonedUserDataDir, launchOptions); 440 | return { 441 | context, 442 | launchMode: "cloned_profile", 443 | userDataDir: clonedUserDataDir, 444 | }; 445 | } 446 | } 447 | 448 | async function captureScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { 449 | const targetPath = testInfo.outputPath(`${name}.png`); 450 | await page.screenshot({ fullPage: true, path: targetPath }); 451 | await testInfo.attach(name, { 452 | body: await fs.promises.readFile(targetPath), 453 | contentType: "image/png", 454 | }); 455 | } 456 | 457 | async function runDbProbe(config: RuntimeConfig, payload: Record): Promise { 458 | const stdout = execFileSync( 459 | config.pythonPath, 460 | ["-c", DB_HELPER, PROJECT_ROOT], 461 | { 462 | cwd: PROJECT_ROOT, 463 | env: process.env, 464 | input: JSON.stringify(payload), 465 | maxBuffer: 10 * 1024 * 1024, 466 | stdio: ["pipe", "pipe", "pipe"], 467 | encoding: "utf-8", 468 | }, 469 | ); 470 | return JSON.parse(stdout.trim()) as T; 471 | } 472 | 473 | async function pollUntil( 474 | label: string, 475 | timeoutMs: number, 476 | pollIntervalMs: number, 477 | probe: () => Promise, 478 | predicate: (value: T) => boolean, 479 | ): Promise { 480 | const deadline = Date.now() + timeoutMs; 481 | let lastValue: T | undefined; 482 | while (Date.now() < deadline) { 483 | lastValue = await probe(); 484 | if (predicate(lastValue)) { 485 | return lastValue; 486 | } 487 | await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); 488 | } > 489 | throw new Error(`${label} timed out after ${timeoutMs}ms. Last value: ${JSON.stringify(lastValue, null, 2)}`); | ^ Error: initial draft work item timed out after 240000ms. Last value: null 490 | } 491 | 492 | async function pickBottomComposerOrNull(page: Page): Promise | null> { 493 | const candidates = await page.locator('textarea, [contenteditable="true"]').elementHandles(); 494 | let bestHandle: ElementHandle | null = null; 495 | let bestBottom = -1; 496 | for (const candidate of candidates) { 497 | const asHtml = candidate as ElementHandle; 498 | const box = await asHtml.boundingBox(); 499 | if (!box || box.width < 60 || box.height < 16) { 500 | continue; 501 | } 502 | const visible = await asHtml.isVisible(); 503 | if (!visible) { 504 | continue; 505 | } 506 | const bottom = box.y + box.height; 507 | if (bottom > bestBottom) { 508 | bestBottom = bottom; 509 | bestHandle = asHtml; 510 | } 511 | } 512 | return bestHandle; 513 | } 514 | 515 | async function pickBottomComposer(page: Page): Promise> { 516 | const bestHandle = await pickBottomComposerOrNull(page); 517 | if (!bestHandle) { 518 | throw new Error("No visible Feishu message composer was found."); 519 | } 520 | return bestHandle; 521 | } 522 | 523 | async function waitForVisibleComposer(page: Page, timeoutMs = 12_000): Promise { 524 | const deadline = Date.now() + timeoutMs; 525 | while (Date.now() < deadline) { 526 | if (await pickBottomComposerOrNull(page)) { 527 | return true; 528 | } 529 | await page.waitForTimeout(500); 530 | } 531 | return false; 532 | } 533 | 534 | async function sendChatMessage(page: Page, message: string, sendShortcut: string): Promise { 535 | const composer = await pickBottomComposer(page); 536 | const isTextArea = await composer.evaluate((node) => node.tagName.toLowerCase() === "textarea"); 537 | await composer.click({ force: true }); 538 | const selectAllShortcut = process.platform === "darwin" ? "Meta+A" : "Control+A"; 539 | await page.keyboard.press(selectAllShortcut).catch(() => undefined); 540 | await page.keyboard.press("Backspace").catch(() => undefined); 541 | if (isTextArea) { 542 | await composer.fill(message); 543 | } else { 544 | await page.keyboard.type(message, { delay: 18 }); 545 | } 546 | await page.keyboard.press(sendShortcut); 547 | } 548 | 549 | async function dismissSoftOverlays(page: Page): Promise { 550 | await page.keyboard.press("Escape").catch(() => undefined); 551 | const softButtons = ["知道了", "我知道了", "稍后", "下次再说"]; 552 | for (const label of softButtons) { 553 | const button = page.getByRole("button", { name: label }).first(); 554 | if (await button.isVisible().catch(() => false)) { 555 | await button.click().catch(() => undefined); 556 | } 557 | } 558 | } 559 | 560 | async function assertLoggedIn(page: Page, testInfo: TestInfo): Promise { 561 | const loginSignals = [ 562 | "text=扫码登录", 563 | "text=密码登录", 564 | "text=Log In With QR Code", 565 | "text=Switch to Lark to log in", 566 | "text=欢迎使用 Lark", 567 | "text=切换至飞书登录", 568 | "input[type='password']", 569 | ]; 570 | for (const selector of loginSignals) { 571 | const matched = await page.locator(selector).first().isVisible().catch(() => false); 572 | if (matched) { 573 | await captureScreenshot(page, testInfo, "feishu-login-required"); 574 | throw new Error("Feishu web is not logged in. Please provide a logged-in Chrome profile via FEISHU_USER_DATA_DIR / FEISHU_PROFILE_DIRECTORY."); 575 | } 576 | } 577 | } 578 | 579 | async function readChatCandidates(page: Page, desiredName?: string): Promise { 580 | return page.evaluate((inputDesiredName) => { 581 | const normalizedDesiredName = String(inputDesiredName || "").trim().toLowerCase(); 582 | const feedState = ( 583 | globalThis as typeof globalThis & { 584 | __feedStore?: { 585 | getState?: () => { 586 | previews?: Record>; 587 | }; 588 | }; 589 | } ```