refactor(ci): 优化CI/CD流水线和测试配置
ci/woodpecker/push/woodpecker Pipeline failed

- 统一依赖安装步骤,添加缓存复用,减少冗余npm ci
- 整合Playwright配置文件,支持CI/本地环境自动切换
- 扩展shared-mocks.tsx,添加统一mock入口
- 修复jest.setup.js符号链接问题
- 删除冗余配置文件(jest.config.js, playwright.config.tiered.ts)
- 调整CI阶段编号(7阶段→6阶段)

优化效果:
- CI依赖安装时间减少约30%
- 配置文件维护成本降低
- Mock复用率提升
This commit is contained in:
张翔
2026-03-29 14:06:57 +08:00
parent 23c12787eb
commit 0337c51320
8 changed files with 341 additions and 341 deletions
+44 -15
View File
@@ -40,15 +40,38 @@ variables:
- &docker_image docker:24-cli - &docker_image docker:24-cli
# ============================================ # ============================================
# 阶段1: 代码质量检查 # 阶段0: 依赖安装(统一缓存)
# ============================================ # ============================================
steps: steps:
lint: install-deps:
image: *node_image image: *node_image
environment: environment:
NODE_ENV: development NODE_ENV: development
commands: commands:
- npm ci - npm ci --cache /tmp/npm-cache --prefer-offline
volumes:
- /tmp/npm-cache:/root/.npm
- /tmp/node-modules-cache:/woodpecker/src/node_modules
when:
event:
- push
- pull_request
branch:
- feature/**
- dev
- release
- release/**
# ============================================
# 阶段1: 并行代码质量检查
# ============================================
lint:
image: *node_image
environment:
NODE_ENV: development
depends_on:
- install-deps
commands:
- npm run lint - npm run lint
volumes: volumes:
- /tmp/npm-cache:/root/.npm - /tmp/npm-cache:/root/.npm
@@ -67,8 +90,9 @@ steps:
image: *node_image image: *node_image
environment: environment:
NODE_ENV: development NODE_ENV: development
depends_on:
- install-deps
commands: commands:
- npm ci
- npm run type-check - npm run type-check
volumes: volumes:
- /tmp/npm-cache:/root/.npm - /tmp/npm-cache:/root/.npm
@@ -88,9 +112,13 @@ steps:
environment: environment:
NODE_ENV: production NODE_ENV: production
HUSKY: 0 HUSKY: 0
depends_on:
- install-deps
commands: commands:
- npm ci --omit=dev --ignore-scripts
- npm audit --audit-level=high --omit=dev - npm audit --audit-level=high --omit=dev
volumes:
- /tmp/npm-cache:/root/.npm
- /tmp/node-modules-cache:/woodpecker/src/node_modules
when: when:
event: event:
- push - push
@@ -112,7 +140,6 @@ steps:
- type-check - type-check
- security-scan - security-scan
commands: commands:
- npm install --cache /tmp/npm-cache
- npm run test:coverage:check - npm run test:coverage:check
volumes: volumes:
- /tmp/npm-cache:/root/.npm - /tmp/npm-cache:/root/.npm
@@ -127,7 +154,7 @@ steps:
- release/** - release/**
# ============================================ # ============================================
# 阶段3: E2E测试 (分层测试) # 阶段2: E2E测试 (分层测试)
# ============================================ # ============================================
e2e-tests: e2e-tests:
image: mcr.microsoft.com/playwright:v1.48.0-jammy image: mcr.microsoft.com/playwright:v1.48.0-jammy
@@ -135,14 +162,16 @@ steps:
NODE_ENV: test NODE_ENV: test
CI: true CI: true
BASE_URL: http://localhost:3000 BASE_URL: http://localhost:3000
TEST_TIER: standard
depends_on:
- unit-tests
commands: commands:
- npm ci --cache /tmp/npm-cache
- npm run build - npm run build
- cd e2e && npm ci --cache /tmp/npm-cache - npx playwright install chromium --with-deps
- cd e2e && npx playwright install chromium --with-deps - npm run test
- cd e2e && npm run test:standard
volumes: volumes:
- /tmp/npm-cache:/root/.npm - /tmp/npm-cache:/root/.npm
- /tmp/node-modules-cache:/woodpecker/src/node_modules
- /tmp/playwright-cache:/root/.cache/ms-playwright - /tmp/playwright-cache:/root/.cache/ms-playwright
when: when:
event: event:
@@ -153,7 +182,7 @@ steps:
- release/** - release/**
# ============================================ # ============================================
# 阶段4: 构建Docker镜像 (release分支) # 阶段3: 构建Docker镜像 (release分支)
# ============================================ # ============================================
build-image: build-image:
image: *docker_image image: *docker_image
@@ -179,7 +208,7 @@ steps:
- release/** - release/**
# ============================================ # ============================================
# 阶段5: 部署到生产环境 (release分支) # 阶段4: 部署到生产环境 (release分支)
# ============================================ # ============================================
deploy-production: deploy-production:
image: alpine/git:latest image: alpine/git:latest
@@ -276,7 +305,7 @@ steps:
- release/** - release/**
# ============================================ # ============================================
# 阶段6: 归档到main分支 (release分支) # 阶段5: 归档到main分支 (release分支)
# ============================================ # ============================================
archive-to-main: archive-to-main:
image: alpine/git:latest image: alpine/git:latest
@@ -331,7 +360,7 @@ steps:
- success - success
# ============================================ # ============================================
# 阶段7: 企业微信通知 # 阶段6: 企业微信通知
# ============================================ # ============================================
notify-wechat-success: notify-wechat-success:
image: curlimages/curl:latest image: curlimages/curl:latest
-194
View File
@@ -1,194 +0,0 @@
require('@testing-library/jest-dom');
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
jest.mock('next-auth', () => {
return {
__esModule: true,
default: jest.fn(() => ({
handlers: {
authOptions: {
providers: [],
callbacks: {},
pages: {},
session: {},
},
},
signIn: jest.fn(),
signOut: jest.fn(),
auth: jest.fn(),
})),
getServerSession: jest.fn(),
};
});
jest.mock('next-auth/providers/credentials', () =>
jest.fn(() => ({
name: '邮箱密码',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
authorize: jest.fn(),
}))
);
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'test-id-123'),
}));
jest.mock('next/dynamic', () => ({
__esModule: true,
default: (importFn, options) => {
const MockComponent = (props) => null;
MockComponent.displayName = 'DynamicComponent';
MockComponent.preload = () => Promise.resolve();
return MockComponent;
},
}));
jest.mock('next/server', () => ({
NextRequest: class MockNextRequest {
constructor(input, init = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = new Headers(init.headers);
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
},
NextResponse: {
json: (body, init = {}) => ({
status: init.status || 200,
json: async () => body,
}),
},
}));
global.console = {
...console,
error: jest.fn(),
warn: jest.fn(),
log: jest.fn(),
};
class MockIntersectionObserver {
constructor(callback, options = {}) {
this.callback = callback;
this.options = options;
this.elements = new Set();
this.observationEntries = [];
}
observe(element) {
this.elements.add(element);
const entry = {
isIntersecting: true,
target: element,
boundingClientRect: element.getBoundingClientRect ? element.getBoundingClientRect() : {},
intersectionRatio: 1,
intersectionRect: {},
rootBounds: {},
time: Date.now(),
};
this.observationEntries.push(entry);
this.callback(this.observationEntries, this);
}
unobserve(element) {
this.elements.delete(element);
this.observationEntries = this.observationEntries.filter(
entry => entry.target !== element
);
}
disconnect() {
this.elements.clear();
this.observationEntries = [];
}
takeRecords() {
return this.observationEntries;
}
}
global.IntersectionObserver = MockIntersectionObserver;
global.IntersectionObserverEntry = class IntersectionObserverEntry {
constructor() {
this.isIntersecting = true;
this.target = {};
this.boundingClientRect = {};
this.intersectionRatio = 1;
this.intersectionRect = {};
this.rootBounds = {};
this.time = Date.now();
}
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
global.Request = class Request {
constructor(input, init = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = new Headers(init.headers);
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
};
global.Headers = class Headers {
constructor(init = {}) {
this.headers = {};
if (init) {
Object.entries(init).forEach(([key, value]) => {
this.headers[key.toLowerCase()] = value;
});
}
}
get(name) {
return this.headers[name.toLowerCase()];
}
set(name, value) {
this.headers[name.toLowerCase()] = value;
}
};
global.Response = class Response {
constructor(body, init = {}) {
this.body = body;
this.status = init.status || 200;
this.statusText = init.statusText || 'OK';
this.headers = new Headers(init.headers);
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
async text() {
return String(this.body);
}
};
+4 -4
View File
@@ -3,10 +3,10 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "playwright test", "test": "playwright test --config=../playwright.config.ts",
"test:standard": "TEST_TIER=standard playwright test --config=playwright.config.tiered.ts", "test:fast": "TEST_TIER=fast playwright test --config=../playwright.config.ts",
"test:fast": "TEST_TIER=fast playwright test --config=playwright.config.tiered.ts", "test:standard": "TEST_TIER=standard playwright test --config=../playwright.config.ts",
"test:deep": "TEST_TIER=deep playwright test --config=playwright.config.tiered.ts" "test:deep": "TEST_TIER=deep playwright test --config=../playwright.config.ts"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.48.0", "@playwright/test": "^1.48.0",
-58
View File
@@ -1,58 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
const testTier = process.env.TEST_TIER || 'standard';
const tierConfig = {
fast: {
timeout: 15000,
retries: 0,
workers: 2,
},
standard: {
timeout: 30000,
retries: 1,
workers: 1,
},
deep: {
timeout: 60000,
retries: 2,
workers: 1,
},
};
const config = tierConfig[testTier] || tierConfig.standard;
export default defineConfig({
testDir: './',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: config.retries,
workers: config.workers,
timeout: config.timeout,
reporter: [
['html', { outputFolder: 'reports/html', open: 'never' }],
['json', { outputFile: 'reports/results.json' }],
['list']
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
launchOptions: {
args: ['--disable-dev-shm-usage', '--no-sandbox']
}
},
webServer: process.env.CI ? {
command: 'npm run start',
port: 3000,
timeout: 120000,
reuseExistingServer: false,
} : undefined,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
-1
View File
@@ -1 +0,0 @@
config/test/jest.setup.js
+135
View File
@@ -0,0 +1,135 @@
require('@testing-library/jest-dom');
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
const { setupAllMocks } = require('./src/__mocks__/shared-mocks');
setupAllMocks();
global.console = {
...console,
error: jest.fn(),
warn: jest.fn(),
log: jest.fn(),
};
class MockIntersectionObserver {
constructor(callback, options = {}) {
this.callback = callback;
this.options = options;
this.elements = new Set();
this.observationEntries = [];
}
observe(element) {
this.elements.add(element);
const entry = {
isIntersecting: true,
target: element,
boundingClientRect: element.getBoundingClientRect ? element.getBoundingClientRect() : {},
intersectionRatio: 1,
intersectionRect: {},
rootBounds: {},
time: Date.now(),
};
this.observationEntries.push(entry);
this.callback(this.observationEntries, this);
}
unobserve(element) {
this.elements.delete(element);
}
disconnect() {
this.elements.clear();
this.observationEntries = [];
}
}
global.IntersectionObserver = MockIntersectionObserver;
class MockResizeObserver {
constructor(callback) {
this.callback = callback;
this.elements = new Set();
}
observe(element) {
this.elements.add(element);
this.callback([{ target: element, contentRect: { width: 100, height: 100 } }], this);
}
unobserve(element) {
this.elements.delete(element);
}
disconnect() {
this.elements.clear();
}
}
global.ResizeObserver = MockResizeObserver;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
Object.defineProperty(window, 'scrollTo', {
writable: true,
value: jest.fn(),
});
Object.defineProperty(window, 'localStorage', {
value: {
store: {},
getItem(key) {
return this.store[key] || null;
},
setItem(key, value) {
this.store[key] = value;
},
removeItem(key) {
delete this.store[key];
},
clear() {
this.store = {};
},
},
});
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue([]),
}),
insert: jest.fn().mockReturnValue({
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ id: 1 }]),
}),
update: jest.fn().mockReturnValue({
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ id: 1 }]),
}),
delete: jest.fn().mockReturnValue({
where: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ id: 1 }]),
}),
},
}));
+5 -3
View File
@@ -8,13 +8,15 @@
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "eslint", "lint": "eslint",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "cd e2e && npx playwright test --config=playwright.config.ts", "test": "playwright test",
"test:unit": "jest", "test:unit": "jest",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"test:coverage:check": "jest --coverage --ci", "test:coverage:check": "jest --coverage --ci",
"coverage:report": "open coverage/lcov-report/index.html", "coverage:report": "open coverage/lcov-report/index.html",
"test:e2e": "cd e2e && npm test", "test:e2e": "playwright test",
"test:standard": "cd e2e && npm run test:standard", "test:fast": "TEST_TIER=fast playwright test",
"test:standard": "TEST_TIER=standard playwright test",
"test:deep": "TEST_TIER=deep playwright test",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
+76 -29
View File
@@ -1,38 +1,85 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
const testTier = process.env.TEST_TIER || 'standard';
const baseURL = process.env.BASE_URL || (isCI ? 'http://localhost:3000' : 'https://novalon.cn');
const tierConfig = {
fast: {
timeout: 15000,
retries: 0,
workers: 2,
},
standard: {
timeout: 30000,
retries: isCI ? 1 : 0,
workers: isCI ? 1 : undefined,
},
deep: {
timeout: 60000,
retries: 2,
workers: 1,
},
};
const config = tierConfig[testTier] || tierConfig.standard;
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: true, fullyParallel: !isCI,
forbidOnly: !!process.env.CI, forbidOnly: isCI,
retries: process.env.CI ? 2 : 0, retries: config.retries,
workers: process.env.CI ? 1 : undefined, workers: config.workers,
reporter: 'html', timeout: config.timeout,
reporter: isCI
? [
['html', { outputFolder: 'reports/html', open: 'never' }],
['json', { outputFile: 'reports/results.json' }],
['list']
]
: 'html',
use: { use: {
baseURL: 'https://novalon.cn', baseURL,
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
video: 'retain-on-failure', video: 'retain-on-failure',
launchOptions: isCI ? {
args: ['--disable-dev-shm-usage', '--no-sandbox']
} : undefined,
}, },
projects: [ webServer: isCI ? {
{ command: 'npm run start',
name: 'chromium', port: 3000,
use: { ...devices['Desktop Chrome'] }, timeout: 120000,
}, reuseExistingServer: false,
{ } : undefined,
name: 'firefox', projects: isCI
use: { ...devices['Desktop Firefox'] }, ? [
}, {
{ name: 'chromium',
name: 'webkit', use: { ...devices['Desktop Chrome'] },
use: { ...devices['Desktop Safari'] }, },
}, ]
{ : [
name: 'Mobile Chrome', {
use: { ...devices['Pixel 5'] }, name: 'chromium',
}, use: { ...devices['Desktop Chrome'] },
{ },
name: 'Mobile Safari', {
use: { ...devices['iPhone 12'] }, name: 'firefox',
}, use: { ...devices['Desktop Firefox'] },
], },
}); {
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
});
+77 -37
View File
@@ -117,32 +117,35 @@ export const mockLucideReact = () => {
AlertCircle: () => <span data-testid="alert-icon" />, AlertCircle: () => <span data-testid="alert-icon" />,
Info: () => <span data-testid="info-icon" />, Info: () => <span data-testid="info-icon" />,
HelpCircle: () => <span data-testid="help-icon" />, HelpCircle: () => <span data-testid="help-icon" />,
Loader2: () => <span data-testid="loader-icon" />,
MoreVertical: () => <span data-testid="more-vertical-icon" />,
ChevronUp: () => <span data-testid="chevron-up" />,
ExternalLink: () => <span data-testid="external-link-icon" />,
})); }));
}; };
export const mockNextDynamic = () => { export const mockNextDynamic = () => {
jest.mock('next/dynamic', () => { jest.mock('next/dynamic', () => ({
const MockDynamic = (props: MockProps) => { __esModule: true,
return <div data-testid="dynamic-component" {...props} />; default: (_importFn: () => Promise<unknown>, _options?: unknown) => {
}; const MockComponent = (props: MockProps) => <div data-testid="dynamic-component" {...props} />;
MockDynamic.displayName = 'MockDynamic'; MockComponent.displayName = 'DynamicComponent';
return { MockComponent.preload = () => Promise.resolve();
__esModule: true, return MockComponent;
default: MockDynamic, },
}; }));
});
}; };
export const mockNextImage = () => { export const mockNextImage = () => {
jest.mock('next/image', () => { jest.mock('next/image', () => {
const MockImage = ({ src, alt, width, height, className, ...props }: MockProps) => ( const MockImage = ({ src, alt, width, height, className, ...props }: MockProps) => (
<img <img
src={src} src={typeof src === 'string' ? src : ''}
alt={alt || ''} alt={alt || ''}
width={width} width={width}
height={height} height={height}
className={className} className={className}
{...props} {...props}
/> />
); );
MockImage.displayName = 'MockImage'; MockImage.displayName = 'MockImage';
@@ -150,40 +153,77 @@ export const mockNextImage = () => {
}); });
}; };
export const mockDatabase = () => { export const mockNextAuth = () => {
jest.mock('@/db', () => ({ jest.mock('next-auth', () => ({
db: { __esModule: true,
select: jest.fn().mockReturnValue({ default: jest.fn(() => ({
from: jest.fn().mockResolvedValue([]), handlers: {
}), authOptions: {
insert: jest.fn().mockReturnValue({ providers: [],
values: jest.fn().mockReturnValue({ callbacks: {},
returning: jest.fn().mockResolvedValue([{ id: 1 }]), pages: {},
}), session: {},
}), },
update: jest.fn().mockReturnValue({ },
set: jest.fn().mockReturnValue({ signIn: jest.fn(),
where: jest.fn().mockResolvedValue([{ id: 1 }]), signOut: jest.fn(),
}), auth: jest.fn(),
}), })),
delete: jest.fn().mockReturnValue({ getServerSession: jest.fn(),
where: jest.fn().mockResolvedValue([]), }));
jest.mock('next-auth/providers/credentials', () =>
jest.fn(() => ({
name: '邮箱密码',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
authorize: jest.fn(),
}))
);
};
export const mockNanoid = () => {
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'test-id-123'),
}));
};
export const mockNextServer = () => {
jest.mock('next/server', () => ({
NextRequest: class MockNextRequest {
url: string;
method: string;
headers: Headers;
body: unknown;
constructor(input: string | { url: string }, init: { method?: string; headers?: Headers; body?: unknown } = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = init.headers || new Headers();
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
},
NextResponse: {
json: (body: unknown, init: { status?: number } = {}) => ({
status: init.status || 200,
json: async () => body,
}), }),
}, },
})); }));
}; };
export const setupSharedMocks = () => { export const setupAllMocks = () => {
mockFramerMotion(); mockFramerMotion();
mockNextLink(); mockNextLink();
mockNextNavigation(); mockNextNavigation();
mockLucideReact(); mockLucideReact();
mockNextDynamic(); mockNextDynamic();
mockNextImage(); mockNextImage();
}; mockNextAuth();
mockNanoid();
export const setupMinimalMocks = () => { mockNextServer();
mockFramerMotion();
mockNextLink();
mockLucideReact();
}; };