dev #5

Merged
zhangxiang merged 159 commits from dev into main 2026-04-12 17:39:08 +08:00
8 changed files with 341 additions and 341 deletions
Showing only changes of commit 0337c51320 - Show all commits
+44 -15
View File
@@ -40,15 +40,38 @@ variables:
- &docker_image docker:24-cli
# ============================================
# 阶段1: 代码质量检查
# 阶段0: 依赖安装(统一缓存)
# ============================================
steps:
lint:
install-deps:
image: *node_image
environment:
NODE_ENV: development
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
volumes:
- /tmp/npm-cache:/root/.npm
@@ -67,8 +90,9 @@ steps:
image: *node_image
environment:
NODE_ENV: development
depends_on:
- install-deps
commands:
- npm ci
- npm run type-check
volumes:
- /tmp/npm-cache:/root/.npm
@@ -88,9 +112,13 @@ steps:
environment:
NODE_ENV: production
HUSKY: 0
depends_on:
- install-deps
commands:
- npm ci --omit=dev --ignore-scripts
- npm audit --audit-level=high --omit=dev
volumes:
- /tmp/npm-cache:/root/.npm
- /tmp/node-modules-cache:/woodpecker/src/node_modules
when:
event:
- push
@@ -112,7 +140,6 @@ steps:
- type-check
- security-scan
commands:
- npm install --cache /tmp/npm-cache
- npm run test:coverage:check
volumes:
- /tmp/npm-cache:/root/.npm
@@ -127,7 +154,7 @@ steps:
- release/**
# ============================================
# 阶段3: E2E测试 (分层测试)
# 阶段2: E2E测试 (分层测试)
# ============================================
e2e-tests:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
@@ -135,14 +162,16 @@ steps:
NODE_ENV: test
CI: true
BASE_URL: http://localhost:3000
TEST_TIER: standard
depends_on:
- unit-tests
commands:
- npm ci --cache /tmp/npm-cache
- npm run build
- cd e2e && npm ci --cache /tmp/npm-cache
- cd e2e && npx playwright install chromium --with-deps
- cd e2e && npm run test:standard
- npx playwright install chromium --with-deps
- npm run test
volumes:
- /tmp/npm-cache:/root/.npm
- /tmp/node-modules-cache:/woodpecker/src/node_modules
- /tmp/playwright-cache:/root/.cache/ms-playwright
when:
event:
@@ -153,7 +182,7 @@ steps:
- release/**
# ============================================
# 阶段4: 构建Docker镜像 (release分支)
# 阶段3: 构建Docker镜像 (release分支)
# ============================================
build-image:
image: *docker_image
@@ -179,7 +208,7 @@ steps:
- release/**
# ============================================
# 阶段5: 部署到生产环境 (release分支)
# 阶段4: 部署到生产环境 (release分支)
# ============================================
deploy-production:
image: alpine/git:latest
@@ -276,7 +305,7 @@ steps:
- release/**
# ============================================
# 阶段6: 归档到main分支 (release分支)
# 阶段5: 归档到main分支 (release分支)
# ============================================
archive-to-main:
image: alpine/git:latest
@@ -331,7 +360,7 @@ steps:
- success
# ============================================
# 阶段7: 企业微信通知
# 阶段6: 企业微信通知
# ============================================
notify-wechat-success:
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",
"private": true,
"scripts": {
"test": "playwright test",
"test:standard": "TEST_TIER=standard playwright test --config=playwright.config.tiered.ts",
"test:fast": "TEST_TIER=fast playwright test --config=playwright.config.tiered.ts",
"test:deep": "TEST_TIER=deep playwright test --config=playwright.config.tiered.ts"
"test": "playwright test --config=../playwright.config.ts",
"test:fast": "TEST_TIER=fast playwright test --config=../playwright.config.ts",
"test:standard": "TEST_TIER=standard playwright test --config=../playwright.config.ts",
"test:deep": "TEST_TIER=deep playwright test --config=../playwright.config.ts"
},
"devDependencies": {
"@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",
"lint": "eslint",
"type-check": "tsc --noEmit",
"test": "cd e2e && npx playwright test --config=playwright.config.ts",
"test": "playwright test",
"test:unit": "jest",
"test:coverage": "jest --coverage",
"test:coverage:check": "jest --coverage --ci",
"coverage:report": "open coverage/lcov-report/index.html",
"test:e2e": "cd e2e && npm test",
"test:standard": "cd e2e && npm run test:standard",
"test:e2e": "playwright test",
"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:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
+76 -29
View File
@@ -1,38 +1,85 @@
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({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
fullyParallel: !isCI,
forbidOnly: isCI,
retries: config.retries,
workers: config.workers,
timeout: config.timeout,
reporter: isCI
? [
['html', { outputFolder: 'reports/html', open: 'never' }],
['json', { outputFile: 'reports/results.json' }],
['list']
]
: 'html',
use: {
baseURL: 'https://novalon.cn',
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
launchOptions: isCI ? {
args: ['--disable-dev-shm-usage', '--no-sandbox']
} : undefined,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
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'] },
},
],
});
webServer: isCI ? {
command: 'npm run start',
port: 3000,
timeout: 120000,
reuseExistingServer: false,
} : undefined,
projects: isCI
? [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
]
: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
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" />,
Info: () => <span data-testid="info-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 = () => {
jest.mock('next/dynamic', () => {
const MockDynamic = (props: MockProps) => {
return <div data-testid="dynamic-component" {...props} />;
};
MockDynamic.displayName = 'MockDynamic';
return {
__esModule: true,
default: MockDynamic,
};
});
jest.mock('next/dynamic', () => ({
__esModule: true,
default: (_importFn: () => Promise<unknown>, _options?: unknown) => {
const MockComponent = (props: MockProps) => <div data-testid="dynamic-component" {...props} />;
MockComponent.displayName = 'DynamicComponent';
MockComponent.preload = () => Promise.resolve();
return MockComponent;
},
}));
};
export const mockNextImage = () => {
jest.mock('next/image', () => {
const MockImage = ({ src, alt, width, height, className, ...props }: MockProps) => (
<img
src={src}
src={typeof src === 'string' ? src : ''}
alt={alt || ''}
width={width}
height={height}
className={className}
{...props}
{...props}
/>
);
MockImage.displayName = 'MockImage';
@@ -150,40 +153,77 @@ export const mockNextImage = () => {
});
};
export const mockDatabase = () => {
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
from: jest.fn().mockResolvedValue([]),
}),
insert: jest.fn().mockReturnValue({
values: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{ id: 1 }]),
}),
}),
update: jest.fn().mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockResolvedValue([{ id: 1 }]),
}),
}),
delete: jest.fn().mockReturnValue({
where: jest.fn().mockResolvedValue([]),
export const mockNextAuth = () => {
jest.mock('next-auth', () => ({
__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(),
}))
);
};
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();
mockNextLink();
mockNextNavigation();
mockLucideReact();
mockNextDynamic();
mockNextImage();
};
export const setupMinimalMocks = () => {
mockFramerMotion();
mockNextLink();
mockLucideReact();
mockNextAuth();
mockNanoid();
mockNextServer();
};