223 lines
8.6 KiB
Markdown
223 lines
8.6 KiB
Markdown
# 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<ReturnType<typeof chromium.launchPersistentContext>>,
|
|
396 | ): Promise<boolean> {
|
|
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<LaunchOutcome> {
|
|
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<void> {
|
|
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<T>(config: RuntimeConfig, payload: Record<string, unknown>): Promise<T> {
|
|
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<T>(
|
|
474 | label: string,
|
|
475 | timeoutMs: number,
|
|
476 | pollIntervalMs: number,
|
|
477 | probe: () => Promise<T>,
|
|
478 | predicate: (value: T) => boolean,
|
|
479 | ): Promise<T> {
|
|
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<ElementHandle<HTMLElement> | null> {
|
|
493 | const candidates = await page.locator('textarea, [contenteditable="true"]').elementHandles();
|
|
494 | let bestHandle: ElementHandle<HTMLElement> | null = null;
|
|
495 | let bestBottom = -1;
|
|
496 | for (const candidate of candidates) {
|
|
497 | const asHtml = candidate as ElementHandle<HTMLElement>;
|
|
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<ElementHandle<HTMLElement>> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<FeishuChatCandidate[]> {
|
|
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<string, Record<string, unknown>>;
|
|
587 | };
|
|
588 | };
|
|
589 | }
|
|
``` |