diff --git a/.woodpecker.yml b/.woodpecker.yml index cfcdc9b..4358222 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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 diff --git a/config/test/jest.setup.js b/config/test/jest.setup.js deleted file mode 100644 index 7c991a8..0000000 --- a/config/test/jest.setup.js +++ /dev/null @@ -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); - } -}; \ No newline at end of file diff --git a/e2e/package.json b/e2e/package.json index 12ad1fd..21b065c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -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", diff --git a/e2e/playwright.config.tiered.ts b/e2e/playwright.config.tiered.ts deleted file mode 100644 index 174aad4..0000000 --- a/e2e/playwright.config.tiered.ts +++ /dev/null @@ -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'] }, - }, - ], -}); diff --git a/jest.setup.js b/jest.setup.js deleted file mode 120000 index 6eced6b..0000000 --- a/jest.setup.js +++ /dev/null @@ -1 +0,0 @@ -config/test/jest.setup.js \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..0a6fe02 --- /dev/null +++ b/jest.setup.js @@ -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 }]), + }), + }, +})); diff --git a/package.json b/package.json index 0694337..606555d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index d623031..60707b0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'] }, - }, - ], -}); \ No newline at end of file + 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'] }, + }, + ], +}); diff --git a/src/__mocks__/shared-mocks.tsx b/src/__mocks__/shared-mocks.tsx index 0513425..c7406bc 100644 --- a/src/__mocks__/shared-mocks.tsx +++ b/src/__mocks__/shared-mocks.tsx @@ -117,32 +117,35 @@ export const mockLucideReact = () => { AlertCircle: () => , Info: () => , HelpCircle: () => , + Loader2: () => , + MoreVertical: () => , + ChevronUp: () => , + ExternalLink: () => , })); }; export const mockNextDynamic = () => { - jest.mock('next/dynamic', () => { - const MockDynamic = (props: MockProps) => { - return
; - }; - MockDynamic.displayName = 'MockDynamic'; - return { - __esModule: true, - default: MockDynamic, - }; - }); + jest.mock('next/dynamic', () => ({ + __esModule: true, + default: (_importFn: () => Promise, _options?: unknown) => { + const MockComponent = (props: MockProps) =>
; + 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) => ( {alt ); 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(); };