基于自然语言的端到端自动化测试

341 阅读10分钟
  • 此方案已具备生产级自动化测试的雏形,在原型开发阶段重点验证 LLM 解析准确率(通过 100 + 测试用例人工校验)和动态场景适配能力,逐步完善容错机制和扩展点。
  • 且具备较高的创新性和实用性,优化后可作为企业级测试基础设施。建议在实际落地时配合监控系统实时跟踪测试生成成功率等关键指标。

行业现状与痛点分析

  1. 传统E2E测试的挑战
    • 测试用例维护成本高
    • 技术门槛导致的协作困难
    • 敏捷开发中的响应速度瓶颈
  2. 现有解决方案局限性
    • 录制回放工具的脆弱性
    • 动态内容处理的不足

三代技术方案演进

第一代:传统代码驱动方案

  • 技术栈:Playwright/Cypress + JavaScript/TypeScript
  • 典型工作流:

deepseek_mermaid_20250525_f5b174.png

  • 痛点演示:元素变更导致用例失效案例

第二代:行为录制转译方案

  • 技术升级点:
    • 浏览器扩展录制(Playwright Codegen)
    • AST语法树转换(Babel)
    • 可视化编辑(JSON Schema)
  • 架构设计:

deepseek_mermaid_20250525_cb1912.png

  • 核心问题:复杂场景的语义理解缺失

第三代:自然语言驱动方案

  • 技术突破点:

    • NLP语义理解(GPT-4/ChatGLM)
    • 多模态指令解析
    • 动态策略生成
  • 系统架构:

