dev #5
+44
-15
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
config/test/jest.setup.js
|
|
||||||
+135
@@ -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
@@ -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",
|
||||||
|
|||||||
+75
-28
@@ -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'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
@@ -117,27 +117,30 @@ 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}
|
||||||
@@ -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();
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user