下一代 QA:在大型 Java 既有專案中實現 AI 驅動的自主多輪驗收測試

文件性質:這是一份技術實作計畫,用於評估在 Java 既有專案中導入 AI 自主測試的可行性。文中的架構設計基於現有工具的能力推演,尚未經過大規模生產驗證。建議先執行 PoC 驗證核心假設後,再決定是否全面導入。


目錄

  1. 專案目標與範圍
  2. 核心概念:從自動化到自主化
  3. 技術選型與版本
  4. 專案設定
  5. 核心組件實作
  6. 三層驗證迴圈:狀態機設計
  7. Prompt Engineering
  8. 整合層實作
  9. 評估框架與指標
  10. 導入計畫與里程碑
  11. 風險評估與緩解策略
  12. 成本估算
  13. 決策檢查點

一、專案目標與範圍

1.1 要解決的問題

在大型 Java 既有專案中,我們面臨以下測試困境:

問題 現狀 影響
測試覆蓋率不足 單元測試 60%,E2E 測試僅覆蓋 Happy Path 生產環境頻繁出現邊界條件 Bug
測試腳本脆弱 前端 DOM 變更導致 30% 的 Selenium 測試失效 每次 UI 改版需 2-3 天修復測試
診斷效率低 測試失敗後平均需 2 小時定位根因 開發者時間大量消耗在 Debug
Flaky Tests 約 15% 的測試間歇性失敗 CI/CD 信心不足,經常手動重跑

1.2 專案目標

Phase 1 目標(PoC,8 週)

  • 建立 AI 診斷輔助系統,自動分析測試失敗的根因
  • 目標:診斷準確率 > 80%,平均診斷時間 < 30 秒

Phase 2 目標(MVP,12 週)

  • 實現視覺定位能力,減少因 DOM 變更導致的測試失效
  • 目標:測試腳本存活率從 70% 提升到 95%

Phase 3 目標(Production,16 週)

  • 實現自主探索測試,發現人工未覆蓋的邊界場景
  • 目標:新發現 Bug 數量 > 10 個/月

1.3 不在範圍內

  • 效能測試(Load Testing)
  • 安全性滲透測試
  • 行動裝置 App 測試(僅限 Web)
  • 取代現有的單元測試與整合測試

二、核心概念:從自動化到自主化

2.1 傳統自動化 vs AI 自主化

傳統自動化(命令式):
  開發者定義 → 點擊 #login-btn → 等待 2s → 斷言 URL 包含 /dashboard

  問題:
  - 路徑固定,無法處理意外情況
  - 元素定位脆弱,DOM 變更即失效
  - 失敗時只有 Exception,無診斷能力

AI 自主化(宣告式):
  開發者定義 → 目標:以 VIP 身份完成購物,使用折價券,驗證金額

  AI 行為:
  - 自行規劃達成目標的路徑
  - 遇到障礙時嘗試替代方案
  - 失敗時自動調查根因

2.2 三層驗證迴圈概念

┌─────────────────────────────────────────────────────────────┐
│                    探索迴圈 (Exploration)                    │
│  ┌─────────────────────────────────────────────────────┐   │
│  │                診斷迴圈 (Diagnosis)                  │   │
│  │  ┌─────────────────────────────────────────────┐   │   │
│  │  │           穩定性迴圈 (Stability)             │   │   │
│  │  │                                              │   │   │
│  │  │   執行單一測試步驟                           │   │   │
│  │  │   ↓                                          │   │   │
│  │  │   失敗 → 是環境問題?→ 重試 N 次             │   │   │
│  │  │                                              │   │   │
│  │  └─────────────────────────────────────────────┘   │   │
│  │                      ↓                              │   │
│  │   仍然失敗 → 收集證據 → 分析根因 → 生成報告        │   │
│  │                                                     │   │
│  └─────────────────────────────────────────────────────┘   │
│                          ↓                                  │
│   完成當前路徑 → 尋找下一個探索目標 → 發現新的測試場景     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

三、技術選型與版本

3.1 核心技術棧

組件 技術選擇 版本 選擇理由
LLM 編排 LangChain4j 0.35.0 Java 原生、Spring Boot 整合佳、Tool Calling 支援完整
LLM 模型 GPT-4o 2024-08-06 視覺能力強、推理穩定、Function Calling 支援最佳
備用模型 GPT-4o-mini 2024-07-18 成本較低,用於簡單判斷
瀏覽器自動化 Playwright 1.48.0 比 Selenium 更穩定、支援多瀏覽器、Java 官方支援
測試容器 Testcontainers 1.20.3 資料庫隔離、環境一致性
日誌收集 Micrometer + OTLP 1.13.0 與 Spring Boot 整合、支援 TraceId 串聯

3.2 相依性相容性矩陣

Spring Boot 3.3.x
├── Java 21 (required)
├── LangChain4j 0.35.0
│   └── langchain4j-open-ai 0.35.0
│   └── langchain4j-spring-boot-starter 0.35.0
├── Playwright 1.48.0
│   └── playwright-java 1.48.0
└── Testcontainers 1.20.3
    └── postgresql 1.20.3

四、專案設定

4.1 Maven pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>ai-qa-agent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.5</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>21</java.version>
        <langchain4j.version>0.35.0</langchain4j.version>
        <playwright.version>1.48.0</playwright.version>
        <testcontainers.version>1.20.3</testcontainers.version>
    </properties>

    <dependencies>
        <!-- Spring Boot -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- LangChain4j -->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-spring-boot-starter</artifactId>
            <version>${langchain4j.version}</version>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-open-ai</artifactId>
            <version>${langchain4j.version}</version>
        </dependency>

        <!-- Playwright -->
        <dependency>
            <groupId>com.microsoft.playwright</groupId>
            <artifactId>playwright</artifactId>
            <version>${playwright.version}</version>
        </dependency>

        <!-- Testcontainers -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>${testcontainers.version}</version>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <version>${testcontainers.version}</version>
        </dependency>

        <!-- Observability -->
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-tracing-bridge-otel</artifactId>
        </dependency>
        <dependency>
            <groupId>io.opentelemetry</groupId>
            <artifactId>opentelemetry-exporter-otlp</artifactId>
        </dependency>

        <!-- Utilities -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <!-- Playwright 需要先安裝瀏覽器 -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>install-playwright-browsers</id>
                        <phase>generate-resources</phase>
                        <goals>
                            <goal>java</goal>
                        </goals>
                        <configuration>
                            <mainClass>com.microsoft.playwright.CLI</mainClass>
                            <arguments>
                                <argument>install</argument>
                                <argument>chromium</argument>
                            </arguments>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

4.2 application.yml

spring:
  application:
    name: ai-qa-agent

langchain4j:
  open-ai:
    chat-model:
      api-key: ${OPENAI_API_KEY}
      model-name: gpt-4o
      temperature: 0.1  # 測試場景需要穩定輸出
      timeout: PT60S    # 視覺分析可能較慢
      max-retries: 3
      log-requests: true
      log-responses: true

# AI QA Agent 配置
ai-qa:
  browser:
    headless: true
    viewport-width: 1280
    viewport-height: 720
    timeout-ms: 30000

  loops:
    stability:
      max-retries: 3
      retry-delay-ms: 1000
      flakiness-threshold: 0.8  # 80% 以上成功視為 flaky
    diagnosis:
      collect-screenshot: true
      collect-console-logs: true
      collect-network-logs: true
      max-log-lines: 500
    exploration:
      max-depth: 10
      max-actions-per-page: 20

  cost:
    budget-per-test-usd: 0.50
    budget-per-day-usd: 100.00

  reporting:
    output-dir: ./test-reports
    screenshot-format: png

