680 lines
18 KiB
Markdown
680 lines
18 KiB
Markdown
# 高级工作流
|
||
|
||
> **适用场景**:复杂的多步骤自动化场景——多页面抓取、弹窗与新窗口处理、无障碍审计、文件下载、OAuth 认证流程、无限滚动提取、表单向导自动化,以及将多个 CLI 功能组合使用。
|
||
> **前置知识**:[core-commands.md](core-commands.md)、[running-custom-code.md](running-custom-code.md)、[session-management.md](session-management.md)
|
||
|
||
## 快速参考
|
||
|
||
```bash
|
||
# 多页面抓取
|
||
playwright-cli run-code "async page => {
|
||
const data = [];
|
||
for (let i = 1; i <= 5; i++) {
|
||
await page.goto(\`https://example.com/page/\${i}\`);
|
||
const items = await page.locator('.item').allTextContents();
|
||
data.push(...items);
|
||
}
|
||
return data;
|
||
}"
|
||
|
||
# 处理弹窗
|
||
playwright-cli run-code "async page => {
|
||
const [popup] = await Promise.all([
|
||
page.waitForEvent('popup'),
|
||
page.click('a[target=_blank]')
|
||
]);
|
||
return await popup.title();
|
||
}"
|
||
|
||
# 无障碍快照
|
||
playwright-cli run-code "async page => {
|
||
return await page.accessibility.snapshot();
|
||
}"
|
||
```
|
||
|
||
## 弹窗与新窗口处理
|
||
|
||
当点击的链接会打开新窗口或弹窗时:
|
||
|
||
### 捕获弹窗内容
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
const [popup] = await Promise.all([
|
||
page.waitForEvent('popup'),
|
||
page.click('a[target=_blank]')
|
||
]);
|
||
|
||
await popup.waitForLoadState();
|
||
const title = await popup.title();
|
||
const url = popup.url();
|
||
|
||
return { title, url };
|
||
}"
|
||
```
|
||
|
||
### 与弹窗交互
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
const [popup] = await Promise.all([
|
||
page.waitForEvent('popup'),
|
||
page.click('#open-settings')
|
||
]);
|
||
|
||
// 在弹窗中填写表单
|
||
await popup.fill('input[name=email]', 'user@example.com');
|
||
await popup.click('button:text(\"Save\")');
|
||
|
||
// 等待弹窗关闭
|
||
await popup.waitForEvent('close');
|
||
return 'Popup handled';
|
||
}"
|
||
```
|
||
|
||
### 使用弹窗进行 OAuth 登录
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
await page.goto('https://app.example.com/login');
|
||
|
||
const [popup] = await Promise.all([
|
||
page.waitForEvent('popup'),
|
||
page.click('button:text(\"Sign in with Google\")')
|
||
]);
|
||
|
||
// 在弹窗中处理 Google OAuth
|
||
await popup.fill('input[type=email]', 'user@gmail.com');
|
||
await popup.click('#identifierNext');
|
||
await popup.waitForSelector('input[type=password]', { state: 'visible' });
|
||
await popup.fill('input[type=password]', 'password');
|
||
await popup.click('#passwordNext');
|
||
|
||
// 认证后弹窗关闭,主页面跳转
|
||
await popup.waitForEvent('close');
|
||
await page.waitForURL('**/dashboard');
|
||
|
||
return 'OAuth login complete: ' + page.url();
|
||
}"
|
||
```
|
||
|
||
## 多页面导航与抓取
|
||
|
||
### 分页数据提取
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
await page.goto('https://example.com/products');
|
||
const allProducts = [];
|
||
|
||
while (true) {
|
||
// 从当前页面提取数据
|
||
const products = await page.locator('.product-card').evaluateAll(cards =>
|
||
cards.map(card => ({
|
||
name: card.querySelector('.name')?.textContent?.trim(),
|
||
price: card.querySelector('.price')?.textContent?.trim(),
|
||
rating: card.querySelector('.rating')?.getAttribute('data-score')
|
||
}))
|
||
);
|
||
allProducts.push(...products);
|
||
|
||
// 检查是否有下一页
|
||
const nextBtn = page.locator('a.next-page');
|
||
if (await nextBtn.isVisible()) {
|
||
await nextBtn.click();
|
||
await page.waitForLoadState('networkidle');
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return { total: allProducts.length, products: allProducts };
|
||
}"
|
||
```
|
||
|
||
### 无限滚动提取
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
await page.goto('https://example.com/feed');
|
||
const seen = new Set();
|
||
const items = [];
|
||
const maxItems = 100;
|
||
|
||
while (items.length < maxItems) {
|
||
// 收集可见条目
|
||
const newItems = await page.locator('.feed-item').evaluateAll(
|
||
els => els.map(el => ({
|
||
id: el.getAttribute('data-id'),
|
||
text: el.textContent.trim()
|
||
}))
|
||
);
|
||
|
||
for (const item of newItems) {
|
||
if (item.id && !seen.has(item.id)) {
|
||
seen.add(item.id);
|
||
items.push(item);
|
||
}
|
||
}
|
||
|
||
// 滚动到底部
|
||
const previousHeight = await page.evaluate(() => document.body.scrollHeight);
|
||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 检查是否已到达底部
|
||
const newHeight = await page.evaluate(() => document.body.scrollHeight);
|
||
if (newHeight === previousHeight) break;
|
||
}
|
||
|
||
return { collected: items.length, items: items.slice(0, maxItems) };
|
||
}"
|
||
```
|
||
|
||
### 多站点数据聚合
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# 使用命名会话实现并发站点抓取
|
||
|
||
playwright-cli -s=site1 open https://news.example.com &
|
||
playwright-cli -s=site2 open https://blog.example.com &
|
||
playwright-cli -s=site3 open https://docs.example.com &
|
||
wait
|
||
|
||
# 从每个站点提取标题
|
||
playwright-cli -s=site1 run-code "async page => {
|
||
return await page.locator('h2.headline').allTextContents();
|
||
}"
|
||
|
||
playwright-cli -s=site2 run-code "async page => {
|
||
return await page.locator('.post-title').allTextContents();
|
||
}"
|
||
|
||
playwright-cli -s=site3 run-code "async page => {
|
||
return await page.locator('.doc-title').allTextContents();
|
||
}"
|
||
|
||
playwright-cli close-all
|
||
```
|
||
|
||
## 文件上传与下载
|
||
|
||
### 下载文件
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
const [download] = await Promise.all([
|
||
page.waitForEvent('download'),
|
||
page.click('a.download-link')
|
||
]);
|
||
|
||
const filename = download.suggestedFilename();
|
||
await download.saveAs('./downloads/' + filename);
|
||
return {
|
||
filename,
|
||
url: download.url()
|
||
};
|
||
}"
|
||
```
|
||
|
||
### 下载多个文件
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
const downloadLinks = await page.locator('a.download').all();
|
||
const files = [];
|
||
|
||
for (const link of downloadLinks) {
|
||
const [download] = await Promise.all([
|
||
page.waitForEvent('download'),
|
||
link.click()
|
||
]);
|
||
const name = download.suggestedFilename();
|
||
await download.saveAs('./downloads/' + name);
|
||
files.push(name);
|
||
}
|
||
|
||
return files;
|
||
}"
|
||
```
|
||
|
||
### 上传文件
|
||
|
||
```bash
|
||
# 通过元素引用进行简单上传
|
||
playwright-cli snapshot
|
||
playwright-cli upload e5 ./document.pdf
|
||
|
||
# 上传多个文件
|
||
playwright-cli upload e5 ./photo1.jpg ./photo2.jpg ./photo3.jpg
|
||
|
||
# 编程式上传(适用于隐藏的文件输入)
|
||
playwright-cli run-code "async page => {
|
||
const fileInput = page.locator('input[type=file]');
|
||
await fileInput.setInputFiles('./report.pdf');
|
||
}"
|
||
|
||
# 编程式上传多个文件
|
||
playwright-cli run-code "async page => {
|
||
const fileInput = page.locator('input[type=file]');
|
||
await fileInput.setInputFiles([
|
||
'./document1.pdf',
|
||
'./document2.pdf',
|
||
'./image.png'
|
||
]);
|
||
}"
|
||
|
||
# 清除文件输入
|
||
playwright-cli run-code "async page => {
|
||
await page.locator('input[type=file]').setInputFiles([]);
|
||
}"
|
||
```
|
||
|
||
### 拖放文件上传
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
// 为拖放区域创建模拟文件
|
||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||
await page.dispatchEvent('.dropzone', 'drop', { dataTransfer });
|
||
}"
|
||
```
|
||
|
||
## 无障碍审计
|
||
|
||
### 无障碍树快照
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
const snapshot = await page.accessibility.snapshot();
|
||
return JSON.stringify(snapshot, null, 2);
|
||
}"
|
||
```
|
||
|
||
### 检查缺失的 ARIA 标签
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
return await page.evaluate(() => {
|
||
const issues = [];
|
||
|
||
// 没有 alt 文本的图片
|
||
document.querySelectorAll('img:not([alt])').forEach(img => {
|
||
issues.push({ type: 'img-no-alt', src: img.src.substring(0, 50) });
|
||
});
|
||
|
||
// 没有无障碍名称的按钮
|
||
document.querySelectorAll('button').forEach(btn => {
|
||
if (!btn.textContent.trim() && !btn.getAttribute('aria-label')) {
|
||
issues.push({ type: 'button-no-name', html: btn.outerHTML.substring(0, 80) });
|
||
}
|
||
});
|
||
|
||
// 没有标签的表单输入
|
||
document.querySelectorAll('input:not([type=hidden])').forEach(input => {
|
||
const id = input.id;
|
||
const hasLabel = id && document.querySelector(\`label[for=\${id}]\`);
|
||
const hasAriaLabel = input.getAttribute('aria-label') || input.getAttribute('aria-labelledby');
|
||
if (!hasLabel && !hasAriaLabel) {
|
||
issues.push({ type: 'input-no-label', name: input.name, type: input.type });
|
||
}
|
||
});
|
||
|
||
// 没有文本的链接
|
||
document.querySelectorAll('a').forEach(link => {
|
||
if (!link.textContent.trim() && !link.getAttribute('aria-label')) {
|
||
issues.push({ type: 'link-no-text', href: link.href.substring(0, 50) });
|
||
}
|
||
});
|
||
|
||
return { issueCount: issues.length, issues };
|
||
});
|
||
}"
|
||
```
|
||
|
||
### 颜色对比度检查
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
return await page.evaluate(() => {
|
||
const getContrast = (rgb1, rgb2) => {
|
||
const luminance = (r, g, b) => {
|
||
const [rs, gs, bs] = [r, g, b].map(c => {
|
||
c = c / 255;
|
||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||
});
|
||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||
};
|
||
const l1 = luminance(...rgb1);
|
||
const l2 = luminance(...rgb2);
|
||
const lighter = Math.max(l1, l2);
|
||
const darker = Math.min(l1, l2);
|
||
return (lighter + 0.05) / (darker + 0.05);
|
||
};
|
||
|
||
const parseRGB = (str) => {
|
||
const match = str.match(/\d+/g);
|
||
return match ? match.slice(0, 3).map(Number) : [0, 0, 0];
|
||
};
|
||
|
||
const lowContrast = [];
|
||
document.querySelectorAll('p, span, a, button, label, h1, h2, h3, h4, h5, h6').forEach(el => {
|
||
const style = window.getComputedStyle(el);
|
||
const fg = parseRGB(style.color);
|
||
const bg = parseRGB(style.backgroundColor);
|
||
const ratio = getContrast(fg, bg);
|
||
if (ratio < 4.5) {
|
||
lowContrast.push({
|
||
text: el.textContent.trim().substring(0, 30),
|
||
ratio: ratio.toFixed(2),
|
||
fg: style.color,
|
||
bg: style.backgroundColor
|
||
});
|
||
}
|
||
});
|
||
|
||
return { lowContrastCount: lowContrast.length, elements: lowContrast.slice(0, 20) };
|
||
});
|
||
}"
|
||
```
|
||
|
||
### Tab 键顺序验证
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
return await page.evaluate(() => {
|
||
const focusable = Array.from(document.querySelectorAll(
|
||
'a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'
|
||
));
|
||
return focusable.map((el, i) => ({
|
||
order: i + 1,
|
||
tag: el.tagName.toLowerCase(),
|
||
text: (el.textContent || el.getAttribute('aria-label') || el.name || '').trim().substring(0, 40),
|
||
tabIndex: el.tabIndex
|
||
}));
|
||
});
|
||
}"
|
||
```
|
||
|
||
## 表单向导自动化
|
||
|
||
### 多步骤表单
|
||
|
||
```bash
|
||
# 步骤 1:个人信息
|
||
playwright-cli open https://example.com/apply
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "Jane" # 名
|
||
playwright-cli fill e2 "Doe" # 姓
|
||
playwright-cli fill e3 "jane@example.com" # 邮箱
|
||
playwright-cli fill e4 "+1-555-0123" # 电话
|
||
playwright-cli click e5 # 下一步
|
||
|
||
# 步骤 2:地址
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "123 Main Street"
|
||
playwright-cli fill e2 "Apt 4B"
|
||
playwright-cli fill e3 "Springfield"
|
||
playwright-cli select e4 "IL"
|
||
playwright-cli fill e5 "62701"
|
||
playwright-cli click e6 # 下一步
|
||
|
||
# 步骤 3:上传文档
|
||
playwright-cli snapshot
|
||
playwright-cli upload e1 ./resume.pdf
|
||
playwright-cli upload e2 ./cover-letter.pdf
|
||
playwright-cli click e3 # 下一步
|
||
|
||
# 步骤 4:审核与提交
|
||
playwright-cli snapshot
|
||
playwright-cli screenshot --filename=review-page.png
|
||
playwright-cli check e1 # 接受条款
|
||
playwright-cli click e2 # 提交
|
||
playwright-cli snapshot # 确认页面
|
||
```
|
||
|
||
### 带条件字段的动态表单
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
await page.goto('https://example.com/registration');
|
||
|
||
// 选择账户类型——这会显示/隐藏字段
|
||
await page.selectOption('#account-type', 'business');
|
||
|
||
// 等待企业字段出现
|
||
await page.waitForSelector('#company-name', { state: 'visible' });
|
||
|
||
// 填写企业专属字段
|
||
await page.fill('#company-name', 'Acme Corp');
|
||
await page.fill('#tax-id', '12-3456789');
|
||
await page.fill('#company-size', '50-100');
|
||
|
||
// 填写通用字段
|
||
await page.fill('#contact-name', 'Jane Doe');
|
||
await page.fill('#contact-email', 'jane@acme.com');
|
||
|
||
await page.click('button:text(\"Register\")');
|
||
await page.waitForURL('**/welcome');
|
||
|
||
return 'Registration complete';
|
||
}"
|
||
```
|
||
|
||
## 数据提取模式
|
||
|
||
### 表格数据提取
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
await page.goto('https://example.com/reports');
|
||
|
||
const data = await page.evaluate(() => {
|
||
const table = document.querySelector('table.data-table');
|
||
const headers = Array.from(table.querySelectorAll('thead th'))
|
||
.map(th => th.textContent.trim());
|
||
const rows = Array.from(table.querySelectorAll('tbody tr'))
|
||
.map(tr => {
|
||
const cells = Array.from(tr.querySelectorAll('td'))
|
||
.map(td => td.textContent.trim());
|
||
return Object.fromEntries(headers.map((h, i) => [h, cells[i]]));
|
||
});
|
||
return { headers, rowCount: rows.length, rows };
|
||
});
|
||
|
||
return JSON.stringify(data, null, 2);
|
||
}"
|
||
```
|
||
|
||
### 提取结构化数据(JSON-LD、Meta 标签)
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
return await page.evaluate(() => {
|
||
// JSON-LD 结构化数据
|
||
const jsonLd = Array.from(document.querySelectorAll('script[type=\"application/ld+json\"]'))
|
||
.map(s => JSON.parse(s.textContent));
|
||
|
||
// Open Graph meta 标签
|
||
const og = {};
|
||
document.querySelectorAll('meta[property^=\"og:\"]').forEach(m => {
|
||
og[m.getAttribute('property')] = m.content;
|
||
});
|
||
|
||
// 标准 meta 标签
|
||
const meta = {};
|
||
document.querySelectorAll('meta[name]').forEach(m => {
|
||
meta[m.name] = m.content;
|
||
});
|
||
|
||
return { jsonLd, openGraph: og, meta };
|
||
});
|
||
}"
|
||
```
|
||
|
||
### 截图每个链接的目标页面
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
await page.goto('https://example.com');
|
||
const links = await page.locator('nav a').evaluateAll(
|
||
anchors => anchors.map(a => ({ text: a.textContent.trim(), href: a.href }))
|
||
);
|
||
|
||
for (const link of links) {
|
||
const safeName = link.text.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||
await page.goto(link.href);
|
||
await page.waitForLoadState('networkidle');
|
||
await page.screenshot({ path: \`screenshots/\${safeName}.png\` });
|
||
}
|
||
|
||
return links.map(l => l.text);
|
||
}"
|
||
```
|
||
|
||
## 错误恢复模式
|
||
|
||
### 失败时重试
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
const maxRetries = 3;
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
await page.goto('https://flaky-site.example.com', { timeout: 15000 });
|
||
await page.waitForSelector('.content', { timeout: 5000 });
|
||
return 'Success on attempt ' + attempt;
|
||
} catch (error) {
|
||
if (attempt === maxRetries) throw error;
|
||
console.log(\`Attempt \${attempt} failed, retrying...\`);
|
||
await page.waitForTimeout(2000 * attempt); // 指数退避
|
||
}
|
||
}
|
||
}"
|
||
```
|
||
|
||
### 自动关闭 Cookie 横幅
|
||
|
||
```bash
|
||
playwright-cli run-code "async page => {
|
||
// 尝试常见的 Cookie 同意选择器
|
||
const selectors = [
|
||
'button:text(\"Accept\")',
|
||
'button:text(\"Accept All\")',
|
||
'button:text(\"Accept Cookies\")',
|
||
'button:text(\"I Agree\")',
|
||
'#cookie-accept',
|
||
'.cookie-consent-accept',
|
||
'[data-testid=cookie-accept]'
|
||
];
|
||
|
||
for (const sel of selectors) {
|
||
try {
|
||
const btn = page.locator(sel).first();
|
||
if (await btn.isVisible({ timeout: 1000 })) {
|
||
await btn.click();
|
||
return 'Dismissed cookie banner with: ' + sel;
|
||
}
|
||
} catch (e) {
|
||
// 尝试下一个选择器
|
||
}
|
||
}
|
||
return 'No cookie banner found';
|
||
}"
|
||
```
|
||
|
||
### 处理意外弹框
|
||
|
||
```bash
|
||
# 设置自动关闭所有弹框
|
||
playwright-cli run-code "async page => {
|
||
page.on('dialog', async dialog => {
|
||
console.log(\`Auto-handled \${dialog.type()} dialog: \${dialog.message()}\`);
|
||
await dialog.dismiss();
|
||
});
|
||
}"
|
||
```
|
||
|
||
## 组合 CLI 功能
|
||
|
||
### 完整的端到端测试工作流
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
set -e
|
||
|
||
# 1. 启动新的会话
|
||
playwright-cli open https://app.example.com --persistent
|
||
|
||
# 2. 设置监控
|
||
playwright-cli run-code "async page => {
|
||
page.on('console', msg => {
|
||
if (msg.type() === 'error') console.log('[ERROR]', msg.text());
|
||
});
|
||
}"
|
||
|
||
# 3. 启动追踪
|
||
playwright-cli tracing-start
|
||
|
||
# 4. 登录
|
||
playwright-cli goto https://app.example.com/login
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "test@example.com"
|
||
playwright-cli fill e2 "password123"
|
||
playwright-cli click e3
|
||
playwright-cli state-save auth-state.json
|
||
|
||
# 5. 验证仪表盘
|
||
playwright-cli snapshot
|
||
playwright-cli screenshot --filename=dashboard.png
|
||
|
||
# 6. 测试某个功能
|
||
playwright-cli click e5 # 导航至功能页面
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "test data"
|
||
playwright-cli click e3 # 提交
|
||
playwright-cli screenshot --filename=feature-result.png
|
||
|
||
# 7. 停止追踪并清理
|
||
playwright-cli tracing-stop
|
||
playwright-cli close
|
||
|
||
echo "Test complete. Check screenshots/ and traces/"
|
||
```
|
||
|
||
### 对比测试脚本
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# 对比两个环境
|
||
|
||
STAGING="https://staging.example.com"
|
||
PROD="https://www.example.com"
|
||
PAGES=("/" "/pricing" "/about" "/features")
|
||
|
||
for path in "${PAGES[@]}"; do
|
||
safePath=$(echo "$path" | tr '/' '-' | sed 's/^-//')
|
||
[ -z "$safePath" ] && safePath="home"
|
||
|
||
playwright-cli -s=staging open "${STAGING}${path}"
|
||
playwright-cli -s=staging screenshot --filename="compare-staging-${safePath}.png"
|
||
|
||
playwright-cli -s=prod open "${PROD}${path}"
|
||
playwright-cli -s=prod screenshot --filename="compare-prod-${safePath}.png"
|
||
done
|
||
|
||
playwright-cli close-all
|
||
echo "Screenshots saved. Compare staging vs prod visually."
|
||
```
|
||
|
||
## 提示
|
||
|
||
- **从简到繁**——从基本的 CLI 命令开始,仅在必要时使用 `run-code`
|
||
- **保存状态检查点**——在关键节点执行 `state-save checkpoint.json`,以便出问题时可以恢复
|
||
- **使用命名会话实现隔离**——切勿在单个会话中混杂不同任务
|
||
- **监控控制台和网络**——尽早设置监听器,以便实时捕获错误
|
||
- **追踪复杂流程**——调试多步骤工作流时始终开启追踪
|
||
- **错误恢复至关重要**——网站不稳定,请在复杂脚本中构建重试逻辑
|
||
- **清理资源**——每个脚本末尾执行 `close-all`;出问题时执行 `kill-all`
|