Loading...
Loading...
Compare original and translation side by side
Schema authority: when verifying that data renders correctly, the source-of-truth field names come from. E.g. asserting_shared/domain-primitives.mdmatches §11's Coverage Floor.unit.illustrations.length >= 1Reference implementation:for production verify-script patterns (multi-viewport, console-error capture, screenshot-on-fail).d:/GitHub/ai-workshop/scripts/verify-*.mjs
Schema权威来源:验证数据渲染是否正确时,基准字段名称来自。例如,断言_shared/domain-primitives.md符合第11节的覆盖要求。unit.illustrations.length >= 1参考实现:生产环境验证脚本的模式可参考(多视口、控制台错误捕获、失败时截图)。d:/GitHub/ai-workshop/scripts/verify-*.mjs
web-content-auditweb-content-audit| Role | Prefix | Has assertions? | Outputs | When to use |
|---|---|---|---|---|
| Verify | | Yes ( | Pass/fail + screenshots | Default — runtime regression check |
| Capture | | No | Screenshots only | Need visual evidence without judgement (design review, change log) |
| Diagnose | | No (but logs heavily) | Console dump + DOM tree + screenshots | When a verify failed and you need to figure out why |
| Probe | | Targeted, often ad-hoc | Console output | Quick CLI: "does this string appear?", "where is element X?" |
| 角色 | 前缀 | 是否包含断言? | 输出内容 | 使用场景 |
|---|---|---|---|---|
| 验证 | | 是( | 成功/失败结果 + 截图 | 默认场景——运行时回归检查 |
| 捕获 | | 否 | 仅截图 | 需要视觉证据但无需判断时(设计评审、变更日志) |
| 诊断 | | 否(但会大量日志) | 控制台输出 + DOM树 + 截图 | 验证脚本失败后,需要排查原因时 |
| 探测 | | 针对性强,通常为临时脚本 | 控制台输出 | 快速CLI工具:「这个字符串是否存在?」、「元素X在哪里?」 |
// scripts/verify-mobile.mjs
import { chromium, devices } from 'playwright';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir } from 'node:fs/promises';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const SCREENSHOT_DIR = resolve(ROOT, 'data', 'mobile-verify');
const URL = process.env.URL || 'http://localhost:3000/';
const PROFILES = [
{ id: 'iphone-se', device: devices['iPhone SE'] },
{ id: 'iphone-13', device: devices['iPhone 13'] },
{ id: 'ipad-mini', device: devices['iPad Mini'] },
{ id: 'desktop', device: { viewport: { width: 1280, height: 900 }, userAgent: 'Mozilla/5.0' } },
];
async function verifyProfile(browser, profile) {
const ctx = await browser.newContext({ ...profile.device, locale: 'zh-TW' });
const page = await ctx.newPage();
// === CRITICAL: collect runtime errors ===
const errors = [];
page.on('pageerror', e => errors.push(`[pageerror] ${e.message}`));
page.on('console', m => { if (m.type() === 'error') errors.push(`[console.error] ${m.text()}`); });
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('section.chapter', { timeout: 10000 });
// === Per-profile assertions go here ===
const isMobile = (profile.device.viewport?.width ?? 1280) <= 768;
// Example: assert horizontal overflow doesn't exist
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
assert.equal(overflow, false, `${profile.id}: 水平溢出`);
// Screenshot at key states
await mkdir(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${profile.id}-home.png`), fullPage: false });
// === Fail if any console errors collected ===
assert.equal(errors.length, 0, `${profile.id} console errors:\n${errors.join('\n')}`);
await ctx.close();
}
const browser = await chromium.launch({ headless: true });
try {
for (const p of PROFILES) await verifyProfile(browser, p);
console.log('✅ All profiles passed');
} finally { await browser.close(); }// scripts/verify-mobile.mjs
import { chromium, devices } from 'playwright';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir } from 'node:fs/promises';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const SCREENSHOT_DIR = resolve(ROOT, 'data', 'mobile-verify');
const URL = process.env.URL || 'http://localhost:3000/';
const PROFILES = [
{ id: 'iphone-se', device: devices['iPhone SE'] },
{ id: 'iphone-13', device: devices['iPhone 13'] },
{ id: 'ipad-mini', device: devices['iPad Mini'] },
{ id: 'desktop', device: { viewport: { width: 1280, height: 900 }, userAgent: 'Mozilla/5.0' } },
];
async function verifyProfile(browser, profile) {
const ctx = await browser.newContext({ ...profile.device, locale: 'zh-TW' });
const page = await ctx.newPage();
// === 关键:收集运行时错误 ===
const errors = [];
page.on('pageerror', e => errors.push(`[pageerror] ${e.message}`));
page.on('console', m => { if (m.type() === 'error') errors.push(`[console.error] ${m.text()}`); });
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('section.chapter', { timeout: 10000 });
// === 此处添加针对不同设备的断言 ===
const isMobile = (profile.device.viewport?.width ?? 1280) <= 768;
// 示例:断言不存在水平溢出
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
assert.equal(overflow, false, `${profile.id}: 水平溢出`);
// 在关键状态下截图
await mkdir(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${profile.id}-home.png`), fullPage: false });
// === 如果收集到控制台错误则失败 ===
assert.equal(errors.length, 0, `${profile.id} console errors:\
${errors.join('\
')}`);
await ctx.close();
}
const browser = await chromium.launch({ headless: true });
try {
for (const p of PROFILES) await verifyProfile(browser, p);
console.log('✅ 所有设备验证通过');
} finally { await browser.close(); }const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
assert.ok(overflow.scrollWidth <= overflow.innerWidth, `水平溢出 ${overflow.scrollWidth} > ${overflow.innerWidth}`);const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
assert.ok(overflow.scrollWidth <= overflow.innerWidth, `水平溢出 ${overflow.scrollWidth} > ${overflow.innerWidth}`);overflow-x: hiddenoverflow-x: hiddenawait page.evaluate(() => window.scrollTo(0, 1500));
const y = await page.evaluate(() => window.scrollY);
assert.ok(y > 0, 'window 無法捲動(檢查 ancestor 是否有 overflow:hidden)');await page.evaluate(() => window.scrollTo(0, 1500));
const y = await page.evaluate(() => window.scrollY);
assert.ok(y > 0, 'window 無法捲動(檢查 ancestor 是否有 overflow:hidden)');if (isMobile) {
// Default: sidebar hidden
const before = await page.locator('.sidebar').boundingBox();
assert.ok(!before || before.x < 0, 'sidebar should be off-screen by default on mobile');
// Open via hamburger
await page.locator('.menu-toggle').click();
await page.waitForTimeout(400); // animation
const after = await page.locator('.sidebar').boundingBox();
assert.ok(after && after.x >= 0, 'sidebar should slide in after toggle');
// Backdrop click closes
await page.locator('.sidebar-backdrop').click();
await page.waitForTimeout(400);
}if (isMobile) {
// 默认状态:侧边栏隐藏
const before = await page.locator('.sidebar').boundingBox();
assert.ok(!before || before.x < 0, 'sidebar should be off-screen by default on mobile');
// 点击汉堡菜单打开
await page.locator('.menu-toggle').click();
await page.waitForTimeout(400); // 动画等待
const after = await page.locator('.sidebar').boundingBox();
assert.ok(after && after.x >= 0, 'sidebar should slide in after toggle');
// 点击背景遮罩关闭
await page.locator('.sidebar-backdrop').click();
await page.waitForTimeout(400);
}await page.locator('input[type=checkbox]').first().check();
await page.reload();
await page.waitForSelector('input[type=checkbox]');
const checked = await page.locator('input[type=checkbox]').first().isChecked();
assert.equal(checked, true, '勾選未持久化(檢查 localStorage key 與 file:// 限制)');await page.locator('input[type=checkbox]').first().check();
await page.reload();
await page.waitForSelector('input[type=checkbox]');
const checked = await page.locator('input[type=checkbox]').first().isChecked();
assert.equal(checked, true, '勾選未持久化(檢查 localStorage key 與 file:// 限制)');.content.contentconst sidebarRect = await page.locator('.sidebar').boundingBox();
// Trigger zoom in
await page.evaluate(() => document.documentElement.style.setProperty('--content-zoom', '1.35'));
await page.waitForTimeout(100);
const sidebarRectAfter = await page.locator('.sidebar').boundingBox();
assert.equal(sidebarRect.width, sidebarRectAfter.width, 'sidebar should not be affected by zoom');const sidebarRect = await page.locator('.sidebar').boundingBox();
// 触发放大
await page.evaluate(() => document.documentElement.style.setProperty('--content-zoom', '1.35'));
await page.waitForTimeout(100);
const sidebarRectAfter = await page.locator('.sidebar').boundingBox();
assert.equal(sidebarRect.width, sidebarRectAfter.width, 'sidebar should not be affected by zoom'); | When to use |
|---|---|
| DOM exists but JS may still be running. Fast. Use when verifying static content or when you |
| All XHR/fetch quiet for 500ms. Use when site depends on data loaded post-DOMContentLoaded (e.g. |
| Only the document's |
'networkidle''domcontentloaded'waitForSelector | 使用场景 |
|---|---|
| DOM已存在但JS可能仍在运行。速度快。验证静态内容或后续会使用 |
| 所有XHR/fetch请求静默500ms后。当网站依赖DOMContentLoaded之后加载的数据时使用(例如注入的 |
| 仅等待文档的 |
'networkidle''domcontentloaded'waitForSelectordata/
├── mobile-verify/
│ ├── iphone-se-home.png
│ ├── iphone-se-sidebar-open.png
│ ├── iphone-13-home.png
│ └── desktop-home.png
├── toolbox-verify/
│ └── ...
└── design-audit/ ← capture-*.mjs goes here (no assertions, just evidence){profile-id}-{state}.pngdata/data/
├── mobile-verify/
│ ├── iphone-se-home.png
│ ├── iphone-se-sidebar-open.png
│ ├── iphone-13-home.png
│ └── desktop-home.png
├── toolbox-verify/
│ └── ...
└── design-audit/ ← capture-*.mjs 的截图存放于此(无断言,仅作为证据){设备标识}-{状态}.pngdata/// scripts/diagnose-day2-visibility.mjs
const html = await page.evaluate(() => {
const day2 = document.querySelector('#day2');
if (!day2) return 'NOT FOUND';
return {
boundingBox: day2.getBoundingClientRect().toJSON(),
computedStyle: {
display: getComputedStyle(day2).display,
visibility: getComputedStyle(day2).visibility,
opacity: getComputedStyle(day2).opacity,
transform: getComputedStyle(day2).transform,
},
innerHTML: day2.innerHTML.slice(0, 500),
childCount: day2.children.length,
};
});
console.log(JSON.stringify(html, null, 2));display: nonetransform: translateY(20px)// scripts/diagnose-day2-visibility.mjs
const html = await page.evaluate(() => {
const day2 = document.querySelector('#day2');
if (!day2) return 'NOT FOUND';
return {
boundingBox: day2.getBoundingClientRect().toJSON(),
computedStyle: {
display: getComputedStyle(day2).display,
visibility: getComputedStyle(day2).visibility,
opacity: getComputedStyle(day2).opacity,
transform: getComputedStyle(day2).transform,
},
innerHTML: day2.innerHTML.slice(0, 500),
childCount: day2.children.length,
};
});
console.log(JSON.stringify(html, null, 2));display: nonetransform: translateY(20px)// scripts/check-text-absence.mjs "招生人數" "100 人"
const phrases = process.argv.slice(2);
const browser = await chromium.launch({ headless: true });
const page = await (await browser.newContext({ locale: 'zh-TW' })).newPage();
await page.goto(process.env.URL || 'http://localhost:3000/');
const body = await page.evaluate(() => document.body.innerText);
const found = phrases.filter(p => body.includes(p));
console.log(found.length ? `❌ Found: ${found.join(', ')}` : '✅ All absent');
await browser.close();
process.exit(found.length ? 1 : 0);// scripts/check-text-absence.mjs "招生人數" "100 人"
const phrases = process.argv.slice(2);
const browser = await chromium.launch({ headless: true });
const page = await (await browser.newContext({ locale: 'zh-TW' })).newPage();
await page.goto(process.env.URL || 'http://localhost:3000/');
const body = await page.evaluate(() => document.body.innerText);
const found = phrases.filter(p => body.includes(p));
console.log(found.length ? `❌ Found: ${found.join(', ')}` : '✅ All absent');
await browser.close();
process.exit(found.length ? 1 : 0);assert.*console.log("ok")"verify:all": "verify-mobile && verify-toolbox && ..."pageerrorconsole.errorlocalhost:3000process.env.URLpage.waitForTimeout(N)waitForSelectorwaitForFunctionassert.*console.log("ok")"verify:all": "verify-mobile && verify-toolbox && ..."pageerrorconsole.errorlocalhost:3000process.env.URLpage.waitForTimeout(N)waitForSelectorwaitForFunction{
"scripts": {
"verify": "node scripts/verify-mobile.mjs && node scripts/verify-toolbox.mjs && node scripts/verify-instructor-card.mjs",
"capture": "node scripts/capture-overview.mjs && node scripts/capture-all-zones.mjs"
}
}verifycapture{
"scripts": {
"verify": "node scripts/verify-mobile.mjs && node scripts/verify-toolbox.mjs && node scripts/verify-instructor-card.mjs",
"capture": "node scripts/capture-overview.mjs && node scripts/capture-all-zones.mjs"
}
}verifycapturescripts/npm run verifydata/{domain}-verify/console.logscripts/npm run verifydata/{领域}-verify/console.log