Dev #21
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/impeccable
|
|
||||||
@@ -266,6 +266,15 @@ performance-baseline.json
|
|||||||
test-data-*.json
|
test-data-*.json
|
||||||
test-results-*.json
|
test-results-*.json
|
||||||
|
|
||||||
|
# Stryker mutation testing temp files
|
||||||
|
.stryker-tmp/
|
||||||
|
|
||||||
|
# Pi framework temp files
|
||||||
|
.pi/
|
||||||
|
|
||||||
|
# Agent config (local only)
|
||||||
|
.agents/settings.local.json
|
||||||
|
|
||||||
# Additional E2E test artifacts
|
# Additional E2E test artifacts
|
||||||
e2e/blob-report/
|
e2e/blob-report/
|
||||||
e2e/report/
|
e2e/report/
|
||||||
|
|||||||
Vendored
+80
@@ -162,6 +162,86 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== L3: E2E + 用户旅程测试 ======
|
||||||
|
stage('🌐 E2E 测试') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
beforeAgent true
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
echo "🔨 构建用于 E2E 测试..."
|
||||||
|
npm run build
|
||||||
|
echo "🚀 启动预览服务器..."
|
||||||
|
npx serve dist -l 3000 &
|
||||||
|
sleep 5
|
||||||
|
echo "🧪 运行 E2E 快速测试(@smoke + @critical)..."
|
||||||
|
cd e2e && npx playwright test --grep "@smoke|@critical" || echo "⚠️ E2E 测试部分失败,继续执行"
|
||||||
|
echo "🧪 运行用户旅程测试..."
|
||||||
|
npx playwright test --grep @journey || echo "⚠️ 用户旅程测试部分失败,继续执行"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
|
||||||
|
publishHTML(target: [
|
||||||
|
allowMissing: true,
|
||||||
|
reportDir: 'e2e/playwright-report',
|
||||||
|
reportFiles: 'index.html',
|
||||||
|
reportName: 'E2E Test Report'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== L4: 视觉回归测试 ======
|
||||||
|
stage('👁️ 视觉回归测试') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
beforeAgent true
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
echo "🚀 启动预览服务器..."
|
||||||
|
npx serve dist -l 3000 &
|
||||||
|
sleep 5
|
||||||
|
echo "🧪 运行视觉回归测试..."
|
||||||
|
cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop || echo "⚠️ 视觉回归测试失败,请检查基线是否需要更新"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
|
||||||
|
archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== L5: 安全扫描 ======
|
||||||
|
stage('🔒 安全扫描') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
beforeAgent true
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
sh '''
|
||||||
|
echo "🔒 运行依赖安全审计..."
|
||||||
|
npm audit --audit-level=high || echo "⚠️ 存在高危依赖漏洞,请检查"
|
||||||
|
echo "🔒 检查安全响应头..."
|
||||||
|
npm run test:security:headers -- --url https://novalon.cn || echo "⚠️ 安全头检查未通过,请检查 Nginx 配置"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('🏗️ 构建 dist') {
|
stage('🏗️ 构建 dist') {
|
||||||
steps {
|
steps {
|
||||||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
|
rootDir: path.resolve(__dirname, '../..'),
|
||||||
roots: ['<rootDir>/src'],
|
roots: ['<rootDir>/src'],
|
||||||
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
|
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
|
||||||
// 仅收集实际可单元测试的目录的覆盖率,排除 pages/API routes/admin 等需集成/E2E 测试的代码
|
// 仅收集实际可单元测试的目录的覆盖率,排除 pages/API routes/admin 等需集成/E2E 测试的代码
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
reporter: [
|
reporter: [
|
||||||
['html', { open: 'never' }],
|
['html', { open: 'never' }],
|
||||||
['list', { printSteps: true }],
|
['list', { printSteps: true }],
|
||||||
|
['allure-playwright', { outputFolder: 'allure-results' }],
|
||||||
],
|
],
|
||||||
snapshotDir: './visual-snapshots',
|
snapshotDir: './visual-snapshots',
|
||||||
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
|
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
config/test/jest.config.js
|
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
|
||||||
|
// 仅收集实际可单元测试的目录的覆盖率,排除 pages/API routes/admin 等需集成/E2E 测试的代码
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/components/ui/**/*.{ts,tsx}',
|
||||||
|
'src/components/layout/**/*.{ts,tsx}',
|
||||||
|
'src/components/seo/**/*.{ts,tsx}',
|
||||||
|
'src/components/detail/**/*.{ts,tsx}',
|
||||||
|
'src/components/sections/**/*.{ts,tsx}',
|
||||||
|
'src/components/content/**/*.{ts,tsx}',
|
||||||
|
'src/hooks/**/*.{ts,tsx}',
|
||||||
|
'src/lib/**/*.ts',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/**/*.stories.{ts,tsx}',
|
||||||
|
'!src/**/__tests__/**',
|
||||||
|
],
|
||||||
|
// 覆盖率阈值采用渐进提升策略:基于 Phase 1-5 实测值设定目录级阈值,避免全局平均掩盖核心模块缺口
|
||||||
|
// 实测基准 (Phase 5): global(~33% stmts/~32% branches), hooks(~61%/~41%), lib(~45%/~43%),
|
||||||
|
// ui(~15%/~21%), layout(~59%/~48%), detail(~32%/~29%), sections(~31%/~35%), content(~29%/~5%),
|
||||||
|
// lib/cms(~67%/~83%), lib/constants(~94%/~81%)
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 30,
|
||||||
|
functions: 25,
|
||||||
|
lines: 32,
|
||||||
|
statements: 30,
|
||||||
|
},
|
||||||
|
'./src/hooks/': {
|
||||||
|
branches: 38,
|
||||||
|
functions: 60,
|
||||||
|
lines: 60,
|
||||||
|
statements: 60,
|
||||||
|
},
|
||||||
|
'./src/lib/': {
|
||||||
|
branches: 40,
|
||||||
|
functions: 45,
|
||||||
|
lines: 42,
|
||||||
|
statements: 42,
|
||||||
|
},
|
||||||
|
'./src/components/ui/': {
|
||||||
|
branches: 18,
|
||||||
|
functions: 10,
|
||||||
|
lines: 15,
|
||||||
|
statements: 14,
|
||||||
|
},
|
||||||
|
'./src/components/layout/': {
|
||||||
|
branches: 45,
|
||||||
|
functions: 35,
|
||||||
|
lines: 60,
|
||||||
|
statements: 55,
|
||||||
|
},
|
||||||
|
'./src/components/detail/': {
|
||||||
|
branches: 25,
|
||||||
|
functions: 22,
|
||||||
|
lines: 30,
|
||||||
|
statements: 30,
|
||||||
|
},
|
||||||
|
'./src/components/sections/': {
|
||||||
|
branches: 30,
|
||||||
|
functions: 18,
|
||||||
|
lines: 30,
|
||||||
|
statements: 30,
|
||||||
|
},
|
||||||
|
'./src/components/content/': {
|
||||||
|
branches: 4,
|
||||||
|
functions: 4,
|
||||||
|
lines: 25,
|
||||||
|
statements: 25,
|
||||||
|
},
|
||||||
|
'./src/components/seo/': {
|
||||||
|
branches: 0,
|
||||||
|
functions: 18,
|
||||||
|
lines: 40,
|
||||||
|
statements: 40,
|
||||||
|
},
|
||||||
|
'./src/lib/cms/': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 75,
|
||||||
|
lines: 60,
|
||||||
|
statements: 65,
|
||||||
|
},
|
||||||
|
'./src/lib/constants/': {
|
||||||
|
branches: 88,
|
||||||
|
functions: 45,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
coverageProvider: 'v8',
|
||||||
|
coverageReporters: ['text', 'lcov', 'html', 'json'],
|
||||||
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
// ts-jest 需要明确指定 jsx: 'react-jsx',因为项目 tsconfig 使用 'preserve'
|
||||||
|
// 由后续 SWC/Babel 处理,但 Jest 环境下没有这些工具链
|
||||||
|
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(nanoid|next-auth|@auth|@aws-sdk)/)',
|
||||||
|
],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
verbose: true,
|
||||||
|
maxWorkers: '50%',
|
||||||
|
};
|
||||||
@@ -1 +0,0 @@
|
|||||||
config/test/jest.setup.js
|
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
require('@testing-library/jest-dom');
|
||||||
|
|
||||||
|
const { TextEncoder, TextDecoder } = require('util');
|
||||||
|
global.TextEncoder = TextEncoder;
|
||||||
|
global.TextDecoder = TextDecoder;
|
||||||
|
|
||||||
|
jest.mock('nanoid', () => ({
|
||||||
|
nanoid: jest.fn(() => 'test-id-123'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/generated/prisma/client', () => ({
|
||||||
|
PrismaClient: jest.fn().mockImplementation(() => ({
|
||||||
|
contentItem: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
contentModel: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
contentZone: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/cms/data-server', () => ({
|
||||||
|
getPublishedItems: jest.fn().mockResolvedValue([]),
|
||||||
|
getPublishedItemBySlug: jest.fn().mockResolvedValue(null),
|
||||||
|
getItemById: jest.fn().mockResolvedValue(null),
|
||||||
|
getPageZones: jest.fn().mockResolvedValue([]),
|
||||||
|
getZone: jest.fn().mockResolvedValue(null),
|
||||||
|
getContentModels: jest.fn().mockResolvedValue([]),
|
||||||
|
getCases: jest.fn().mockResolvedValue([]),
|
||||||
|
getNews: jest.fn().mockResolvedValue([]),
|
||||||
|
getServices: jest.fn().mockResolvedValue([]),
|
||||||
|
getProducts: jest.fn().mockResolvedValue([]),
|
||||||
|
getSolutions: jest.fn().mockResolvedValue([]),
|
||||||
|
getStats: jest.fn().mockResolvedValue([]),
|
||||||
|
getHeroBanners: jest.fn().mockResolvedValue([]),
|
||||||
|
getCaseBySlug: jest.fn().mockResolvedValue(null),
|
||||||
|
getNewsBySlug: jest.fn().mockResolvedValue(null),
|
||||||
|
getServiceBySlug: jest.fn().mockResolvedValue(null),
|
||||||
|
getProductBySlug: jest.fn().mockResolvedValue(null),
|
||||||
|
getSolutionBySlug: jest.fn().mockResolvedValue(null),
|
||||||
|
getHomePageZones: jest.fn().mockResolvedValue([]),
|
||||||
|
getAllPublishedSlugs: jest.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+1174
-264
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -19,7 +19,6 @@
|
|||||||
"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 && npx playwright test --config=playwright.config.ts",
|
"test:e2e": "cd e2e && npx playwright test --config=playwright.config.ts",
|
||||||
"test:smoke": "cd e2e && npx playwright test --grep @smoke",
|
|
||||||
"test:visual": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop",
|
"test:visual": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop",
|
||||||
"test:visual:all": "cd e2e && npx playwright test visual-regression.spec.ts",
|
"test:visual:all": "cd e2e && npx playwright test visual-regression.spec.ts",
|
||||||
"test:visual:update": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop --update-snapshots",
|
"test:visual:update": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop --update-snapshots",
|
||||||
@@ -28,8 +27,19 @@
|
|||||||
"test:visual:browsers": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop --project=visual-firefox-desktop --project=visual-webkit-desktop",
|
"test:visual:browsers": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop --project=visual-firefox-desktop --project=visual-webkit-desktop",
|
||||||
"test:performance": "k6 run tests/performance/load-test.js",
|
"test:performance": "k6 run tests/performance/load-test.js",
|
||||||
"test:stress": "k6 run tests/performance/stress-test.js",
|
"test:stress": "k6 run tests/performance/stress-test.js",
|
||||||
|
"test:smoke": "cd e2e && npx playwright test --grep @smoke",
|
||||||
|
"test:critical": "cd e2e && npx playwright test --grep @critical",
|
||||||
|
"test:e2e:fast": "cd e2e && npx playwright test --grep '@smoke|@critical'",
|
||||||
|
"test:e2e:standard": "cd e2e && npx playwright test --grep @regression",
|
||||||
|
"test:e2e:journey": "cd e2e && npx playwright test --grep @journey",
|
||||||
|
"test:e2e:mobile": "cd e2e && npx playwright test --grep @mobile",
|
||||||
|
"test:mutation": "rm -rf .stryker-tmp && npx stryker run --inPlace",
|
||||||
|
"test:mutation:quick": "rm -rf .stryker-tmp && npx stryker run --inPlace --mutate 'src/lib/utils.ts'",
|
||||||
|
"test:all": "npm run type-check && npm run lint && npm run test:coverage && npm run test:e2e:fast && npm run test:security:headers",
|
||||||
"check:contrast": "tsx scripts/utils/check-color-contrast.ts",
|
"check:contrast": "tsx scripts/utils/check-color-contrast.ts",
|
||||||
"check:headings": "tsx scripts/utils/check-heading-hierarchy.ts",
|
"check:headings": "tsx scripts/utils/check-heading-hierarchy.ts",
|
||||||
|
"test:security": "npm audit --audit-level=high && npm run test:security:headers",
|
||||||
|
"test:security:headers": "tsx scripts/utils/check-security-headers.ts",
|
||||||
"lighthouse": "lhci autorun",
|
"lighthouse": "lhci autorun",
|
||||||
"lighthouse:collect": "lhci collect",
|
"lighthouse:collect": "lhci collect",
|
||||||
"lighthouse:assert": "lhci assert",
|
"lighthouse:assert": "lhci assert",
|
||||||
@@ -98,6 +108,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.5.0",
|
"@commitlint/config-conventional": "^20.5.0",
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
|
"@stryker-mutator/core": "^9.6.1",
|
||||||
|
"@stryker-mutator/jest-runner": "^9.6.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -109,6 +121,7 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||||
"@typescript-eslint/parser": "^8.65.0",
|
"@typescript-eslint/parser": "^8.65.0",
|
||||||
|
"allure-playwright": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"chrome-launcher": "^1.2.1",
|
"chrome-launcher": "^1.2.1",
|
||||||
"eslint": "^9.25.1",
|
"eslint": "^9.25.1",
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
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',
|
|
||||||
use: {
|
|
||||||
baseURL: 'https://novalon.cn',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
video: 'retain-on-failure',
|
|
||||||
},
|
|
||||||
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'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全响应头检查脚本
|
||||||
|
*
|
||||||
|
* 检查部署网站的安全头配置是否合规,包括:
|
||||||
|
* - 核心安全头(Content-Security-Policy, X-Content-Type-Options 等)
|
||||||
|
* - CORS 配置
|
||||||
|
* - Cookie 安全标记
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SecurityCheck {
|
||||||
|
header: string;
|
||||||
|
expected: string;
|
||||||
|
actual: string | null;
|
||||||
|
status: 'pass' | 'fail' | 'warn';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CookieCheck {
|
||||||
|
name: string;
|
||||||
|
hasHttpOnly: boolean;
|
||||||
|
hasSecure: boolean;
|
||||||
|
hasSameSite: boolean;
|
||||||
|
status: 'pass' | 'fail' | 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): string {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const urlIndex = args.indexOf('--url');
|
||||||
|
if (urlIndex !== -1 && args[urlIndex + 1]) {
|
||||||
|
return args[urlIndex + 1]!;
|
||||||
|
}
|
||||||
|
return 'https://novalon.cn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(setCookieHeader: string | null): Array<{ name: string; flags: string[] }> {
|
||||||
|
if (!setCookieHeader) return [];
|
||||||
|
|
||||||
|
const cookies: Array<{ name: string; flags: string[] }> = [];
|
||||||
|
|
||||||
|
// Handle multiple Set-Cookie headers (joined by comma-newline)
|
||||||
|
const cookieStrings = setCookieHeader.split(/\n|,(?=\s*\w+=)/);
|
||||||
|
|
||||||
|
for (const cookieStr of cookieStrings) {
|
||||||
|
const trimmed = cookieStr.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const parts = trimmed.split(';').map((p) => p.trim());
|
||||||
|
const nameValue = parts[0]!;
|
||||||
|
const name = nameValue.split('=')[0] || 'unknown';
|
||||||
|
const flags = parts.slice(1).map((f) => f.toLowerCase());
|
||||||
|
|
||||||
|
cookies.push({ name, flags });
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCookies(cookies: Array<{ name: string; flags: string[] }>): CookieCheck[] {
|
||||||
|
return cookies.map((cookie) => ({
|
||||||
|
name: cookie.name,
|
||||||
|
hasHttpOnly: cookie.flags.some((f) => f === 'httponly'),
|
||||||
|
hasSecure: cookie.flags.some((f) => f === 'secure'),
|
||||||
|
hasSameSite: cookie.flags.some((f) => f.startsWith('samesite')),
|
||||||
|
status: 'pass' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSecurityHeaders(url: string): Promise<{
|
||||||
|
headerChecks: SecurityCheck[];
|
||||||
|
cookieChecks: CookieCheck[];
|
||||||
|
}> {
|
||||||
|
console.log(`\n🔒 安全响应头检查 — ${url}\n`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = response.headers;
|
||||||
|
|
||||||
|
const headerChecks: SecurityCheck[] = [
|
||||||
|
{
|
||||||
|
header: 'Content-Security-Policy',
|
||||||
|
expected: '存在(需包含合理策略)',
|
||||||
|
actual: headers.get('content-security-policy'),
|
||||||
|
status: headers.get('content-security-policy') ? 'pass' : 'warn',
|
||||||
|
description: '防止 XSS 和数据注入攻击的核心策略',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'X-Content-Type-Options',
|
||||||
|
expected: 'nosniff',
|
||||||
|
actual: headers.get('x-content-type-options'),
|
||||||
|
status: headers.get('x-content-type-options') === 'nosniff' ? 'pass' : 'fail',
|
||||||
|
description: '防止 MIME 类型嗅探攻击',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'X-Frame-Options',
|
||||||
|
expected: 'DENY 或 SAMEORIGIN',
|
||||||
|
actual: headers.get('x-frame-options'),
|
||||||
|
status: (() => {
|
||||||
|
const val = headers.get('x-frame-options');
|
||||||
|
if (val === 'DENY' || val === 'SAMEORIGIN') return 'pass' as const;
|
||||||
|
return val ? 'warn' as const : 'fail' as const;
|
||||||
|
})(),
|
||||||
|
description: '防止点击劫持(Clickjacking)攻击',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Strict-Transport-Security',
|
||||||
|
expected: '存在(需包含 max-age)',
|
||||||
|
actual: headers.get('strict-transport-security'),
|
||||||
|
status: headers.get('strict-transport-security') ? 'pass' : 'fail',
|
||||||
|
description: '强制 HTTPS 连接,防止 SSL Strip 攻击',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Referrer-Policy',
|
||||||
|
expected: '存在(如 strict-origin-when-cross-origin)',
|
||||||
|
actual: headers.get('referrer-policy'),
|
||||||
|
status: headers.get('referrer-policy') ? 'pass' : 'fail',
|
||||||
|
description: '控制 Referer 头信息的发送策略',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Permissions-Policy',
|
||||||
|
expected: '存在(需配置合理权限)',
|
||||||
|
actual: headers.get('permissions-policy'),
|
||||||
|
status: headers.get('permissions-policy') ? 'pass' : 'warn',
|
||||||
|
description: '限制浏览器 API 权限(摄像头、麦克风等)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'X-XSS-Protection',
|
||||||
|
expected: '存在(0 或 1; mode=block)',
|
||||||
|
actual: headers.get('x-xss-protection'),
|
||||||
|
status: (() => {
|
||||||
|
const val = headers.get('x-xss-protection');
|
||||||
|
if (val === '0' || val === '1; mode=block') return 'pass' as const;
|
||||||
|
return val ? 'warn' as const : 'warn' as const;
|
||||||
|
})(),
|
||||||
|
description: '已废弃的 XSS 过滤器(现代浏览器不再需要)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// CORS check
|
||||||
|
const corsOrigin = headers.get('access-control-allow-origin');
|
||||||
|
if (corsOrigin) {
|
||||||
|
headerChecks.push({
|
||||||
|
header: 'Access-Control-Allow-Origin',
|
||||||
|
expected: '非通配符(或仅对特定来源开放)',
|
||||||
|
actual: corsOrigin,
|
||||||
|
status: corsOrigin === '*' ? 'warn' : 'pass',
|
||||||
|
description: 'CORS 跨域配置',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
headerChecks.push({
|
||||||
|
header: 'Access-Control-Allow-Origin',
|
||||||
|
expected: '无(不暴露 CORS 头)',
|
||||||
|
actual: null,
|
||||||
|
status: 'pass',
|
||||||
|
description: 'CORS 跨域配置(未设置,符合安全预期)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie security check
|
||||||
|
const setCookie = headers.get('set-cookie');
|
||||||
|
const parsedCookies = parseCookies(setCookie);
|
||||||
|
const cookieChecks = checkCookies(parsedCookies);
|
||||||
|
|
||||||
|
return { headerChecks, cookieChecks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTable(checks: SecurityCheck[]): void {
|
||||||
|
// Column widths
|
||||||
|
const headerWidth = 34;
|
||||||
|
const statusWidth = 6;
|
||||||
|
const expectedWidth = 38;
|
||||||
|
const actualWidth = 38;
|
||||||
|
|
||||||
|
const separator = `├${'─'.repeat(headerWidth + 2)}┼${'─'.repeat(statusWidth + 2)}┼${'─'.repeat(expectedWidth + 2)}┼${'─'.repeat(actualWidth + 2)}┤`;
|
||||||
|
const topBorder = `┌${'─'.repeat(headerWidth + 2)}┬${'─'.repeat(statusWidth + 2)}┬${'─'.repeat(expectedWidth + 2)}┬${'─'.repeat(actualWidth + 2)}┐`;
|
||||||
|
const bottomBorder = `└${'─'.repeat(headerWidth + 2)}┴${'─'.repeat(statusWidth + 2)}┴${'─'.repeat(expectedWidth + 2)}┴${'─'.repeat(actualWidth + 2)}┘`;
|
||||||
|
|
||||||
|
const headerRow = `│ ${'Header'.padEnd(headerWidth)} │ ${'Status'.padEnd(statusWidth)} │ ${'Expected'.padEnd(expectedWidth)} │ ${'Actual'.padEnd(actualWidth)} │`;
|
||||||
|
|
||||||
|
console.log(topBorder);
|
||||||
|
console.log(headerRow);
|
||||||
|
console.log(separator);
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const statusIcon = check.status === 'pass' ? '✅ PASS' : check.status === 'fail' ? '❌ FAIL' : '⚠️ WARN';
|
||||||
|
const actual = check.actual ?? '(未设置)';
|
||||||
|
const row = `│ ${check.header.padEnd(headerWidth)} │ ${statusIcon.padEnd(statusWidth + 2)} │ ${check.expected.padEnd(expectedWidth)} │ ${actual.padEnd(actualWidth)} │`;
|
||||||
|
console.log(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bottomBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printCookieTable(cookieChecks: CookieCheck[]): void {
|
||||||
|
if (cookieChecks.length === 0) {
|
||||||
|
console.log('\n🍪 Cookie 安全标记: 无 Cookie 设置\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameWidth = 24;
|
||||||
|
const httpOnlyWidth = 10;
|
||||||
|
const secureWidth = 8;
|
||||||
|
const sameSiteWidth = 10;
|
||||||
|
|
||||||
|
const topBorder = `┌${'─'.repeat(nameWidth + 2)}┬${'─'.repeat(httpOnlyWidth + 2)}┬${'─'.repeat(secureWidth + 2)}┬${'─'.repeat(sameSiteWidth + 2)}┐`;
|
||||||
|
const separator = `├${'─'.repeat(nameWidth + 2)}┼${'─'.repeat(httpOnlyWidth + 2)}┼${'─'.repeat(secureWidth + 2)}┼${'─'.repeat(sameSiteWidth + 2)}┤`;
|
||||||
|
const bottomBorder = `└${'─'.repeat(nameWidth + 2)}┴${'─'.repeat(httpOnlyWidth + 2)}┴${'─'.repeat(secureWidth + 2)}┴${'─'.repeat(sameSiteWidth + 2)}┘`;
|
||||||
|
const headerRow = `│ ${'Cookie Name'.padEnd(nameWidth)} │ ${'HttpOnly'.padEnd(httpOnlyWidth)} │ ${'Secure'.padEnd(secureWidth)} │ ${'SameSite'.padEnd(sameSiteWidth)} │`;
|
||||||
|
|
||||||
|
console.log('\n🍪 Cookie 安全标记\n');
|
||||||
|
console.log(topBorder);
|
||||||
|
console.log(headerRow);
|
||||||
|
console.log(separator);
|
||||||
|
|
||||||
|
for (const cookie of cookieChecks) {
|
||||||
|
const httpOnly = cookie.hasHttpOnly ? '✅' : '❌';
|
||||||
|
const secure = cookie.hasSecure ? '✅' : '❌';
|
||||||
|
const sameSite = cookie.hasSameSite ? '✅' : '❌';
|
||||||
|
const row = `│ ${cookie.name.padEnd(nameWidth)} │ ${httpOnly.padEnd(httpOnlyWidth)} │ ${secure.padEnd(secureWidth)} │ ${sameSite.padEnd(sameSiteWidth)} │`;
|
||||||
|
console.log(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bottomBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(headerChecks: SecurityCheck[], cookieChecks: CookieCheck[]): void {
|
||||||
|
const total = headerChecks.length;
|
||||||
|
const passed = headerChecks.filter((c) => c.status === 'pass').length;
|
||||||
|
const failed = headerChecks.filter((c) => c.status === 'fail').length;
|
||||||
|
const warned = headerChecks.filter((c) => c.status === 'warn').length;
|
||||||
|
|
||||||
|
const cookiePassed = cookieChecks.filter((c) => c.status === 'pass').length;
|
||||||
|
const cookieFailed = cookieChecks.filter((c) => c.status === 'fail').length;
|
||||||
|
|
||||||
|
console.log('\n📊 检查摘要\n');
|
||||||
|
console.log(` 安全头检查:`);
|
||||||
|
console.log(` 总计: ${total}`);
|
||||||
|
console.log(` ✅ 通过: ${passed}`);
|
||||||
|
console.log(` ⚠️ 警告: ${warned}`);
|
||||||
|
console.log(` ❌ 失败: ${failed}`);
|
||||||
|
|
||||||
|
if (cookieChecks.length > 0) {
|
||||||
|
console.log(`\n Cookie 安全标记:`);
|
||||||
|
console.log(` 总计: ${cookieChecks.length}`);
|
||||||
|
console.log(` ✅ 通过: ${cookiePassed}`);
|
||||||
|
console.log(` ❌ 失败: ${cookieFailed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = parseArgs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { headerChecks, cookieChecks } = await checkSecurityHeaders(url);
|
||||||
|
|
||||||
|
// Print header check table
|
||||||
|
console.log('安全头检查结果:');
|
||||||
|
printTable(headerChecks);
|
||||||
|
|
||||||
|
// Print cookie check table
|
||||||
|
printCookieTable(cookieChecks);
|
||||||
|
|
||||||
|
// Print descriptions for failed/warned checks
|
||||||
|
const issues = headerChecks.filter((c) => c.status !== 'pass');
|
||||||
|
if (issues.length > 0) {
|
||||||
|
console.log('\n📝 说明:');
|
||||||
|
for (const issue of issues) {
|
||||||
|
console.log(` • [${issue.status === 'fail' ? '❌' : '⚠️'}] ${issue.header}: ${issue.description}`);
|
||||||
|
if (issue.status === 'fail') {
|
||||||
|
console.log(` 期望: ${issue.expected}`);
|
||||||
|
console.log(` 实际: ${issue.actual ?? '(未设置)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
printSummary(headerChecks, cookieChecks);
|
||||||
|
|
||||||
|
// Determine exit code
|
||||||
|
const hasFailures = headerChecks.some((c) => c.status === 'fail');
|
||||||
|
if (hasFailures) {
|
||||||
|
console.log('\n❌ 存在失败的安全头检查项!');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ 所有关键安全头检查通过!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`\n❌ 检查执行失败: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||||
|
"packageManager": "npm",
|
||||||
|
"reporters": ["html", "clear-text", "progress"],
|
||||||
|
"testRunner": "jest",
|
||||||
|
"coverageAnalysis": "perTest",
|
||||||
|
"mutate": [
|
||||||
|
"src/lib/**/*.ts",
|
||||||
|
"src/hooks/**/*.{ts,tsx}",
|
||||||
|
"src/components/ui/**/*.{ts,tsx}",
|
||||||
|
"src/components/layout/**/*.{ts,tsx}",
|
||||||
|
"src/lib/cms/**/*.ts",
|
||||||
|
"!src/**/*.test.{ts,tsx}",
|
||||||
|
"!src/**/__tests__/**",
|
||||||
|
"!src/lib/constants/**"
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"high": 80,
|
||||||
|
"low": 60,
|
||||||
|
"break": 30
|
||||||
|
},
|
||||||
|
"timeoutMS": 60000,
|
||||||
|
"cleanTempDir": true,
|
||||||
|
"symlinkNodeModules": true,
|
||||||
|
"ignorePatterns": [
|
||||||
|
".eslintrc.json",
|
||||||
|
".eslintrc",
|
||||||
|
"eslint.config.mjs",
|
||||||
|
".lintstagedrc.js",
|
||||||
|
"commitlint.config.js",
|
||||||
|
"next.config.mjs",
|
||||||
|
"postcss.config.js",
|
||||||
|
"tailwind.config.js",
|
||||||
|
"tsconfig.json",
|
||||||
|
"public/",
|
||||||
|
"dist/",
|
||||||
|
".next/",
|
||||||
|
"prisma/",
|
||||||
|
"scripts/",
|
||||||
|
"e2e/",
|
||||||
|
".claude/",
|
||||||
|
".trae/",
|
||||||
|
".git/",
|
||||||
|
".husky/",
|
||||||
|
"node_modules/",
|
||||||
|
"coverage/"
|
||||||
|
],
|
||||||
|
"jest": {
|
||||||
|
"configFile": "config/test/jest.config.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user