# 目標系統配置
target:
  base-url: ${TARGET_BASE_URL:http://localhost:8080}
  api-base-url: ${TARGET_API_URL:http://localhost:8080/api}

# Actuator(用於診斷迴圈收集後端 Log)
management:
  endpoints:
    web:
      exposure:
        include: health,info,loggers,trace
  tracing:
    sampling:
      probability: 1.0

4.3 專案結構

ai-qa-agent/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/com/example/aiqaagent/
│   │   │   ├── AiQaAgentApplication.java
│   │   │   ├── config/
│   │   │   │   ├── OpenAiConfig.java
│   │   │   │   ├── PlaywrightConfig.java
│   │   │   │   └── AgentConfig.java
│   │   │   ├── agent/
│   │   │   │   ├── AutonomousTester.java          # AI Agent 介面
│   │   │   │   ├── TestExecutionContext.java      # 執行上下文
│   │   │   │   └── TestReport.java                # 測試報告
│   │   │   ├── tools/
│   │   │   │   ├── BrowserTools.java              # 瀏覽器操作
│   │   │   │   ├── VisionTools.java               # 視覺定位
│   │   │   │   ├── DiagnosticTools.java           # 診斷工具
│   │   │   │   └── DataTools.java                 # 測試資料
│   │   │   ├── loops/
│   │   │   │   ├── LoopStateMachine.java          # 狀態機
│   │   │   │   ├── StabilityLoop.java             # 穩定性迴圈
│   │   │   │   ├── DiagnosisLoop.java             # 診斷迴圈
│   │   │   │   └── ExplorationLoop.java           # 探索迴圈
│   │   │   ├── model/
│   │   │   │   ├── TestGoal.java
│   │   │   │   ├── TestStep.java
│   │   │   │   ├── TestEvidence.java
│   │   │   │   └── DiagnosisResult.java
│   │   │   └── service/
│   │   │       ├── TestOrchestratorService.java
│   │   │       ├── EvidenceCollectorService.java
│   │   │       └── ReportGeneratorService.java
│   │   └── resources/
│   │       ├── application.yml
│   │       └── prompts/
│   │           ├── system-prompt.txt
│   │           ├── vision-locate-element.txt
│   │           ├── diagnosis-analyze.txt
│   │           └── exploration-next-action.txt
│   └── test/
│       └── java/com/example/aiqaagent/
│           ├── integration/
│           └── unit/
└── test-reports/

五、核心組件實作

5.1 OpenAI 配置

package com.example.aiqaagent.config;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
public class OpenAiConfig {

    @Value("${langchain4j.open-ai.chat-model.api-key}")
    private String apiKey;

    /**
     * 主要模型:GPT-4o,用於複雜推理和視覺分析
     */
    @Bean
    public ChatLanguageModel primaryChatModel() {
        return OpenAiChatModel.builder()
                .apiKey(apiKey)
                .modelName("gpt-4o")
                .temperature(0.1) // 測試場景需要穩定輸出
                .timeout(Duration.ofSeconds(60)) // 視覺分析可能較慢
                .maxRetries(3)
                .logRequests(true)
                .logResponses(true)
                .build();
    }

    /**
     * 輕量模型:GPT-4o-mini,用於簡單判斷以節省成本
     */
    @Bean
    public ChatLanguageModel lightweightChatModel() {
        return OpenAiChatModel.builder()
                .apiKey(apiKey)
                .modelName("gpt-4o-mini")
                .temperature(0.1)
                .timeout(Duration.ofSeconds(30))
                .maxRetries(3)
                .build();
    }
}

5.2 Playwright 配置與生命週期管理

package com.example.aiqaagent.config;

import com.microsoft.playwright.*;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
public class PlaywrightConfig {

    @Value("${ai-qa.browser.headless:true}")
    private boolean headless;

    @Value("${ai-qa.browser.viewport-width:1280}")
    private int viewportWidth;

    @Value("${ai-qa.browser.viewport-height:720}")
    private int viewportHeight;

    @Value("${ai-qa.browser.timeout-ms:30000}")
    private int timeoutMs;

    private Playwright playwright;
    private Browser browser;

    @Getter
    private volatile BrowserContext currentContext;

    @Getter
    private volatile Page currentPage;

    /**
     * 初始化瀏覽器(延遲初始化,避免不必要的資源消耗)
     */
    public synchronized void initialize() {
        if (playwright == null) {
            log.info("Initializing Playwright...");
            playwright = Playwright.create();
            browser = playwright.chromium().launch(
                new BrowserType.LaunchOptions()
                    .setHeadless(headless)
                    .setTimeout(timeoutMs)
            );
            log.info("Playwright initialized successfully");
        }
    }

    /**
     * 為每個測試建立新的 Context 和 Page(隔離 Cookie、LocalStorage)
     */
    public Page createNewPage() {
        initialize();

        // 關閉舊的 Context
        if (currentContext != null) {
            currentContext.close();
        }

        currentContext = browser.newContext(
            new Browser.NewContextOptions()
                .setViewportSize(viewportWidth, viewportHeight)
                .setRecordVideoDir(java.nio.file.Paths.get("test-reports/videos"))
        );

        // 啟用 Console 和 Network 日誌收集
        currentPage = currentContext.newPage();

        currentPage.onConsoleMessage(msg ->
            log.debug("[Browser Console] {}: {}", msg.type(), msg.text())
        );

        currentPage.onPageError(error ->
            log.error("[Browser Error] {}", error)
        );

        return currentPage;
    }

    /**
     * 截取當前頁面截圖(Base64 編碼)
     */
    public String captureScreenshotBase64() {
        if (currentPage == null) {
            throw new IllegalStateException("No active page");
        }
        byte[] screenshot = currentPage.screenshot(
            new Page.ScreenshotOptions().setFullPage(false)
        );
        return java.util.Base64.getEncoder().encodeToString(screenshot);
    }

    /**
     * 收集瀏覽器 Console 日誌
     */
    public java.util.List<String> getConsoleLogs() {
        // 需要在 Page 建立時註冊 listener 收集
        // 這裡返回累積的日誌
        return consoleLogBuffer;
    }

    private final java.util.List<String> consoleLogBuffer =
        java.util.Collections.synchronizedList(new java.util.ArrayList<>());

    @PreDestroy
    public void cleanup() {
        log.info("Cleaning up Playwright resources...");
        if (currentContext != null) {
            currentContext.close();
        }
        if (browser != null) {
            browser.close();
        }
        if (playwright != null) {
            playwright.close();
        }
    }
}

5.3 瀏覽器操作 Tools

package com.example.aiqaagent.tools;

import com.example.aiqaagent.config.PlaywrightConfig;
import com.microsoft.playwright.*;
import dev.langchain4j.agent.tool.Tool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class BrowserTools {

    private final PlaywrightConfig playwrightConfig;

    @Tool("開啟指定的 URL。回傳頁面標題。")
    public String navigateTo(String url) {
        log.info("Navigating to: {}", url);
        Page page = playwrightConfig.getCurrentPage();

        Response response = page.navigate(url,
            new Page.NavigateOptions().setTimeout(30000)
        );

        if (response != null && !response.ok()) {
            return String.format("頁面載入失敗,HTTP 狀態碼: %d", response.status());
        }

        // 等待頁面穩定
        page.waitForLoadState(LoadState.NETWORKIDLE);

        return String.format("成功載入頁面,標題: %s", page.title());
    }

    @Tool("點擊包含指定文字的按鈕或連結。文字需完全匹配或部分匹配。")
    public String clickByText(String text) {
        log.info("Clicking element with text: {}", text);
        Page page = playwrightConfig.getCurrentPage();

        try {
            Locator locator = page.getByText(text,
                new Page.GetByTextOptions().setExact(false)
            );

            // 確保元素可見且可點擊
            locator.first().waitFor(new Locator.WaitForOptions()
                .setState(WaitForSelectorState.VISIBLE)
                .setTimeout(5000)
            );

            locator.first().click();

            // 等待可能的頁面變化
            page.waitForTimeout(500);

            return String.format("成功點擊包含 '%s' 的元素", text);
        } catch (TimeoutError e) {
            return String.format("找不到包含 '%s' 的可點擊元素", text);
        }
    }

    @Tool("使用 CSS Selector 點擊元素")
    public String clickBySelector(String selector) {
        log.info("Clicking element with selector: {}", selector);
        Page page = playwrightConfig.getCurrentPage();

        try {
            page.click(selector, new Page.ClickOptions().setTimeout(5000));
            page.waitForTimeout(500);
            return String.format("成功點擊 selector: %s", selector);
        } catch (TimeoutError e) {
            return String.format("找不到 selector: %s", selector);
        }
    }

    @Tool("在指定的輸入框中填入文字。selector 是 CSS 選擇器或 placeholder 文字。")
    public String fillInput(String selectorOrPlaceholder, String value) {
        log.info("Filling input {} with value: {}", selectorOrPlaceholder, value);
        Page page = playwrightConfig.getCurrentPage();

        try {
            // 先嘗試 CSS selector
            Locator locator = page.locator(selectorOrPlaceholder);
            if (locator.count() == 0) {
                // 嘗試用 placeholder 找
                locator = page.getByPlaceholder(selectorOrPlaceholder);
            }

            locator.first().fill(value);
            return String.format("成功在 '%s' 輸入 '%s'", selectorOrPlaceholder, value);
        } catch (Exception e) {
            return String.format("無法填入輸入框: %s", e.getMessage());
        }
    }

    @Tool("取得當前頁面上所有可見的可互動元素(按鈕、連結、輸入框)")
    public String getInteractiveElements() {
        Page page = playwrightConfig.getCurrentPage();

        List<String> elements = new java.util.ArrayList<>();

        // 收集按鈕
        for (Locator btn : page.locator("button:visible").all()) {
            String text = btn.textContent();
            if (text != null && !text.trim().isEmpty()) {
                elements.add(String.format("[按鈕] %s", text.trim()));
            }
        }

        // 收集連結
        for (Locator link : page.locator("a:visible").all()) {
            String text = link.textContent();
            String href = link.getAttribute("href");
            if (text != null && !text.trim().isEmpty()) {
                elements.add(String.format("[連結] %s -> %s", text.trim(), href));
            }
        }

        // 收集輸入框
        for (Locator input : page.locator("input:visible, textarea:visible").all()) {
            String placeholder = input.getAttribute("placeholder");
            String name = input.getAttribute("name");
            String type = input.getAttribute("type");
            elements.add(String.format("[輸入框] name=%s, type=%s, placeholder=%s",
                name, type, placeholder));
        }

        if (elements.isEmpty()) {
            return "當前頁面沒有找到可互動元素";
        }

        return "當前頁面的可互動元素:\n" +
            elements.stream()
                .limit(30)  // 限制數量避免 token 爆炸
                .collect(Collectors.joining("\n"));
    }

    @Tool("取得當前頁面的文字內容摘要")
    public String getPageTextContent() {
        Page page = playwrightConfig.getCurrentPage();
        String fullText = page.locator("body").textContent();

        // 限制長度
        if (fullText.length() > 2000) {
            fullText = fullText.substring(0, 2000) + "...(內容已截斷)";
        }

        return String.format("頁面標題: %s\n\n頁面內容:\n%s",
            page.title(), fullText);
    }

    @Tool("取得當前頁面的 URL")
    public String getCurrentUrl() {
        return playwrightConfig.getCurrentPage().url();
    }

    @Tool("等待指定的毫秒數")
    public String waitFor(int milliseconds) {
        playwrightConfig.getCurrentPage().waitForTimeout(milliseconds);
        return String.format("已等待 %d 毫秒", milliseconds);
    }

    @Tool("按下鍵盤按鍵,例如 Enter, Tab, Escape")
    public String pressKey(String key) {
        playwrightConfig.getCurrentPage().keyboard().press(key);
        return String.format("已按下 %s 鍵", key);
    }

    @Tool("捲動頁面。direction 可以是 'up', 'down', 'top', 'bottom'")
    public String scroll(String direction) {
        Page page = playwrightConfig.getCurrentPage();

        switch (direction.toLowerCase()) {
            case "down":
                page.mouse().wheel(0, 500);
                break;
            case "up":
                page.mouse().wheel(0, -500);
                break;
            case "top":
                page.evaluate("window.scrollTo(0, 0)");
                break;
            case "bottom":
                page.evaluate("window.scrollTo(0, document.body.scrollHeight)");
                break;
            default:
                return "未知的捲動方向: " + direction;
        }

        return String.format("已向 %s 捲動", direction);
    }
}

5.4 視覺定位 Tools

package com.example.aiqaagent.tools;

import com.example.aiqaagent.config.PlaywrightConfig;
import com.microsoft.playwright.Page;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.data.message.*;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Slf4j
@Component
@RequiredArgsConstructor
public class VisionTools {

    private final PlaywrightConfig playwrightConfig;

    @Qualifier("primaryChatModel")
    private final ChatLanguageModel visionModel;

    private static final String VISION_LOCATE_PROMPT = """
        你是一個視覺分析助手,專門協助定位網頁元素。

        任務:在這張網頁截圖中,找到符合以下描述的元素:
        %s

        請回傳該元素的中心點座標,格式為:
        COORDINATES: x=數字, y=數字

        如果找不到符合描述的元素,請回傳:
        NOT_FOUND: 原因說明

        注意事項:
        - 座標是相對於圖片左上角的像素位置
        - 圖片尺寸為 1280x720
        - 只需要回傳座標,不需要其他解釋
        """";

    @Tool("使用視覺 AI 在當前頁面截圖中定位元素。描述應該是視覺特徵,如「藍色的登入按鈕」或「頁面右上角的購物車圖示」")
    public String locateElementByVision(String visualDescription) {
        log.info("Vision locating element: {}", visualDescription);

        String screenshotBase64 = playwrightConfig.captureScreenshotBase64();

        // 建構帶有圖片的訊息
        ImageContent imageContent = ImageContent.from(
            screenshotBase64, "image/png"
        );

        TextContent textContent = TextContent.from(
            String.format(VISION_LOCATE_PROMPT, visualDescription)
        );

        UserMessage userMessage = UserMessage.from(imageContent, textContent);

        // 呼叫 GPT-4o Vision
        String response = visionModel.generate(userMessage).content().text();

        log.debug("Vision response: {}", response);

        return response;
    }

    @Tool("使用視覺定位並點擊元素。描述應該是視覺特徵。")
    public String clickByVision(String visualDescription) {
        String locationResult = locateElementByVision(visualDescription);

        if (locationResult.contains("NOT_FOUND")) {
            return locationResult;
        }

        // 解析座標
        Pattern pattern = Pattern.compile("x=(\\d+),\\s*y=(\\d+)");
        Matcher matcher = pattern.matcher(locationResult);

        if (!matcher.find()) {
            return "無法解析座標: " + locationResult;
        }

        int x = Integer.parseInt(matcher.group(1));
        int y = Integer.parseInt(matcher.group(2));

        log.info("Clicking at coordinates: ({}, {})", x, y);

        Page page = playwrightConfig.getCurrentPage();
        page.mouse().click(x, y);
        page.waitForTimeout(500);

        return String.format("已透過視覺定位點擊座標 (%d, %d)", x, y);
    }

    @Tool("分析當前頁面截圖,描述頁面上看到的內容和佈局")
    public String analyzePageVisually() {
        String screenshotBase64 = playwrightConfig.captureScreenshotBase64();

        ImageContent imageContent = ImageContent.from(
            screenshotBase64, "image/png"
        );

        TextContent textContent = TextContent.from("""
            請分析這張網頁截圖,描述:
            1. 這是什麼類型的頁面(登入頁、商品列表、結帳頁等)
            2. 主要的 UI 元素有哪些
            3. 頁面的狀態(是否有錯誤訊息、載入中、正常顯示)
            4. 下一步可能的操作

            用繁體中文回答,簡潔扼要。
            """);

        UserMessage userMessage = UserMessage.from(imageContent, textContent);

        return visionModel.generate(userMessage).content().text();
    }

    @Tool("比較兩張截圖,檢查是否有預期的變化")
    public String compareScreenshots(String expectedChange) {
        // 先截第一張
        String before = playwrightConfig.captureScreenshotBase64();

        // 等待變化
        playwrightConfig.getCurrentPage().waitForTimeout(1000);

        // 再截第二張
        String after = playwrightConfig.captureScreenshotBase64();

        ImageContent beforeImage = ImageContent.from(before, "image/png");
        ImageContent afterImage = ImageContent.from(after, "image/png");

        TextContent textContent = TextContent.from(String.format("""
            這是兩張連續的網頁截圖(前後順序)。

            預期的變化是:%s

            請分析:
            1. 兩張圖片之間有什麼變化?
            2. 預期的變化是否發生了?
            3. 有沒有意外的變化(錯誤訊息、版面錯亂等)?

            回答格式:
            CHANGE_DETECTED: 是/否
            EXPECTED_CHANGE: 是/否
            DESCRIPTION: 變化描述
            """, expectedChange));

        UserMessage userMessage = UserMessage.from(beforeImage, afterImage, textContent);

        return visionModel.generate(userMessage).content().text();
    }
}

5.5 診斷 Tools

package com.example.aiqaagent.tools;

import com.example.aiqaagent.config.PlaywrightConfig;
import com.example.aiqaagent.model.TestEvidence;
import dev.langchain4j.agent.tool.Tool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.time.Instant;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class DiagnosticTools {

    private final PlaywrightConfig playwrightConfig;
    private final RestTemplate restTemplate = new RestTemplate();

    @Value("${target.api-base-url}")
    private String apiBaseUrl;

    @Tool("收集當前的所有診斷資訊:截圖、Console Log、Network Log")
    public String collectAllEvidence() {
        StringBuilder evidence = new StringBuilder();

        // 1. 截圖
        evidence.append("=== 截圖 ===\n");
        evidence.append("[截圖已保存,Base64 長度: ")
                .append(playwrightConfig.captureScreenshotBase64().length())
                .append("]\n\n");

        // 2. Console Logs
        evidence.append("=== 瀏覽器 Console 日誌 ===\n");
        List<String> consoleLogs = playwrightConfig.getConsoleLogs();
        if (consoleLogs.isEmpty()) {
            evidence.append("無 Console 日誌\n");
        } else {
            consoleLogs.stream()
                .limit(50)
                .forEach(log -> evidence.append(log).append("\n"));
        }
        evidence.append("\n");

        // 3. 當前 URL 和頁面狀態
        evidence.append("=== 頁面狀態 ===\n");
        evidence.append("URL: ").append(playwrightConfig.getCurrentPage().url()).append("\n");
        evidence.append("Title: ").append(playwrightConfig.getCurrentPage().title()).append("\n");

        return evidence.toString();
    }

    @Tool("查詢後端 API 的健康狀態")
    public String checkBackendHealth() {
        try {
            String healthUrl = apiBaseUrl + "/actuator/health";
            String response = restTemplate.getForObject(healthUrl, String.class);
            return "後端健康狀態: " + response;
        } catch (Exception e) {
            return "無法連接後端: " + e.getMessage();
        }
    }

    @Tool("根據 TraceId 查詢後端日誌(需要後端支援)")
    public String queryBackendLogs(String traceId) {
        try {
            // 這裡假設後端有暴露日誌查詢 API
            // 實際實作需要根據你的日誌系統調整(ELK、Splunk、CloudWatch 等)
            String logsUrl = apiBaseUrl + "/actuator/trace?traceId=" + traceId;
            String response = restTemplate.getForObject(logsUrl, String.class);
            return "後端日誌:\n" + response;
        } catch (Exception e) {
            return "無法查詢後端日誌: " + e.getMessage();
        }
    }

    @Tool("檢查頁面上是否有錯誤訊息")
    public String checkForErrorMessages() {
        var page = playwrightConfig.getCurrentPage();

        // 常見的錯誤元素 selectors
        String[] errorSelectors = {
            ".error", ".alert-danger", ".alert-error",
            "[class*='error']", "[class*='Error']",
            ".notification-error", ".toast-error",
            "[role='alert']"
        };

        StringBuilder errors = new StringBuilder();

        for (String selector : errorSelectors) {
            var elements = page.locator(selector).all();
            for (var element : elements) {
                if (element.isVisible()) {
                    String text = element.textContent();
                    if (text != null && !text.trim().isEmpty()) {
                        errors.append("- ").append(text.trim()).append("\n");
                    }
                }
            }
        }

        if (errors.length() == 0) {
            return "頁面上沒有發現明顯的錯誤訊息";
        }

        return "發現以下錯誤訊息:\n" + errors;
    }

    @Tool("取得最近的 Network 請求(最後 10 個)")
    public String getRecentNetworkRequests() {
        // Playwright 需要在頁面建立時就設定 Network 監聽
        // 這裡提供一個簡化版本
        return "Network 請求監控需要在測試開始時啟用。請使用 collectAllEvidence() 取得完整資訊。";
    }

    @Tool("生成診斷報告摘要")
    public String generateDiagnosisSummary(String symptom, String evidence) {
        return String.format(
            "=== 診斷報告 ===\n\n時間: %s\n症狀: %s\n\n收集的證據:\n%s\n\n待分析...\n",
            Instant.now(), symptom, evidence);
    }
}

5.6 測試資料 Tools

package com.example.aiqaagent.tools;

import dev.langchain4j.agent.tool.Tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.testcontainers.containers.PostgreSQLContainer;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@Slf4j
@Component
public class DataTools {

    private PostgreSQLContainer<?> postgresContainer;
    private final Map<String, Object> testDataCache = new HashMap<>();

    @Tool("建立一個隔離的測試資料庫環境")
    public String createIsolatedDatabase() {
        if (postgresContainer != null && postgresContainer.isRunning()) {
            return "測試資料庫已在運行中: " + postgresContainer.getJdbcUrl();
        }

        postgresContainer = new PostgreSQLContainer<>("postgres:15")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

        postgresContainer.start();

        String jdbcUrl = postgresContainer.getJdbcUrl();
        log.info("Test database started: {}", jdbcUrl);

        return String.format(
            "測試資料庫已建立:\nJDBC URL: %s\nUsername: test\nPassword: test\n",
            jdbcUrl);
    }

    @Tool("在測試資料庫中執行 SQL")
    public String executeSql(String sql) {
        if (postgresContainer == null || !postgresContainer.isRunning()) {
            return "錯誤: 請先呼叫 createIsolatedDatabase() 建立測試資料庫";
        }

        try (Connection conn = DriverManager.getConnection(
                postgresContainer.getJdbcUrl(),
                postgresContainer.getUsername(),
                postgresContainer.getPassword());
             Statement stmt = conn.createStatement()) {

            boolean hasResultSet = stmt.execute(sql);
            if (hasResultSet) {
                var rs = stmt.getResultSet();
                StringBuilder result = new StringBuilder();
                var meta = rs.getMetaData();
                int cols = meta.getColumnCount();

                // Header
                for (int i = 1; i <= cols; i++) {
                    result.append(meta.getColumnName(i)).append("\t");
                }
                result.append("\n");

                // Data
                while (rs.next()) {
                    for (int i = 1; i <= cols; i++) {
                        result.append(rs.getString(i)).append("\t");
                    }
                    result.append("\n");
                }
                return result.toString();
            } else {
                return "SQL 執行成功,影響 " + stmt.getUpdateCount() + " 行";
            }
        } catch (Exception e) {
            return "SQL 執行失敗: " + e.getMessage();
        }
    }

    @Tool("生成測試用的隨機資料")
    public String generateTestData(String dataType) {
        String result;

        switch (dataType.toLowerCase()) {
            case "email":
                result = "test_" + UUID.randomUUID().toString().substring(0, 8) + "@example.com";
                break;
            case "username":
                result = "user_" + UUID.randomUUID().toString().substring(0, 8);
                break;
            case "password":
                result = "Test@" + UUID.randomUUID().toString().substring(0, 8);
                break;
            case "phone":
                result = "09" + String.format("%08d", (int)(Math.random() * 100000000));
                break;
            case "credit_card":
                result = "4111111111111111"; // 測試用卡號
                break;
            default:
                result = UUID.randomUUID().toString();
        }

        // 快取以便後續參照
        testDataCache.put(dataType, result);

        return String.format("生成的 %s: %s", dataType, result);
    }

    @Tool("取得之前生成的測試資料")
    public String getGeneratedData(String dataType) {
        Object data = testDataCache.get(dataType.toLowerCase());
        if (data == null) {
            return "找不到 " + dataType + " 的測試資料,請先呼叫 generateTestData()";
        }
        return data.toString();
    }

    @Tool("清理測試環境")
    public String cleanup() {
        testDataCache.clear();

        if (postgresContainer != null && postgresContainer.isRunning()) {
            postgresContainer.stop();
            postgresContainer = null;
            return "測試資料庫已關閉,測試資料已清除";
        }

        return "測試資料已清除";
    }
}

六、三層驗證迴圈:狀態機設計

6.1 狀態定義

package com.example.aiqaagent.loops;

/**
 * 測試執行狀態
 */
public enum TestState {
    // 初始狀態
    IDLE,

    // 探索迴圈狀態
    EXPLORING,
    PLANNING_NEXT_ACTION,

    // 執行狀態
    EXECUTING_ACTION,
    WAITING_FOR_RESULT,

    // 穩定性迴圈狀態
    RETRYING,
    CHECKING_FLAKINESS,

    // 診斷迴圈狀態
    COLLECTING_EVIDENCE,
    ANALYZING_FAILURE,
    GENERATING_REPORT,

    // 終態
    ACTION_SUCCEEDED,
    ACTION_FAILED_DIAGNOSED,
    ACTION_FLAKY,
    TEST_COMPLETED,
    TEST_ABORTED
}

6.2 狀態機實作

package com.example.aiqaagent.loops;

import com.example.aiqaagent.model.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class LoopStateMachine {

    private final StabilityLoop stabilityLoop;
    private final DiagnosisLoop diagnosisLoop;
    private final ExplorationLoop explorationLoop;

    @Getter
    private TestState currentState = TestState.IDLE;

    @Getter
    private final List<TestStep> executedSteps = new ArrayList<>();

    @Getter
    private final List<DiagnosisResult> diagnosisResults = new ArrayList<>();

    private int totalActionsExecuted = 0;
    private int failedActions = 0;
    private int flakyActions = 0;

    /**
     * 執行單一測試動作,經過三層迴圈處理
     */
    public ActionResult executeAction(TestAction action, TestExecutionContext context) {
        log.info("Executing action: {} in state: {}", action.getDescription(), currentState);

        Instant startTime = Instant.now();
        totalActionsExecuted++;

        // 第一層:嘗試執行
        transitionTo(TestState.EXECUTING_ACTION);
        ActionResult result = attemptAction(action, context);

        if (result.isSuccess()) {
            transitionTo(TestState.ACTION_SUCCEEDED);
            recordStep(action, result, Duration.between(startTime, Instant.now()));
            return result;
        }

        // 第二層:穩定性迴圈 - 判斷是否為環境問題
        transitionTo(TestState.RETRYING);
        StabilityResult stabilityResult = stabilityLoop.checkAndRetry(action, context);

        if (stabilityResult.isEventuallySucceeded()) {
            if (stabilityResult.isFlaky()) {
                transitionTo(TestState.ACTION_FLAKY);
                flakyActions++;
                log.warn("Action is flaky: {} (success rate: {}%)",
                    action.getDescription(), stabilityResult.getSuccessRate() * 100);
            } else {
                transitionTo(TestState.ACTION_SUCCEEDED);
            }
            recordStep(action, stabilityResult.getFinalResult(),
                Duration.between(startTime, Instant.now()));
            return stabilityResult.getFinalResult();
        }

        // 第三層:診斷迴圈 - 分析根因
        transitionTo(TestState.COLLECTING_EVIDENCE);
        DiagnosisResult diagnosis = diagnosisLoop.diagnose(action, result, context);
        diagnosisResults.add(diagnosis);

        transitionTo(TestState.ACTION_FAILED_DIAGNOSED);
        failedActions++;

        ActionResult failedResult = ActionResult.failure(
            result.getErrorMessage(),
            diagnosis
        );
        recordStep(action, failedResult, Duration.between(startTime, Instant.now()));

        return failedResult;
    }

    /**
     * 執行探索性測試
     */
    public ExplorationResult exploreAndTest(TestGoal goal, TestExecutionContext context) {
        log.info("Starting exploration for goal: {}", goal.getDescription());

        transitionTo(TestState.EXPLORING);

        List<TestAction> discoveredActions = new ArrayList<>();
        int maxIterations = context.getConfig().getExplorationMaxDepth();

        for (int i = 0; i < maxIterations; i++) {
            transitionTo(TestState.PLANNING_NEXT_ACTION);

            // 讓 AI 決定下一步
            TestAction nextAction = explorationLoop.planNextAction(goal, executedSteps, context);

            if (nextAction == null || nextAction.isTerminal()) {
                log.info("Exploration completed: no more actions to take");
                break;
            }

            discoveredActions.add(nextAction);

            // 執行動作(經過穩定性和診斷迴圈)
            ActionResult result = executeAction(nextAction, context);

            // 如果是關鍵失敗,可能需要中斷探索
            if (result.isCriticalFailure() && !context.getConfig().isContinueOnCriticalFailure()) {
                log.warn("Critical failure detected, stopping exploration");
                transitionTo(TestState.TEST_ABORTED);
                break;
            }

            // 檢查是否達成目標
            if (explorationLoop.isGoalAchieved(goal, executedSteps)) {
                log.info("Goal achieved!");
                break;
            }
        }

        transitionTo(TestState.TEST_COMPLETED);

        return ExplorationResult.builder()
            .goal(goal)
            .discoveredActions(discoveredActions)
            .executedSteps(new ArrayList<>(executedSteps))
            .diagnosisResults(new ArrayList<>(diagnosisResults))
            .statistics(buildStatistics())
            .build();
    }

    private ActionResult attemptAction(TestAction action, TestExecutionContext context) {
        try {
            // 實際執行動作(透過 AI Agent)
            return context.getAgent().executeAction(action);
        } catch (Exception e) {
            log.error("Action execution failed", e);
            return ActionResult.failure(e.getMessage(), null);
        }
    }

    private void transitionTo(TestState newState) {
        log.debug("State transition: {} -> {}", currentState, newState);
        currentState = newState;
    }

    private void recordStep(TestAction action, ActionResult result, Duration duration) {
        executedSteps.add(TestStep.builder()
            .action(action)
            .result(result)
            .duration(duration)
            .timestamp(Instant.now())
            .build());
    }

    private TestStatistics buildStatistics() {
        return TestStatistics.builder()
            .totalActions(totalActionsExecuted)
            .successfulActions(totalActionsExecuted - failedActions)
            .failedActions(failedActions)
            .flakyActions(flakyActions)
            .diagnosisCount(diagnosisResults.size())
            .build();
    }

    public void reset() {
        currentState = TestState.IDLE;
        executedSteps.clear();
        diagnosisResults.clear();
        totalActionsExecuted = 0;
        failedActions = 0;
        flakyActions = 0;
    }
}

6.3 穩定性迴圈

package com.example.aiqaagent.loops;

import com.example.aiqaagent.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class StabilityLoop {

    @Value("${ai-qa.loops.stability.max-retries:3}")
    private int maxRetries;

    @Value("${ai-qa.loops.stability.retry-delay-ms:1000}")
    private long retryDelayMs;

    @Value("${ai-qa.loops.stability.flakiness-threshold:0.8}")
    private double flakinessThreshold;

    // 看起來像環境問題的錯誤模式
    private static final List<String> TRANSIENT_ERROR_PATTERNS = List.of(
        "timeout",
        "timed out",
        "connection reset",
        "connection refused",
        "network",
        "ECONNRESET",
        "ETIMEDOUT",
        "socket hang up",
        "503",
        "502",
        "504"
    );

    public StabilityResult checkAndRetry(TestAction action, TestExecutionContext context) {
        log.info("Entering stability loop for action: {}", action.getDescription());

        List<ActionResult> attempts = new ArrayList<>();
        int successCount = 0;
        ActionResult lastResult = null;

        for (int attempt = 0; attempt <= maxRetries; attempt++) {
            if (attempt > 0) {
                log.info("Retry attempt {} of {}", attempt, maxRetries);
                sleep(retryDelayMs);
            }

            lastResult = context.getAgent().executeAction(action);
            attempts.add(lastResult);

            if (lastResult.isSuccess()) {
                successCount++;

                // 如果第一次就成功,直接返回
                if (attempt == 0) {
                    return StabilityResult.success(lastResult);
                }
            } else {
                // 檢查是否為暫時性錯誤
                if (!isTransientError(lastResult.getErrorMessage())) {
                    log.info("Error doesn't look transient, skipping further retries");
                    break;
                }
            }
        }

        // 計算成功率
        double successRate = (double) successCount / attempts.size();

        if (successCount > 0) {
            // 至少有一次成功
            boolean isFlaky = successRate < 1.0 && successRate >= flakinessThreshold;

            if (isFlaky) {
                log.warn("Action is flaky: success rate = {}%", successRate * 100);
            }

            return StabilityResult.builder()
                .eventuallySucceeded(true)
                .flaky(isFlaky)
                .successRate(successRate)
                .attempts(attempts)
                .finalResult(findSuccessfulResult(attempts))
                .build();
        }

        // 全部失敗
        return StabilityResult.builder()
            .eventuallySucceeded(false)
            .flaky(false)
            .successRate(0)
            .attempts(attempts)
            .finalResult(lastResult)
            .build();
    }

    private boolean isTransientError(String errorMessage) {
        if (errorMessage == null) return false;

        String lowerError = errorMessage.toLowerCase();
        return TRANSIENT_ERROR_PATTERNS.stream()
            .anyMatch(lowerError::contains);
    }

    private ActionResult findSuccessfulResult(List<ActionResult> attempts) {
        return attempts.stream()
            .filter(ActionResult::isSuccess)
            .findFirst()
            .orElse(attempts.get(attempts.size() - 1));
    }

    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

6.4 診斷迴圈

package com.example.aiqaagent.loops;

import com.example.aiqaagent.model.*;
import com.example.aiqaagent.tools.DiagnosticTools;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

import java.time.Instant;

@Slf4j
@Component
@RequiredArgsConstructor
public class DiagnosisLoop {

    private final DiagnosticTools diagnosticTools;

    @Qualifier("primaryChatModel")
    private final ChatLanguageModel analysisModel;

    private static final String DIAGNOSIS_PROMPT = """
        你是一位資深的 QA 工程師,正在分析一個測試失敗案例。

        ## 失敗的動作
        %s

        ## 錯誤訊息
        %s

        ## 收集到的證據
        %s

        ## 分析任務
        請分析這個失敗的根本原因,並提供:

        1. **根因分類**(選擇一個):
           - FRONTEND_BUG: 前端 JavaScript 錯誤、渲染問題
           - BACKEND_BUG: API 錯誤、資料庫問題、業務邏輯錯誤
           - ENVIRONMENT: 網路問題、服務不可用、資源不足
           - TEST_SCRIPT: 測試腳本本身的問題(定位錯誤、時序問題)
           - DATA_ISSUE: 測試資料問題
           - UNKNOWN: 無法判斷

        2. **根因描述**:用一句話描述問題的本質

        3. **技術細節**:相關的錯誤訊息、堆疊追蹤、狀態碼等

        4. **建議修復方向**:開發者應該從哪裡開始調查

        5. **信心程度**:HIGH / MEDIUM / LOW

        請用以下 JSON 格式回答:
        ```json
        {
          "rootCauseCategory": "FRONTEND_BUG",
          "rootCauseDescription": "...",
          "technicalDetails": "...",
          "suggestedFix": "...",
          "confidence": "HIGH"
        }
        ```
        """;

    public DiagnosisResult diagnose(TestAction action, ActionResult failedResult, 
                                     TestExecutionContext context) {
        log.info("Starting diagnosis for failed action: {}", action.getDescription());

        Instant startTime = Instant.now();

        // 1. 收集證據
        String evidence = collectEvidence(context);

        // 2. 檢查頁面錯誤
        String pageErrors = diagnosticTools.checkForErrorMessages();

        // 3. 組合所有證據
        String fullEvidence = evidence + "\n\n頁面錯誤檢查:\n" + pageErrors;

        // 4. 呼叫 AI 分析
        String prompt = String.format(DIAGNOSIS_PROMPT,
            action.getDescription(),
            failedResult.getErrorMessage(),
            fullEvidence
        );

        String analysisResponse = analysisModel.generate(prompt);

        log.debug("Diagnosis response: {}", analysisResponse);

        // 5. 解析回應
        DiagnosisResult result = parseAnalysisResponse(analysisResponse, action, failedResult);
        result.setDiagnosisTime(java.time.Duration.between(startTime, Instant.now()));
        result.setEvidence(fullEvidence);

        return result;
    }

    private String collectEvidence(TestExecutionContext context) {
        StringBuilder evidence = new StringBuilder();

        try {
            evidence.append(diagnosticTools.collectAllEvidence());
        } catch (Exception e) {
            log.warn("Failed to collect some evidence: {}", e.getMessage());
            evidence.append("部分證據收集失敗: ").append(e.getMessage());
        }

        // 嘗試收集後端健康狀態
        try {
            evidence.append("\n\n").append(diagnosticTools.checkBackendHealth());
        } catch (Exception e) {
            evidence.append("\n\n後端健康檢查失敗: ").append(e.getMessage());
        }

        return evidence.toString();
    }

    private DiagnosisResult parseAnalysisResponse(String response, TestAction action, 
                                                   ActionResult failedResult) {
        // 嘗試解析 JSON
        try {
            // 提取 JSON 部分
            int jsonStart = response.indexOf("{");
            int jsonEnd = response.lastIndexOf("}") + 1;

            if (jsonStart >= 0 && jsonEnd > jsonStart) {
                String json = response.substring(jsonStart, jsonEnd);
                // 使用 Jackson 解析
                var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
                var node = mapper.readTree(json);

                return DiagnosisResult.builder()
                    .action(action)
                    .originalError(failedResult.getErrorMessage())
                    .rootCauseCategory(RootCauseCategory.valueOf(
                        node.get("rootCauseCategory").asText()))
                    .rootCauseDescription(node.get("rootCauseDescription").asText())
                    .technicalDetails(node.get("technicalDetails").asText())
                    .suggestedFix(node.get("suggestedFix").asText())
                    .confidence(Confidence.valueOf(node.get("confidence").asText()))
                    .rawAnalysis(response)
                    .build();
            }
        } catch (Exception e) {
            log.warn("Failed to parse diagnosis response as JSON: {}", e.getMessage());
        }

        // 解析失敗,返回原始回應
        return DiagnosisResult.builder()
            .action(action)
            .originalError(failedResult.getErrorMessage())
            .rootCauseCategory(RootCauseCategory.UNKNOWN)
            .rootCauseDescription("無法解析 AI 分析結果")
            .technicalDetails(response)
            .suggestedFix("請人工檢視原始分析內容")
            .confidence(Confidence.LOW)
            .rawAnalysis(response)
            .build();
    }
}

6.5 探索迴圈

package com.example.aiqaagent.loops;

import com.example.aiqaagent.model.*;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class ExplorationLoop {

    @Qualifier("primaryChatModel")
    private final ChatLanguageModel planningModel;

    private static final String PLANNING_PROMPT = """
        你是一位資深的 QA 工程師,正在進行探索性測試。

        ## 測試目標
        %s

        ## 已執行的步驟
        %s

        ## 當前頁面狀態
        %s

        ## 可用的互動元素
        %s

        ## 任務
        請決定下一步應該執行什麼動作。考慮:
        1. 是否已達成目標?
        2. 有沒有值得探索的邊界條件?
        3. 有沒有潛在的風險路徑值得測試?

        回答格式(JSON):
        ```json
        {
          "shouldContinue": true,
          "nextAction": {
            "type": "CLICK | FILL | NAVIGATE | ASSERT | WAIT",
            "target": "元素描述或 selector",
            "value": "如果是 FILL,填入的值",
            "description": "這個動作的目的"
          },
          "reasoning": "選擇這個動作的原因",
          "goalProgress": "目前進度評估 (0-100)"
        }
        ```

        如果認為測試應該結束,設置 shouldContinue 為 false。
        """;

    private static final String GOAL_CHECK_PROMPT = """
        ## 測試目標
        %s

        ## 已執行的步驟和結果
        %s

        ## 問題
        這個測試目標是否已經達成?

        回答格式:
        ACHIEVED: 是/否
        REASON: 判斷理由
        """;

    public TestAction planNextAction(TestGoal goal, List<TestStep> executedSteps, 
                                      TestExecutionContext context) {
        log.info("Planning next action for goal: {}", goal.getDescription());

        // 取得當前頁面資訊
        String pageState = context.getAgent().getPageState();
        String interactiveElements = context.getAgent().getInteractiveElements();

        String stepsHistory = formatStepsHistory(executedSteps);

        String prompt = String.format(PLANNING_PROMPT,
            goal.getDescription(),
            stepsHistory,
            pageState,
            interactiveElements
        );

        String response = planningModel.generate(prompt);

        return parseActionFromResponse(response);
    }

    public boolean isGoalAchieved(TestGoal goal, List<TestStep> executedSteps) {
        String stepsHistory = formatStepsHistory(executedSteps);

        String prompt = String.format(GOAL_CHECK_PROMPT,
            goal.getDescription(),
            stepsHistory
        );

        String response = planningModel.generate(prompt);

        return response.toUpperCase().contains("ACHIEVED: 是") ||
               response.toUpperCase().contains("ACHIEVED: YES");
    }

    private String formatStepsHistory(List<TestStep> steps) {
        if (steps.isEmpty()) {
            return "(尚無執行步驟)";
        }

        return steps.stream()
            .map(step -> String.format("- %s: %s",
                step.getAction().getDescription(),
                step.getResult().isSuccess() ? "成功" : "失敗 - " + step.getResult().getErrorMessage()))
            .collect(Collectors.joining("\n"));
    }

    private TestAction parseActionFromResponse(String response) {
        try {
            int jsonStart = response.indexOf("{");
            int jsonEnd = response.lastIndexOf("}") + 1;

            if (jsonStart >= 0 && jsonEnd > jsonStart) {
                String json = response.substring(jsonStart, jsonEnd);
                var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
                var node = mapper.readTree(json);

                boolean shouldContinue = node.get("shouldContinue").asBoolean();

                if (!shouldContinue) {
                    return TestAction.terminal();
                }

                var actionNode = node.get("nextAction");

                return TestAction.builder()
                    .type(ActionType.valueOf(actionNode.get("type").asText()))
                    .target(actionNode.get("target").asText())
                    .value(actionNode.has("value") ? actionNode.get("value").asText() : null)
                    .description(actionNode.get("description").asText())
                    .reasoning(node.get("reasoning").asText())
                    .build();
            }
        } catch (Exception e) {
            log.error("Failed to parse action from response: {}", e.getMessage());
        }

        return TestAction.terminal();
    }
}

七、Prompt Engineering

7.1 System Prompt(存於 resources/prompts/system-prompt.txt)

# AI QA Agent System Prompt

你是一位資深的 QA 自動化工程師,具備以下能力:

## 核心能力
1. **測試規劃**:根據業務目標規劃測試路徑
2. **操作執行**:透過工具操作瀏覽器
3. **問題診斷**:當測試失敗時分析根因
4. **邊界探索**:主動發現潛在的 Bug 和風險

## 行為準則

### 執行測試時
- 每一步操作後都要驗證結果
- 如果操作失敗,先嘗試替代方案再報告失敗
- 遇到意外的彈窗、錯誤訊息時要記錄並處理
- 定期截圖作為證據

### 診斷問題時
- 先收集所有可用的證據(截圖、Console Log、Network Log)
- 區分前端問題、後端問題、環境問題
- 提供具體的技術細節,不要只說「出錯了」
- 給出可執行的修復建議

### 探索測試時
- 優先測試核心業務流程
- 嘗試邊界條件(空值、極大值、特殊字符)
- 測試異常流程(未登入訪問、權限不足)
- 注意頁面間的狀態一致性

## 工具使用指南

### 元素定位優先順序
1. 先嘗試用文字內容定位(clickByText)
2. 如果失敗,嘗試 CSS Selector(clickBySelector)
3. 最後才使用視覺定位(clickByVision)—— 這個最貴但最可靠

### 等待策略
- 頁面跳轉後等待 networkidle
- 動態內容載入等待特定元素出現
- 不要使用固定時間等待(除非必要)

### 成本意識
- 視覺分析(Vision)成本較高,謹慎使用
- 簡單判斷用 GPT-4o-mini
- 複雜推理才用 GPT-4o

## 回報格式
所有回報請使用繁體中文,技術術語可保留英文。

7.2 視覺定位 Prompt(resources/prompts/vision-locate-element.txt)

你是一個視覺分析助手,專門協助定位網頁元素。

## 任務
在這張網頁截圖中,找到符合以下描述的元素:
{element_description}

## 輸出格式
請回傳該元素的中心點座標,格式必須是:
COORDINATES: x=數字, y=數字

如果找不到符合描述的元素,請回傳:
NOT_FOUND: 原因說明

## 注意事項
- 座標是相對於圖片左上角的像素位置
- 圖片尺寸為 1280x720
- 優先找視覺上最符合描述的元素
- 如果有多個符合的元素,選擇最明顯/最主要的那個
- 只需要回傳座標,不需要其他解釋

## 常見元素識別技巧
- 按鈕通常是矩形、有明顯邊框或背景色
- 連結通常是藍色或有底線的文字
- 輸入框通常是白色矩形、有邊框
- 圖示通常在按鈕內部或獨立存在

7.3 診斷分析 Prompt(resources/prompts/diagnosis-analyze.txt)

你是一位資深的 QA 工程師,正在分析一個測試失敗案例。

## 失敗的動作
{action_description}

## 錯誤訊息
{error_message}

## 收集到的證據
{evidence}

## 分析任務
請分析這個失敗的根本原因,並提供:

1. **根因分類**(選擇一個):
   - FRONTEND_BUG: 前端 JavaScript 錯誤、渲染問題
   - BACKEND_BUG: API 錯誤、資料庫問題、業務邏輯錯誤
   - ENVIRONMENT: 網路問題、服務不可用、資源不足
   - TEST_SCRIPT: 測試腳本本身的問題(定位錯誤、時序問題)
   - DATA_ISSUE: 測試資料問題
   - UNKNOWN: 無法判斷

2. **根因描述**:用一句話描述問題的本質

3. **技術細節**:相關的錯誤訊息、堆疊追蹤、狀態碼等

4. **建議修復方向**:開發者應該從哪裡開始調查

5. **信心程度**:HIGH / MEDIUM / LOW

請用以下 JSON 格式回答:
```json
{
  "rootCauseCategory": "FRONTEND_BUG",
  "rootCauseDescription": "...",
  "technicalDetails": "...",
  "suggestedFix": "...",
  "confidence": "HIGH"
}

### 7.4 探索下一步行動 Prompt(resources/prompts/exploration-next-action.txt)

```text
你是一位資深的 QA 工程師,正在進行探索性測試。

## 測試目標
{test_goal_description}

## 已執行的步驟
{executed_steps_history}

## 當前頁面狀態分析
{current_page_state}

## 可用的互動元素
{interactive_elements_list}

## 任務
請決定下一步應該執行什麼動作,以達成測試目標。考慮:
1. 是否已達成測試目標?
2. 有沒有值得探索的邊界條件?
3. 有沒有潛在的風險路徑值得測試?
4. 如何避免重複已失敗的步驟?

如果認為測試應該結束,請將 "shouldContinue" 設定為 false。

## 可用的動作類型 (type)
- CLICK: 點擊元素
- FILL: 填入文字
- NAVIGATE: 導航到指定 URL
- ASSERT: 斷言某個條件成立
- WAIT: 等待一段時間 (盡量少用)
- SCROLL: 捲動頁面
- PRESS_KEY: 按下鍵盤按鍵

## 輸出格式(JSON)
```json
{
  "shouldContinue": true,
  "nextAction": {
    "type": "CLICK",
    "target": "結帳按鈕文字或 CSS Selector",
    "value": "如果是 FILL 動作,請填入的值",
    "description": "這個動作的具體目的和期待效果"
  },
  "reasoning": "選擇這個動作的原因,以及為何它有助於達成測試目標或發現 Bug",
  "goalProgress": "目前達成目標的進度評估 (0-100)"
}

---

## 八、整合層實作

### 8.1 AutonomousTester 介面

```java
package com.example.aiqaagent.agent;

import com.example.aiqaagent.model.*;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;

/**
 * AI QA Agent 的核心介面,定義了其行為。
 * 由 LangChain4j 的 AiServices 動態實作。
 */
public interface AutonomousTester {

    @SystemMessage("你是一位資深的 QA 自動化工程師,具備廣泛的軟體測試知識。" +
                   "你的任務是根據使用者提供的測試目標,自主規劃、執行、診斷並驗證測試流程。" +
                   "你擁有操作瀏覽器、執行視覺分析、查詢後端日誌和管理測試數據的能力。" +
                   "請始終保持專業、細心,並像一位人類資深 QA 一樣思考。" +
                   "請使用繁體中文進行溝通。")
    void initialize(); // 用於在對話開始時設定 System Message

    @UserMessage("測試目標: {{goal.description}}")
    TestReport performTest(@MemoryId String testId, TestGoal goal);

    // 內部方法,用於 LangChain4j 的工具調用
    @Tool("執行瀏覽器操作或數據處理等原子動作")
    ActionResult executeAction(TestAction action);

    // 取得當前頁面文字內容
    @Tool("取得當前頁面文字內容")
    String getPageState();

    // 取得當前頁面可互動元素
    @Tool("取得當前頁面可互動元素")
    String getInteractiveElements();
}

8.2 TestOrchestratorService(測試編排器)

這是整合所有迴圈和 Agent 的核心服務。

package com.example.aiqaagent.service;

import com.example.aiqaagent.agent.AutonomousTester;
import com.example.aiqaagent.config.PlaywrightConfig;
import com.example.aiqaagent.loops.LoopStateMachine;
import com.example.aiqaagent.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class TestOrchestratorService {

    private final AutonomousTester autonomousTester;
    private final PlaywrightConfig playwrightConfig;
    private final LoopStateMachine loopStateMachine;

    public TestReport runAutonomousTest(TestGoal goal) {
        String testId = UUID.randomUUID().toString();
        log.info("Starting autonomous test for goal: {} with ID: {}", goal.getDescription(), testId);

        Instant startTime = Instant.now();
        playwrightConfig.initialize(); // 確保 Playwright 已初始化
        playwrightConfig.createNewPage(); // 為每次測試建立新的瀏覽器 Page

        loopStateMachine.reset(); // 重置狀態機

        try {
            // LangChain4j 會自動處理 Tool Calling,將 goal 傳遞給 AI
            // AI 會自行呼叫 executeAction, getPageState 等方法
            autonomousTester.initialize(); // 確保 System Message 已設定
            TestReport finalReport = autonomousTester.performTest(testId, goal);

            finalReport.setTestId(testId);
            finalReport.setStartTime(startTime);
            finalReport.setEndTime(Instant.now());
            finalReport.setTotalDuration(java.time.Duration.between(startTime, Instant.now()));
            finalReport.setExecutedSteps(loopStateMachine.getExecutedSteps());
            finalReport.setDiagnosisResults(loopStateMachine.getDiagnosisResults());
            finalReport.setStatistics(loopStateMachine.getStatistics());

            log.info("Autonomous test completed for goal: {}. Status: {}",
                goal.getDescription(), finalReport.getStatus());

            return finalReport;

        } catch (Exception e) {
            log.error("Autonomous test aborted due to unexpected error", e);
            return TestReport.builder()
                .testId(testId)
                .goal(goal)
                .status(TestStatus.ABORTED)
                .summary("測試意外終止: " + e.getMessage())
                .startTime(startTime)
                .endTime(Instant.now())
                .totalDuration(java.time.Duration.between(startTime, Instant.now()))
                .build();
        } finally {
            playwrightConfig.cleanup(); // 清理 Playwright 資源
            loopStateMachine.reset(); // 重置狀態機
        }
    }
}

九、評估框架與指標

9.1 定義成功標準

指標 Phase 1 (PoC) Phase 2 (MVP) Phase 3 (Production)
診斷準確率 > 80% > 90% > 95%
診斷時間 < 30 秒 < 10 秒 < 5 秒
腳本存活率 N/A > 95% > 99%
新發現 Bug 數 N/A N/A > 10 個/月
測試覆蓋率 N/A N/A > 80% (基於探索)
Token 成本 可接受 可接受 預算內

9.2 評估方法

  1. 人工標註 (Human Labeling):每次 AI 診斷或發現 Bug 後,由資深 QA 人工驗證結果的準確性。
  2. A/B 測試:將測試案例分成兩組,一組使用 AI Agent 執行,一組使用傳統自動化執行,比較 Bug 發現率和維護成本。
  3. 覆蓋率工具:使用 JaCoCo 等工具追蹤後端程式碼執行覆蓋率,並結合 Playwright 的 page.coverage() 追蹤前端 JS/CSS 覆蓋率。
  4. 成本監控:定期監控 OpenAI API 的 Token 使用量和費用,確保在預算內。

十、導入計畫與里程碑

10.1 Phase 1 (PoC – 8 週): 診斷輔助系統

  • 週 1-2:環境建置與基礎架構設定 (LangChain4j, Playwright, Spring Boot)。
  • 週 3-4:完成 BrowserTools, DiagnosticTools 的實作,整合 GPT-4o 進行日誌分析。
  • 週 5-6:開發診斷迴圈核心邏輯 DiagnosisLoop,並與現有 CI 系統整合,監聽失敗 Build。
  • 週 7-8:針對真實失敗案例進行 PoC 驗證,收集人工標註數據,計算診斷準確率。

10.2 Phase 2 (MVP – 12 週): 視覺與自癒測試

  • 週 1-3:完成 VisionTools 的實作,整合 GPT-4o Vision 進行元素定位與截圖分析。
  • 週 4-6:開發「自癒機制」:當 clickByTextclickBySelector 失敗時,自動觸發 locateElementByVision 重新定位。
  • 週 7-9:在一個獨立的測試套件中,針對部分高頻變化的 UI 功能,進行自癒測試的驗證,評估腳本存活率。
  • 週 10-12:開發 Co-pilot 介面(CLI 或簡易 Web UI),讓 QA 可以透過自然語言發送測試指令。

10.3 Phase 3 (Production – 16 週): 全自主探索與驗收

  • 週 1-4:完成 ExplorationLoop 的實作,整合 Testcontainers 實現測試資料隔離與環境管理。
  • 週 5-8:結合所有迴圈,開發 TestOrchestratorService,實現完整 Autonomous Tester 的功能。
  • 週 9-12:選擇一個核心業務模組(例如商品詳情頁到購物車流程),進行全自主探索測試。
  • 週 13-16:將 Autonomous Tester 整合到夜間 Build 流程,並配置自動報告生成與告警機制。

十一、風險評估與緩解策略

風險 評估 緩解策略
高昂的 Token 成本 1. 優先使用 GPT-4o-mini 或本地化 LLM 處理簡單任務
2. 優化 Prompt,減少 Token 使用
3. 實施嚴格的成本監控和預算控制
4. 僅在核心驗證和複雜診斷時使用 GPT-4o
AI 幻覺/誤判 1. 提高 System Prompt 的清晰度與專業性
2. 引入人工覆審機制,利用 RLHF 提高準確度
3. 設置信心閾值,低信心的判斷需人工介入
測試結果不穩定 (Flakiness) 1. 穩定性迴圈本身就是緩解措施
2. 優化 Playwright 截圖與 Vision 分析的同步機制
3. 提供完整的測試證據(錄影、截圖、Log),方便人工判斷
敏感數據洩露 1. 嚴禁將生產環境敏感數據直接送入 LLM API
2. 測試數據應做去識別化處理
3. 考慮使用企業內部部署的 LLM 解決方案(如 Azure OpenAI Service)
導入時間過長 1. 採取 PoC, MVP, Production 的漸進式導入
2. 每個階段設定明確的驗收標準
3. 重視開發者與 QA 的早期參與和回饋

十二、成本估算

12.1 OpenAI API 成本

假設:

  • GPT-4o Vision 分析一張截圖(包含 Prompt 和輸出)約 $0.05
  • GPT-4o 複雜推理(診斷、規劃)一次約 $0.03
  • GPT-4o-mini 簡單判斷一次約 $0.001
場景 預計執行次數/天 單位成本 日成本
診斷迴圈 50 次 $0.03 $1.50
探索迴圈 200 次 $0.03 $6.00
視覺定位 1000 次 $0.05 $50.00
GPT-4o-mini 5000 次 $0.001 $5.00
總計 $62.50

每月預估成本: $62.50 * 22 個工作天 = $1375 USD

12.2 人力成本 ( PoC 階段 8 週 )

角色 人月 薪資/月 總計
資深 Java 開發 (AI 專長) 2 $5000 $10000
資深 QA (需求定義) 0.5 $3500 $1750
總計 $11750 USD

12.3 硬體成本

PoC 階段可使用現有開發機器。生產環境需額外考慮 Docker Host 或 K8s 節點資源。


十三、決策檢查點

  • 8 週後:PoC 結束時,診斷準確率是否達到 80%?AI 診斷時間是否 < 30 秒?如果未達標,是否需要調整策略或停止?
  • 20 週後:MVP 結束時,腳本存活率是否達到 95%?AI Co-pilot 是否能顯著提升 QA 效率?每月 Token 成本是否在預算內?
  • 36 週後:Production 階段,新發現 Bug 數量是否顯著增加?測試覆蓋率是否達到 80%?是否真的能釋放 QA 的人力,使其從執行轉向策略?

只有在這些檢查點達成預期目標後,才能繼續投入資源,逐步將 AI 自主測試擴展到更多業務模組。

Leave a Comment