mirror of
https://github.com/galacean/engine.git
synced 2026-07-01 02:44:22 +08:00
Migrate e2e testing from Cypress to Playwright (#2746)
* refactor: migrate e2e testing from Cypress to Playwright
This commit is contained in:
86
.github/workflows/ci.yml
vendored
86
.github/workflows/ci.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
@@ -79,11 +79,12 @@ jobs:
|
||||
flags: unittests
|
||||
|
||||
e2e:
|
||||
runs-on: macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
shard: [1/4, 2/4, 3/4, 4/4]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -95,29 +96,78 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Run Cypress Tests
|
||||
uses: cypress-io/github-action@v5
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get installed Playwright version
|
||||
id: playwright-version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
build: npm run build
|
||||
start: npm run e2e:case
|
||||
wait-on: "http://localhost:5175"
|
||||
wait-on-timeout: 120
|
||||
browser: chrome
|
||||
- name: Upload Diff
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
echo "🔧 Installing Playwright chromium browser..."
|
||||
npx playwright install chromium
|
||||
npx playwright install-deps chromium
|
||||
echo "✅ Playwright installation completed"
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
- name: Pull baseline images
|
||||
run: git lfs pull
|
||||
- name: Verify browser installation
|
||||
run: |
|
||||
echo "🔍 Verifying browser installation..."
|
||||
ls -la ~/.cache/ms-playwright/ || echo "Browser cache directory not found"
|
||||
find ~/.cache/ms-playwright -name "*headless_shell*" || echo "Headless shell not found"
|
||||
|
||||
- name: Run Playwright Tests
|
||||
run: |
|
||||
echo "🧪 Starting e2e visual regression tests..."
|
||||
echo "📊 Test environment: ${{ runner.os }} - Node ${{ matrix.node-version }}"
|
||||
start_time=$(date +%s)
|
||||
npx playwright test --shard=${{ matrix.shard }} --reporter=list,github
|
||||
exit_code=$?
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✅ E2E tests completed successfully in ${duration}s!"
|
||||
else
|
||||
echo "❌ E2E tests failed in ${duration}s with exit code $exit_code"
|
||||
exit $exit_code
|
||||
fi
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_FORCE_TTY: 1
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-diff
|
||||
path: e2e/diff/
|
||||
- name: Upload Origin
|
||||
name: playwright-test-results-${{ strategy.job-index }}-${{ github.run_number }}
|
||||
path: |
|
||||
e2e/test-results/
|
||||
!e2e/test-results/**/*.webm
|
||||
retention-days: 7
|
||||
- name: Upload diff images
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-origin
|
||||
path: e2e/fixtures/originImage
|
||||
- name: Upload Screenshots
|
||||
name: playwright-diffs-${{ strategy.job-index }}-${{ github.run_number }}
|
||||
path: e2e/test-results/diffs/
|
||||
retention-days: 14
|
||||
- name: Upload HTML report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-screenshots
|
||||
path: e2e/downloads/
|
||||
name: playwright-report-${{ strategy.job-index }}-${{ github.run_number }}
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -31,6 +31,13 @@ e2e/screenshots/*
|
||||
e2e/downloads/*
|
||||
e2e/diff/*
|
||||
e2e/.dev/mpa
|
||||
e2e/test-results/*
|
||||
playwright-report/
|
||||
|
||||
# Claude Code files
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
.husky/post-checkout
|
||||
.husky/post-commit
|
||||
.husky/pre-push
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import { compare } from "odiff-bin";
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
|
||||
const downloadDirectory = path.join(__dirname, "e2e/downloads");
|
||||
let isRunningInCommandLine = false;
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 800,
|
||||
baseUrl: "http://localhost:5175",
|
||||
defaultCommandTimeout: 60000,
|
||||
fileServerFolder: "e2e",
|
||||
supportFile: "e2e/support/e2e.ts",
|
||||
fixturesFolder: "e2e/fixtures",
|
||||
screenshotsFolder: "e2e/screenshots",
|
||||
videosFolder: "e2e/videos",
|
||||
specPattern: "e2e/tests/*.cy.ts",
|
||||
downloadsFolder: "e2e/downloads",
|
||||
video: false,
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
on("before:browser:launch", (browser, launchOptions) => {
|
||||
console.log("launching browser %s is headless? %s", browser.name, browser.isHeadless);
|
||||
// supply the absolute path to an unpacked extension's folder
|
||||
// NOTE: extensions cannot be loaded in headless Chrome
|
||||
if (fs.existsSync("e2e/diff")) {
|
||||
fs.rmdirSync("e2e/diff", { recursive: true });
|
||||
}
|
||||
if (browser.name === "chrome") {
|
||||
launchOptions.preferences.default["download"] = {
|
||||
default_directory: downloadDirectory
|
||||
};
|
||||
}
|
||||
if (browser.isHeadless) {
|
||||
isRunningInCommandLine = true;
|
||||
}
|
||||
launchOptions.args.push("--force-device-scale-factor=1");
|
||||
return launchOptions;
|
||||
}),
|
||||
on("task", {
|
||||
async compare({ fileName, options }) {
|
||||
const baseFolder = "e2e/fixtures/originImage/";
|
||||
const newFolder = path.join("e2e/downloads");
|
||||
const diffFolder = path.join("e2e/diff", options.specFolder);
|
||||
if (!fs.existsSync(diffFolder)) {
|
||||
fs.mkdirSync(diffFolder, { recursive: true });
|
||||
}
|
||||
const baseImage = path.join(baseFolder, fileName);
|
||||
const newImage = path.join(newFolder, fileName);
|
||||
const diffImage = path.join(diffFolder, fileName);
|
||||
console.log("comparing base image %s to the new image %s", baseImage, newImage);
|
||||
if (options) {
|
||||
console.log("odiff options %o", options);
|
||||
}
|
||||
const started = +new Date();
|
||||
|
||||
const result = await compare(baseImage, newImage, diffImage, options);
|
||||
const finished = +new Date();
|
||||
const elapsed = finished - started;
|
||||
console.log("odiff took %dms", elapsed);
|
||||
|
||||
//@ts-ignore
|
||||
if (result.match === false && result.diffPercentage <= 0.1) {
|
||||
//@ts-ignore
|
||||
result.match = true;
|
||||
}
|
||||
|
||||
console.log(result);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
chromeWebSecurity: false
|
||||
});
|
||||
@@ -45,7 +45,8 @@ module.exports = {
|
||||
server: {
|
||||
open: true,
|
||||
host: "0.0.0.0",
|
||||
port: 5175
|
||||
port: 5175,
|
||||
strictPort: true
|
||||
},
|
||||
resolve: {
|
||||
dedupe: ["@galacean/engine"]
|
||||
|
||||
120
e2e/README.md
120
e2e/README.md
@@ -1,43 +1,105 @@
|
||||
### Note: Require install git-lfs
|
||||
We use [git-lfs](https://git-lfs.com/) (Install by official website) to manage baseline images for e2e tests, so it's necessary to install it, ignore if already installed.
|
||||
### 1. Create a case page in the e2e/case directory
|
||||
You can refer to e2e/case/animator-play.ts.
|
||||
### 2. Configure your e2e test in e2e/config.ts
|
||||
The threshold is color difference threshold (from 0 to 1). Less more precise.
|
||||
### 3. Debug your test cases:
|
||||
#### Launch the Case page:
|
||||
# E2E Testing Guide
|
||||
|
||||
```
|
||||
npm run e2e:case
|
||||
```
|
||||
## Prerequisites
|
||||
|
||||
After successfully launching the case page, run:
|
||||
|
||||
```
|
||||
### Git LFS
|
||||
We use [git-lfs](https://git-lfs.com/) to manage baseline images for e2e tests. Install it if you haven't already:
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs pull
|
||||
```
|
||||
Pull image from github, then run
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Run all e2e tests
|
||||
```bash
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
### Debug tests interactively
|
||||
```bash
|
||||
npm run e2e:debug
|
||||
```
|
||||
|
||||
Open the Cypress client for debugging.
|
||||
Cypress will capture screenshots of your case pages.
|
||||
Review the screenshots in e2e/downloads folder, store them in the e2e/fixtures/originImage directory if there are no issues, then rerun the test cases. If the test cases pass, the debugging is complete.
|
||||
Both commands will automatically:
|
||||
- Install required browsers (Chromium)
|
||||
- Start the test server
|
||||
- Run visual regression tests with odiff comparison
|
||||
|
||||
## Project Structure
|
||||
|
||||
### 4. Run the complete e2e tests:
|
||||
```
|
||||
npm run e2e
|
||||
e2e/
|
||||
├── case/ # Test case implementations
|
||||
├── config.ts # Test configuration
|
||||
├── fixtures/
|
||||
│ └── originImage/ # Baseline images (managed by git-lfs)
|
||||
├── downloads/ # Generated screenshots
|
||||
├── tests/ # Playwright test files
|
||||
└── utils/ # Helper utilities
|
||||
```
|
||||
Note: The e2e testing framework for this project is Cypress. For detailed usage instructions, please refer to https://www.cypress.io/.
|
||||
|
||||
|
||||
### Add new e2e case
|
||||
|
||||
1. modify `config.ts` based on the new test case.
|
||||
2. run `npm run e2e:debug`
|
||||
|
||||
the new image of test case for comparison will be present under directory `e2e/downloads`, you need to copy it into directory `e2e/fixtures/originImage`.
|
||||
|
||||
## Adding New Test Cases
|
||||
|
||||
### 1. Create a test case file
|
||||
Create your test implementation in `e2e/case/`, following existing patterns:
|
||||
```typescript
|
||||
// e2e/case/my-new-test.ts
|
||||
WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
|
||||
// Your test implementation
|
||||
updateForE2E(engine);
|
||||
initScreenshot(engine, camera);
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Add configuration
|
||||
Add your test to `e2e/config.ts`:
|
||||
```typescript
|
||||
MyCategory: {
|
||||
myNewTest: {
|
||||
category: "MyCategory",
|
||||
caseFileName: "my-new-test",
|
||||
threshold: 0.1 // 0.01 for strict tests, 0.1 for normal tests
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Generate baseline image
|
||||
Run in debug mode to generate the initial screenshot:
|
||||
```bash
|
||||
npm run e2e:debug
|
||||
```
|
||||
|
||||
Copy the generated image from `e2e/downloads/` to `e2e/fixtures/originImage/` and commit it with git-lfs.
|
||||
|
||||
## Threshold Guidelines
|
||||
|
||||
- **0.01**: Strict comparison for pixel-perfect tests (e.g., FXAA, transparency)
|
||||
- **0.1**: Normal comparison for most 3D rendering tests
|
||||
- Adjust based on rendering stability and requirements
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser installation issues
|
||||
Manually install browsers:
|
||||
```bash
|
||||
npm run e2e:install
|
||||
```
|
||||
|
||||
### Missing baseline images
|
||||
Pull from git-lfs:
|
||||
```bash
|
||||
git lfs pull
|
||||
```
|
||||
|
||||
### Server startup issues
|
||||
Manually start the test server:
|
||||
```bash
|
||||
npm run e2e:case
|
||||
```
|
||||
|
||||
## Framework Details
|
||||
|
||||
This project uses [Playwright](https://playwright.dev/) with [odiff](https://github.com/dmtrKovalenko/odiff) for visual regression testing. All tests run in Chromium for consistency.
|
||||
|
||||
|
||||
|
||||
@@ -92,17 +92,25 @@ export function initScreenshot(
|
||||
const imageName = `${category}_${caseFileName}.jpg`;
|
||||
a.href = url;
|
||||
a.download = imageName;
|
||||
a.id = "screenshot";
|
||||
a.dataset.testid = "screenshot";
|
||||
a.textContent = "Download Screenshot";
|
||||
a.style.cssText = `
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 9999;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
document.body.appendChild(a);
|
||||
|
||||
a.addEventListener("click", () => {
|
||||
if (a.parentElement) {
|
||||
a.parentElement.removeChild(a);
|
||||
}
|
||||
});
|
||||
|
||||
// window.URL.revokeObjectURL(url);
|
||||
|
||||
// revert
|
||||
callbacks.forEach((cb) => cb());
|
||||
!isPaused && engine.resume();
|
||||
|
||||
@@ -237,7 +237,7 @@ export const E2E_CONFIG = {
|
||||
caseFileName: "particleRenderer-dream",
|
||||
threshold: 0.1
|
||||
},
|
||||
particleFire: {
|
||||
particleFire: {
|
||||
category: "Particle",
|
||||
caseFileName: "particleRenderer-fire",
|
||||
threshold: 0.1
|
||||
|
||||
67
e2e/global-setup.ts
Normal file
67
e2e/global-setup.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as fs from "fs-extra";
|
||||
import * as path from "path";
|
||||
|
||||
// Wait for server to be ready
|
||||
async function waitForServer(url: string, timeout: number = 120000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
console.log(`⏳ Waiting for server at ${url}...`);
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
console.log(`✅ Server is ready at ${url}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Server not ready yet, continue waiting
|
||||
}
|
||||
|
||||
// Wait 1 second before next attempt
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`❌ Server at ${url} did not start within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
console.log("🚀 Galacean Engine E2E Test Setup");
|
||||
console.log("📁 Cleaning downloads directory...");
|
||||
|
||||
// Clean downloads directory before tests
|
||||
const downloadsPath = path.join(process.cwd(), "e2e/downloads");
|
||||
if (fs.existsSync(downloadsPath)) {
|
||||
const files = fs.readdirSync(downloadsPath);
|
||||
fs.emptyDirSync(downloadsPath);
|
||||
console.log(` ✅ Cleaned ${files.length} files from e2e/downloads`);
|
||||
} else {
|
||||
console.log(" ℹ️ Downloads directory is empty");
|
||||
}
|
||||
|
||||
// Count test cases from config
|
||||
let testCount = 0;
|
||||
try {
|
||||
const configPath = path.join(process.cwd(), "e2e/config.ts");
|
||||
if (fs.existsSync(configPath)) {
|
||||
const { E2E_CONFIG } = require(configPath);
|
||||
testCount = Object.values(E2E_CONFIG).reduce((total: number, category: any) => {
|
||||
return total + Object.keys(category).length;
|
||||
}, 0) as number;
|
||||
console.log(`🧪 Found ${testCount} test cases`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("⚠️ Could not read test configuration");
|
||||
}
|
||||
|
||||
// Check baseline images
|
||||
const baselineDir = path.join(process.cwd(), "e2e/fixtures/originImage");
|
||||
if (fs.existsSync(baselineDir)) {
|
||||
const baselineFiles = fs.readdirSync(baselineDir).filter((f) => f.endsWith(".jpg"));
|
||||
console.log(`📸 Found ${baselineFiles.length} baseline images`);
|
||||
}
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer("http://localhost:5175");
|
||||
|
||||
console.log("🎬 Ready to run visual regression tests!\n");
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { recurse } from "cypress-recurse";
|
||||
import * as path from "path";
|
||||
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
screenshotWithThreshold(category: string, name: string, threshold?: number): Chainable<Element>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add("screenshotWithThreshold", (category, name, threshold = 0) => {
|
||||
const downloadsFolder = Cypress.config("downloadsFolder");
|
||||
|
||||
cy.visit(`/mpa/${name}.html?category=${category}&case=${name}`);
|
||||
const imageName = `${category}_${name}.jpg`;
|
||||
const filePath = path.join(downloadsFolder, imageName);
|
||||
cy.get("#screenshot", { timeout: 60000 })
|
||||
.click({ force: true })
|
||||
.then(() => {
|
||||
return new Promise((resolve) => {
|
||||
cy.log(`Reading file ${filePath}`);
|
||||
resolve(
|
||||
recurse(
|
||||
() => {
|
||||
return cy.readFile(filePath).then(() => {
|
||||
cy.log(`Comparing ${imageName} with threshold ${threshold}`);
|
||||
return cy.task("compare", {
|
||||
fileName: imageName,
|
||||
options: {
|
||||
specFolder: Cypress.spec.name,
|
||||
threshold,
|
||||
antialiasing: true
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
({ match }) => match,
|
||||
{
|
||||
limit: 2
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/e2e.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import "./commands";
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
@@ -1,14 +0,0 @@
|
||||
import { E2E_CONFIG } from "../config";
|
||||
|
||||
for (let category in E2E_CONFIG) {
|
||||
const config = E2E_CONFIG[category];
|
||||
|
||||
describe(category, () => {
|
||||
for (const caseName in config) {
|
||||
it(caseName, () => {
|
||||
const { category, caseFileName, threshold } = config[caseName];
|
||||
cy.screenshotWithThreshold(category, caseFileName, threshold);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
27
e2e/tests/index.spec.ts
Normal file
27
e2e/tests/index.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test } from "@playwright/test";
|
||||
import { E2E_CONFIG } from "../config";
|
||||
import { screenshotWithThreshold } from "../utils/screenshot";
|
||||
import type { CategoryConfig, TestCaseConfig } from "../types/test-config";
|
||||
|
||||
/**
|
||||
* Create test cases for a given category
|
||||
*/
|
||||
function createTestsForCategory(categoryName: string, categoryConfig: CategoryConfig) {
|
||||
test.describe(categoryName, () => {
|
||||
Object.entries(categoryConfig).forEach(([caseName, config]: [string, TestCaseConfig]) => {
|
||||
test(caseName, async ({ page }) => {
|
||||
const { category, caseFileName, threshold } = config;
|
||||
await screenshotWithThreshold(page, {
|
||||
category,
|
||||
name: caseFileName,
|
||||
threshold
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Generate test suites for all categories
|
||||
Object.entries(E2E_CONFIG).forEach(([categoryName, categoryConfig]) => {
|
||||
createTestsForCategory(categoryName, categoryConfig);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"]
|
||||
"types": ["@playwright/test", "node"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
||||
85
e2e/utils/screenshot.ts
Normal file
85
e2e/utils/screenshot.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { compare } from "odiff-bin";
|
||||
import * as path from "path";
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
category: string;
|
||||
name: string;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export async function screenshotWithThreshold(page: Page, options: ScreenshotOptions) {
|
||||
const { category, name, threshold = 0.1 } = options;
|
||||
const imageName = `${category}_${name}.jpg`;
|
||||
const testId = `${category}_${name}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`📸 [${testId}] Starting test`);
|
||||
const testUrl = `/mpa/${name}.html`;
|
||||
|
||||
console.log(`🌐 [${testId}] Navigating to ${testUrl}`);
|
||||
await page.goto(testUrl);
|
||||
|
||||
console.log(`⏳ [${testId}] Waiting for DOM content loaded...`);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
const pageReadyTime = Date.now();
|
||||
console.log(`📄 [${testId}] Page ready (${pageReadyTime - startTime}ms)`);
|
||||
|
||||
console.log(`🔍 [${testId}] Looking for screenshot button...`);
|
||||
// 监听下载事件
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
console.log(`📡 [${testId}] Download listener set up`);
|
||||
|
||||
console.log(`⏰ [${testId}] Waiting for screenshot button to be visible (timeout: 180s)...`);
|
||||
let pageRenderedTime;
|
||||
try {
|
||||
// 等待 screenshot 按钮可见
|
||||
await page.getByTestId("screenshot").waitFor({ timeout: 180000 });
|
||||
pageRenderedTime = Date.now();
|
||||
console.log(`✅ [${testId}] Screenshot button visible (${pageRenderedTime - pageReadyTime}ms)`);
|
||||
} catch (error) {
|
||||
console.log(`❌ [${testId}] Screenshot button not visible after 180s`);
|
||||
console.log(`🔍 [${testId}] Page content: ${await page.content()}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`👆 [${testId}] Clicking screenshot button...`);
|
||||
// 点击下载按钮
|
||||
await page.getByTestId("screenshot").click();
|
||||
console.log(`✅ [${testId}] Screenshot button clicked`);
|
||||
|
||||
console.log(`⬇️ [${testId}] Waiting for download to start...`);
|
||||
// 等待下载完成
|
||||
const download = await downloadPromise;
|
||||
console.log(`📦 [${testId}] Download received`);
|
||||
|
||||
console.log(`💾 [${testId}] Saving download...`);
|
||||
const downloadPath = path.join(process.cwd(), "e2e/downloads", imageName);
|
||||
|
||||
// 保存下载的文件
|
||||
await download.saveAs(downloadPath);
|
||||
|
||||
console.log(`📥 [${testId}] Downloaded (${Date.now() - pageRenderedTime}ms)`);
|
||||
|
||||
// Compare with baseline
|
||||
const baseImagePath = path.join(process.cwd(), "e2e/fixtures/originImage", imageName);
|
||||
const diffImagePath = path.join(process.cwd(), "e2e/test-results/diffs", imageName);
|
||||
|
||||
const result = await compare(baseImagePath, downloadPath, diffImagePath, {
|
||||
threshold: threshold * 100,
|
||||
antialiasing: true
|
||||
});
|
||||
|
||||
if (!result.match) {
|
||||
const diffPercentage = "diffPercentage" in result ? result.diffPercentage : "unknown";
|
||||
console.log(`❌ [${testId}] Visual regression: ${diffPercentage}% (${Date.now() - startTime}ms)`);
|
||||
throw new Error(
|
||||
`Visual regression detected for ${imageName}. ` +
|
||||
`Difference: ${diffPercentage}%, threshold: ${threshold * 100}%. ` +
|
||||
`Diff saved to: ${diffImagePath}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ [${testId}] Test passed (${Date.now() - startTime}ms total)`);
|
||||
return result;
|
||||
}
|
||||
14
package.json
14
package.json
@@ -5,9 +5,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"pretest": "pnpm exec playwright install",
|
||||
"pretest": "vitest --version && playwright install --with-deps chromium",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest --coverage",
|
||||
"coverage": "cross-env HEADLESS=true vitest --coverage",
|
||||
"build": "npm run b:module && npm run b:types",
|
||||
"lint": "eslint packages/*/src --ext .ts",
|
||||
"watch": "cross-env NODE_ENV=release BUILD_TYPE=MODULE rollup -cw -m inline",
|
||||
@@ -19,14 +19,17 @@
|
||||
"b:all": "cross-env NODE_ENV=release npm run b:types && cross-env BUILD_TYPE=ALL NODE_ENV=release rollup -c",
|
||||
"clean": "pnpm -r exec rm -rf dist && pnpm -r exec rm -rf types",
|
||||
"e2e:case": "pnpm -C ./e2e run case",
|
||||
"e2e": "cypress run --browser chrome --headless",
|
||||
"e2e:debug": "cypress open",
|
||||
"pree2e": "playwright install --with-deps chromium",
|
||||
"e2e": "playwright test",
|
||||
"pree2e:debug": "playwright install --with-deps chromium",
|
||||
"e2e:debug": "playwright test --ui",
|
||||
"prepare": "husky install",
|
||||
"release": "bumpp -r"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^11.0.0",
|
||||
"@commitlint/config-conventional": "^11.0.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-inject": "^4.0.2",
|
||||
"@rollup/plugin-node-resolve": "^11.0.1",
|
||||
@@ -41,8 +44,6 @@
|
||||
"@vitest/coverage-v8": "2.1.3",
|
||||
"bumpp": "^9.5.2",
|
||||
"cross-env": "^5.2.0",
|
||||
"cypress": "^12.17.1",
|
||||
"cypress-recurse": "^1.23.0",
|
||||
"electron": "^13",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
@@ -52,7 +53,6 @@
|
||||
"lint-staged": "^10.5.3",
|
||||
"nyc": "^15.1.0",
|
||||
"odiff-bin": "^2.5.0",
|
||||
"playwright": "^1.48.1",
|
||||
"prettier": "^3.0.0",
|
||||
"rollup": "^2.36.1",
|
||||
"rollup-plugin-glslify": "^1.2.0",
|
||||
|
||||
53
playwright.config.ts
Normal file
53
playwright.config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
testDir: "./e2e/tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
reporter: process.env.CI ? [["github"], ["list"], ["html", { open: "never" }]] : "html",
|
||||
timeout: process.env.CI ? 180000 : 120000,
|
||||
use: {
|
||||
baseURL: "http://localhost:5175",
|
||||
trace: process.env.CI ? "on-first-retry" : "on-first-retry",
|
||||
video: process.env.CI ? "off" : "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: process.env.CI ? 180000 : 30000,
|
||||
navigationTimeout: process.env.CI ? 180000 : 60000
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1200, height: 800 },
|
||||
launchOptions: {
|
||||
args: process.env.CI
|
||||
? [
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-webgl",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--disable-gpu-sandbox",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-web-security",
|
||||
"--disable-features=VizDisplayCompositor"
|
||||
]
|
||||
: ["--use-gl=egl", "--ignore-gpu-blocklist", "--use-gl=angle"]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run e2e:case",
|
||||
timeout: 120 * 1000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe"
|
||||
},
|
||||
outputDir: "e2e/test-results"
|
||||
});
|
||||
749
pnpm-lock.yaml
generated
749
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -69,36 +69,37 @@ describe("webgl engine test", () => {
|
||||
});
|
||||
|
||||
it("engine device lost", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const engine = await WebGLEngine.create({ canvas });
|
||||
const scene = engine.sceneManager.activeScene;
|
||||
const rootEntity = scene.createRootEntity();
|
||||
|
||||
// init camera
|
||||
const cameraEntity = rootEntity.createChild("camera");
|
||||
const camera = cameraEntity.addComponent(Camera);
|
||||
|
||||
const engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
|
||||
engine.sceneManager.activeScene.createRootEntity().createChild("camera").addComponent(Camera);
|
||||
engine.run();
|
||||
|
||||
const opLost = vi.fn(() => {
|
||||
const onLost = vi.fn(() => {
|
||||
console.log("On device lost.");
|
||||
});
|
||||
const onRestored = vi.fn(() => {
|
||||
console.log("On device restored.");
|
||||
});
|
||||
|
||||
engine.on("devicelost", opLost);
|
||||
engine.on("devicelost", onLost);
|
||||
engine.on("devicerestored", onRestored);
|
||||
|
||||
engine.forceLoseDevice();
|
||||
setTimeout(() => {
|
||||
expect(opLost).toHaveBeenCalledTimes(1);
|
||||
}, 100);
|
||||
const originalOnError = window.onerror;
|
||||
let error: Error | null = null;
|
||||
window.onerror = (msg, src, line, col, err) => (error = err || new Error(String(msg)));
|
||||
|
||||
try {
|
||||
engine.forceLoseDevice();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(onLost).toHaveBeenCalledTimes(1);
|
||||
|
||||
setTimeout(() => {
|
||||
engine.forceRestoreDevice();
|
||||
}, 1000);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(onRestored).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (error) throw error;
|
||||
} finally {
|
||||
window.onerror = originalOnError;
|
||||
engine.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
// npx cross-env TS_NODE_PROJECT=tsconfig.tests.json nyc --reporter=lcov floss -p tests/src/*.test.ts -r ts-node/register
|
||||
// npx cross-env TS_NODE_PROJECT=tsconfig.tests.json nyc --reporter=lcov floss --path tests -r ts-node/register
|
||||
|
||||
@@ -20,7 +20,10 @@ export default defineProject({
|
||||
name: "chromium",
|
||||
providerOptions: {
|
||||
launch: {
|
||||
args: ["--use-gl=egl", "--ignore-gpu-blocklist", "--use-gl=angle"]
|
||||
args:
|
||||
process.env.HEADLESS === "true"
|
||||
? ["--use-gl=egl", "--ignore-gpu-blocklist", "--use-gl=angle", "--headless"]
|
||||
: ["--use-gl=egl", "--ignore-gpu-blocklist", "--use-gl=angle"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user