deepseek_mermaid_20250525_6361cd.png

  • 关键技术组件:
    1. 语义标准化层(SPL:Semantic Protocol Layer)
      1. 如定义统一的动作枚举(navigate/click/fill/assert
      2. 参数规范(如selector优先级:data-testid > id > class
    2. 容错决策引擎
      1. 当 LLM 生成无效选择器时,启用兜底方案(模糊文本搜索 + DOM 遍历)
    3. 上下文感知模块

方案对比与效果验证

维度第一代第二代第三代
开发效率1x2x10x
维护成本
场景覆盖率85%70%95%+
非技术参与度0%30%80%+

传统的基于Playwright或Cypress手动维护,升级版用录制转译,终极版用大模型自然语言转换

第一步:自然语言输入的处理

用户输入“请打开百度网盘外链地址x,检查提取码输入框是否存在,然后输入提取码,检查是否提取成功并开始预览”。这里的关键是将自然语言转化为结构化的步骤,需要用到NLP模型。可能需要使用 OpenAI GPT-4 / LangChain,因为需要理解用户的意图并将其分解为具体的测试步骤。

第二步:网页加载和鉴权处理

涉及网页加载和鉴权处理,然后结合渲染的HTML和结构化数据生成JSON。需要考虑如何在自动化测试中处理鉴权,比如用户登录状态的保持。可能的解决方案是使用浏览器自动化工具(如Playwright或Cypress本身)来处理登录流程,或者通过注入cookies、localStorage等方式。这部分可能需要编写一些预处理脚本,确保测试环境已经通过鉴权。

接下来,将自然语言结构化的问题。需要设计一个合适的Prompt,让大模型能够准确地将自然语言指令转化为结构化的JSON格式。例如,用户指令中的每个动作(打开URL、检查元素存在、输入文本、验证结果)都需要对应到JSON中的不同步骤,每个步骤包含动作类型、参数、断言等信息。需要确保模型输出的JSON结构一致,可能需要后处理来验证和修正结构。

第三步:通用的JSON结构转换为具体的测试代码

将通用的JSON结构转换为具体的测试代码。这里需要为每个测试框架(Playwright和Cypress)编写转换器。例如,JSON中的“检查元素是否存在”会被转换为对应框架的断言语句,如Playwright的expect(page.locator('selector')).toBeVisible()。需要确保转换器能够处理所有可能的动作类型,并生成正确的代码。

第四步:执行生成的测试代码并处理结果

执行生成的测试代码并处理结果。这相对直接,因为Playwright和Cypress都有自己的运行机制。但需要考虑如何动态加载生成的代码并执行,可能需要将代码写入临时文件,然后通过命令行或API调用测试运行器。

自然语言输入 → 大模型处理 → 结构化JSON → 代码生成器 → Playwright/Cypress代码 → 测试执行
           ↑           ↑
          页面元素信息   鉴权状态

技术栈与工具选型

阶段核心工具 / 库作用描述
自然语言解析OpenAI GPT-4 / LangChain将自然语言指令解析为结构化 JSON(结合 HTML 上下文)
网页加载与鉴权Playwright / Cypress + Axios加载网页并处理鉴权(Cookie 注入、LocalStorage、API 调用)
结构化 JSON 生成JSON Schema + OpenAI Function Calling定义标准化 JSON 格式,确保模型输出符合规范
测试代码转换EJS 模板引擎 / 自定义转换器将抽象 JSON 转换为 Playwright/Cypress 可执行的断言代码
测试执行与报告Playwright Test / Cypress + Allure执行测试并生成可视化报告

核心步骤实现

1. 网页加载与鉴权处理(Playwright)

场景:通过 Cookie 注入绕过登录鉴权(适用于 Session-Based 鉴权)

// 1. 预获取鉴权信息(如Cookie,可通过API调用或手动获取)
const authInfo = {
    cookies: [
        {
            name: "sessionId",
            value: "xxx",
            domain: "pan.baidu.com",
            path: "/",
            expires: Date.now() + 86400 * 1000, // 有效期1天
        },
    ],
    storageState: "authState.json", // 保存上下文状态(含Cookie、LocalStorage等)
};

// 2. 创建带鉴权的浏览器上下文
const browser = await chromium.launch();
const context = await browser.newContext({
    storageState: authInfo.storageState, // 直接加载鉴权状态
});

// 或手动注入Cookie
// await context.addCookies(authInfo.cookies);

// 3. 加载目标页面(自动携带鉴权信息)
const page = await context.newPage();
await page.goto("https://pan.baidu.com/s/xxx");

表单登录:先执行登录流程,保存context状态供后续测试复用

// auth-setup.js
const { chromium } = require('playwright');

async function globalSetup() {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  // 执行登录流程
  await page.goto('https://example.com/login');
  await page.fill('#username', process.env.TEST_USER);
  await page.fill('#password', process.env.TEST_PASS);
  await page.click('#login-btn');

  // 保存鉴权状态
  await context.storageState({ path: 'auth-state.json' });
  await browser.close();
}

module.exports = globalSetup;

其他鉴权方式

  • Token 鉴权:通过page.route()拦截请求,注入Authorization请求头
page.route('**/api/refreshtoken', async (route) => {
  const response = await fetch('https://api.example.com/auth/refresh', {
    headers: { 'Authorization': `Bearer ${process.env.REFRESH_TOKEN}` }
  });
  const newToken = await response.json();
  await route.fulfill({ json: { token: newToken.access_token } });
});
  • SSO 鉴权:使用 Playwright 的route()模拟 OAuth 回调
  • 敏感数据处理使用环境变量注入,使用dotenv加载TEST_USER/TEST_PASS,避免硬编码在代码中。
  • 针对动态 Token 刷新场景,使用page.route()拦截 401 响应并自动调用刷新接口。

2. 自然语言解析与结构化 JSON 生成

步骤

  1. 加载页面后获取 HTML 内容
  2. 将自然语言指令与 HTML 传入 LLM,生成符合 JSON Schema 的结构化数据

使用 page.content() 获取完整 HTML

  • 通过 page.content() 获取鉴权后的完整页面 HTML 内容,避免遍历元素带来的性能损耗和选择器遗漏问题12。
  • 结合大语言模型(如 GPT-4)解析 HTML 结构和用户指令,生成更精准的测试步骤。
  • 在获取 HTML 前,通过 page.wait_for_selector()page.wait_for_load_state() 确保页面完全加载34。
  • 针对 iframe 嵌套内容,使用 frame.content() 获取子框架的 HTML。需明确frame.content()的调用时机(如等待子框架加载完成),避免因异步加载导致 HTML 不完整。

使用 page.content() 结合大语言模型的方案在以下场景更具优势:

  1. 页面结构复杂,元素动态加载
  2. 需要快速适配频繁变化的 UI
  3. 测试用例需要高度语义化描述
页面HTML元素信息提取
import { OpenAI } from "langchain/llms/openai";
import { StructuredOutputParser } from "langchain/output_parsers";

// 定义JSON Schema(抽象测试步骤)
const parser = StructuredOutputParser.fromZodSchema(z.object({
  steps: z.array(z.object({
    action: z.enum(["open_url", "check_element", "input_text", "click", "assert"]),
    target: z.string(), // 元素选择器(CSS/XPath)
    params: z.object({
      url?: z.string(),
      value?: z.string(),
      expected?: z.string(),
      timeout?: z.number()
    })
  }))
}));

// 构建提示词(包含HTML上下文与解析指令)
const formatInstructions = parser.getFormatInstructions();

const llm = new OpenAI({ modelName: "gpt-4", temperature: 0.1 });

async function generateTestSteps(naturalLanguage: string, html: string) {
  // 提取iframe信息
  const iframeSelectors = extractIframes(html); // 自定义函数,解析HTML中的iframe
  const prompt = `
        请根据以下自然语言指令和网页HTML,生成测试步骤JSON:
        自然语言:${naturalLanguage}
        HTML:${html}
        ${formatInstructions}
        
        输出JSON必须符合以下规则:
        1. 优先使用data-testid选择器,其次是id,然后是class
        2. 对于按钮,使用包含文本的选择器(如:has-text("提交"))
        3. 对于iframe中的元素,使用"iframe[selector] >> element[selector]"格式
      
        示例:
        输入: "打开百度首页,搜索'大模型'"
        输出:
        {
            "steps": [
              {
                "action": "open_url",
                "params": { "url": "https://www.baidu.com" }
              },
              {
                "action": "input_text",
                "target": "input[id='kw']",
                "params": { "value": "大模型" }
              },
              {
                "action": "click",
                "target": "button:has-text('百度一下')"
              }
            ]
        }

        输入: "点击第2个商品的'加入购物车'按钮"
        输出:
        {
          "steps": [
            {
              "action": "click",
              "target": "div.product-list >> div:nth-child(2) >> button:has-text('加入购物车')"
            }
          ]
        }
      `;

  const result = await llm.predict(prompt);
  return parser.parse(result); // 解析为结构化对象
}

// 使用示例
const html = await page.content(); // 获取页面HTML
const steps = await generateTestSteps(
  "请打开百度网盘外链地址 x,检查提取码输入框是否存在,然后输入提取码1234,检查是否提取成功并开始预览",
  html
);
const validSteps = steps.steps.filter(step =>
  validateSelector(step.target, html)
);
  • 增加重试机制:当解析失败(如 JSON 格式错误)时,自动触发 2-3 次重试,结合正则表达式兜底解析。
  • assert动作补充预期值类型校验,例如:type必须为visible/textEquals/toHaveAttribute等枚举值。

预期 JSON 输出

{
  "steps": [
    {
      "action": "open_url",
      "params": {
        "url": "https://pan.baidu.com/s/x"
      }
    },
    {
      "action": "check_element",
      "target": "input[name='pwd']",
      "params": {
        "existence": "exist"
      }
    },
    {
      "action": "input_text",
      "target": "input[name='pwd']",
      "params": {
        "value": "1234"
      }
    },
    {
      "action": "click",
      "target": "button[type='submit']"
    },
    {
      "action": "assert",
      "target": ".preview-container",
      "params": {
        "expected": "visible",
        "timeout": 5000
      }
    }
  ]
}

3. 抽象 JSON 转换为测试代码(Playwright)

使用 EJS 模板引擎生成代码

// playwright-generator.js
const Handlebars = require('handlebars');

const template = `
    const { test, expect } = require('@playwright/test');

    test('Generated Test', async ({ page }) => {
      await page.goto('{{initialUrl}}');
      {{#each steps}}
      // Step {{@index}}: {{{description}}}
      {{#if (eq action 'click')}}
      await page.locator('{{selector}}').click();
      {{else if (eq action 'fill')}}
      await page.locator('{{selector}}').fill('{{value}}');
      {{else if (eq action 'assert')}}
      await expect(page.locator('{{selector}}')).{{assertion}}({{#if expected}}'{{expected}}'{{/if}});
      {{#else if step.waitUntil}}
      await page.waitForLoadState('{{step.waitUntil}}');
      {{/if}}
      {{/each}}
    });
`;

Handlebars.registerHelper('eq', (a, b) => a === b);

function generateCode(data) {
  const compile = Handlebars.compile(template);
  return compile(data);
}

转换逻辑实现

const { resolve } = require('path');
const { readFile, writeFile } = require('fs/promises');
const ejs = require('ejs');

async function convertToPlaywrightCode(jsonSteps, testName) {
  const templatePath = resolve(__dirname, 'test-template.ejs');
  const template = await readFile(templatePath, 'utf8');
  return ejs.render(template, { steps: jsonSteps.steps, testName });
}

// 生成测试文件
const playwrightCode = await convertToPlaywrightCode(steps, "百度网盘提取码测试");
await writeFile("baidu-pan-test.spec.ts", playwrightCode);

示例输出

输入自然语言: "请打开文件分享页面,验证提取码输入框存在,输入正确提取码后检查预览面板是否显示"

生成 JSON

{
  "steps": [
    {
      "action": "navigate",
      "url": "https://pan.example.com/share/abc123",
      "waitUntil": "networkidle"
    },
    {
      "action": "assert",
      "selector": "#extractCodeInput",
      "type": "visible",
      "timeout": 5000
    },
    {
      "action": "fill",
      "selector": "#extractCodeInput",
      "value": "7x9p"
    },
    {
      "action": "click",
      "selector": "#submitBtn"
    },
    {
      "action": "assert",
      "selector": ".preview-panel",
      "type": "containsText",
      "value": "预览内容"
    }
  ]
}

生成 Playwright 代码

const { test, expect } = require('@playwright/test');

test('Generated Test', async ({ page }) => {
  await page.goto('https://pan.example.com/share/abc123');

  // Step 0: 验证提取码输入框存在
  await expect(page.locator('#extractCodeInput')).toBeVisible({ timeout: 5000 });

  // Step 1: 输入提取码
  await page.locator('#extractCodeInput').fill('7x9p');

  // Step 2: 点击提交按钮
  await page.locator('#submitBtn').click();

  // Step 3: 验证预览面板
  await expect(page.locator('.preview-panel')).toContainText('预览内容');
});

// 测试后钩子
test.afterAll(async ({ context }) => {
  await context.clearCookies();
  await fs.promises.unlink('auth-state.json'); // 删除存储的鉴权文件
});

验证指标

指标目标值测量方法
自然语言识别准确率>90%人工验证 100 个测试用例
元素定位准确率>95%对生成的选择器进行 DOM 验证
测试生成时间<30s端到端性能测试
跨浏览器支持100%在 Chrome/Firefox/Safari 验证

核心步骤代码:

image.png


// nl2json.js
const { chromium } = require('playwright');
const { Configuration, OpenAIApi } = require('openai');
const cheerio = require('cheerio');

// 初始化 OpenAI
const config = new Configuration({ apiKey: process.env.OPENAI_KEY });
const openai = new OpenAIApi(config);

async function generateTestSteps(prompt, targetUrl) {
  // 步骤 1:获取鉴权后的完整 HTML
  const html = await getAuthenticatedHtml(targetUrl);

  // 步骤 2:预处理 HTML(关键区域提取)
  const processedHtml = preprocessHtml(html);

  // 步骤 3:调用 GPT-4 生成结构化 JSON
  const testSteps = await callGPT4(prompt, processedHtml);

  // 步骤 4:验证 JSON 结构
  return validateJsonStructure(testSteps);
}

// ================== 核心工具函数 ================== //

async function getAuthenticatedHtml(url) {
  const browser = await chromium.launch();
  const context = await browser.newContext({ storageState: 'auth-state.json' });
  const page = await context.newPage();

  try {
    // 智能等待策略
    await page.goto(url, { waitUntil: 'domcontentloaded' });
    await page.waitForLoadState('networkidle', { timeout: 15000 });
    await page.waitForSelector('body', { state: 'attached' });

    // 处理 iframe 内容
    const frameHtmls = await Promise.all(
      page.frames().map(async (frame) => ({
        url: frame.url(),
        content: await frame.content()
      }))
    );

    const mainHtml = await page.content();
    await browser.close();

    // 合并所有框架内容
    return [
      { type: 'main', content: mainHtml },
      ...frameHtmls.map(f => ({ type: 'frame', ...f }))
    ];
  } catch (error) {
    await browser.close();
    throw new Error(`HTML获取失败: ${error.message}`);
  }
}

function preprocessHtml(htmlFrames) {
  // 使用 cheerio 提取关键区域
  return htmlFrames.map(({ type, content, url }) => {
    const $ = cheerio.load(content);

    // 优先级:main > div#content > body
    let mainContent = $('main').html()
      || $('#content').html()
      || $('body').html();

    // 移除脚本和样式
    mainContent = mainContent
      .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
      .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');

    return {
      type,
      url,
      content: mainContent.substring(0, 15000) // 控制长度
    };
  });
}

async function callGPT4(prompt, processedHtml) {
  // 构建系统提示词
  const systemMessage = `您是一个专业的测试自动化工程师,请根据以下要求转换自然语言指令:
    1. 输出严格遵循JSON格式
    2. 每个步骤必须包含 action (navigate/click/fill/assert)
    3. selector 优先使用 #id 或 [data-testid]
    4. 对动态加载内容自动添加等待逻辑
    5. 断言必须包含预期值`;

  // 构建用户消息
  const userContent = [
    `用户指令: ${prompt}`,
    '当前页面结构:',
    ...processedHtml.map((h, i) =>
      `Frame ${i} (${h.url}):\n${h.content}`)
  ].join('\n\n');

  try {
    const response = await openai.createChatCompletion({
      model: "gpt-4-turbo",
      messages: [
        { role: "system", content: systemMessage },
        { role: "user", content: userContent }
      ],
      response_format: { type: "json_object" }
    });

    return JSON.parse(response.data.choices[0].message.content);
  } catch (error) {
    throw new Error(`AI处理失败: ${error.response?.data?.error?.message || error.message}`);
  }
}

function validateJsonStructure(json) {
  // 基础结构验证
  const REQUIRED_KEYS = ['steps'];
  const STEP_KEYS = ['action', 'selector'];

  if (!REQUIRED_KEYS.every(k => json.hasOwnProperty(k))) {
    throw new Error('无效的JSON结构:缺少必需字段');
  }

  // 步骤级验证
  json.steps.forEach((step, index) => {
    if (!STEP_KEYS.every(k => step.hasOwnProperty(k))) {
      throw new Error(`步骤 ${index + 1} 缺少必需字段`);
    }

    // 验证 action 类型
    const validActions = ['navigate', 'click', 'fill', 'assert'];
    if (!validActions.includes(step.action)) {
      throw new Error(`步骤 ${index + 1} 包含无效 action: ${step.action}`);
    }

    // 导航步骤特殊处理
    if (step.action === 'navigate' && !step.url) {
      throw new Error(`导航步骤 ${index + 1} 缺少 url 参数`);
    }
  });

  return json;
}

// 使用示例
generateTestSteps(
  "登录后搜索'测试用户'并验证结果",
  "https://example.com/user-management"
)
  .then(console.log)
  .catch(console.error);

性能优化技巧

  1. HTML缓存机制:javascript复制下载
const cache = new Map();
async function getHtmlWithCache(url) {
  const entry = cache.get(url);
  if (entry && Date.now() - entry.timestamp < 300000) { // 5分钟有效期
    return entry.html;
  }
  const html = await getAuthenticatedHtml(url);
  cache.set(url, { html, timestamp: Date.now() });
  return html;
}
  1. 选择器验证
// 选择器验证与优化
function validateSelector(selector, html) {
  // 使用JSDOM模拟验证选择器有效性
  const { JSDOM } = require('jsdom');
  const dom = new JSDOM(html);
  const document = dom.window.document;

  try {
    const elements = document.querySelectorAll(selector);
    if (elements.length === 0) {
      throw new Error(`Selector "${selector}" matches no elements`);
    }
    return true;
  } catch (e) {
    console.warn(`Invalid selector: ${selector}`, e);
    return false;
  }
}
  1. 动态内容适配
function injectWaitLogic(steps) {
  return steps.map(step => {
    if (step.action === 'click' && step.selector.includes('dynamic')) {
      return {
        ...step,
        preAction: `await page.waitForSelector('${step.selector}')`
      }
    }
    return step;
  });
}
  1. 完整工作流示例
# 1. 生成鉴权状态
npx playwright test auth-setup.js

# 2. 执行Node.js处理流程
node nl2json.js \
  --prompt "验证登录后个人中心显示用户名" \
  --url "https://example.com/profile" \
  --output test-steps.json

# 3. 生成测试代码
node playwright-generator.js test-steps.json > generated.spec.js

# 4. 执行测试
npx playwright test generated.spec.js

典型输出结构

{
  "steps": [
    {
      "action": "navigate",
      "url": "/profile",
      "waitUntil": "networkidle"
    },
    {
      "action": "assert",
      "selector": "#usernameDisplay",
      "type": "textEquals",
      "value": "测试用户"
    },
    {
      "action": "click",
      "selector": "[data-testid='logout-button']",
      "timeout": 5000
    }
  ]
}

优势总结

  1. 全Node.js技术栈:避免跨语言调用复杂度
  2. 精准元素定位:结合DOM结构分析与AI语义理解
  3. 生产级健壮性
    • 多层错误处理
    • 输入验证
    • 性能优化
  4. 可扩展架构
    • 方便添加新action类型
    • 支持自定义验证规则
    • 易于集成到CI/CD流水线

具体细节功能点补充:

一、敏感数据环境变量注入

// .env 文件(项目根目录)
TEST_URL = https://pan.baidu.com/s/xxxx
TEST_PASSWORD = 1234
API_TOKEN = your_token_here

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';

dotenv.config(); // 加载环境变量

export default defineConfig({
  use: {
    baseURL: process.env.TEST_URL,
    storageState: 'playwright/.auth/user.json',
    extraHTTPHeaders: {
      'Authorization': `Bearer ${process.env.API_TOKEN}`
    }
  }
});

// 测试用例中使用
test('输入提取码', async ({ page }) => {
  await page.locator('input[name="pwd"]').fill(process.env.TEST_PASSWORD);
});

二、iframe 处理与动态内容等待

function extractIframes(html) {
  const $ = cheerio.load(html);
  return Array.from($('iframe')).map(iframe => {
    const name = $(iframe).attr('name');
    const src = $(iframe).attr('src');
    return name || src ? `iframe[name="${name}"][src="${src}"]` : 'iframe';
  });
}

// 自然语言解析器增强 - 处理iframe
async function generateTestSteps(naturalLanguage, html) {
  // 提取iframe信息
  const iframeSelectors = extractIframes(html); // 自定义函数,解析HTML中的iframe

  // 构建带iframe上下文的提示词
  const prompt = `
        网页包含以下iframe: ${iframeSelectors.join(', ')}
        
        请根据自然语言生成测试步骤,注意iframe嵌套:
        自然语言: ${naturalLanguage}
        HTML: ${html.substring(0, 10000)} // 截断HTML防止超长
        
        示例输出格式:
        {
          "steps": [
            {
              "action": "open_url",
              "params": { "url": "https://example.com" }
            },
            {
              "action": "check_element",
              "target": "iframe[name='content'] >> input[name='search']",
              "params": { "existence": "exist" }
            }
          ]
        }
        
        示例:
        输入: "点击第2个商品的'加入购物车'按钮"
        输出:
        {
          "steps": [
            {
              "action": "click",
              "target": "div.product-list >> div:nth-child(2) >> button:has-text('加入购物车')"
            }
          ]
        }
      `;

  const result = await llm.predict(prompt);
  return parser.parse(result);
}

// 执行带iframe的测试步骤
async function executeStep(page, step) {
  let targetElement = page;

  // 处理iframe嵌套
  if (step.target.includes('>>')) {
    const [iframeSelector, elementSelector] = step.target.split('>>').map(s => s.trim());
    const frame = await page.frameLocator(iframeSelector).first();
    targetElement = frame.locator(elementSelector);
  } else {
    targetElement = page.locator(step.target);
  }

  // 执行动作(示例:click)
  if (step.action === 'click') {
    await targetElement.click({ timeout: step.params.timeout || 5000 });
  }
}

三、测试执行与报告增强

// playwright.config.js (增强版)
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  timeout: 60000, // 全局超时时间
  retries: process.env.CI ? 2 : 0, // CI环境自动重试2次

  reporter: [
    ['html', { open: 'never', outputFolder: 'playwright-report' }],
    ['allure-playwright'],
    ['list']
  ],

  use: {
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'retain-on-failure'
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    }
  ]
});