Compare commits

...

10 Commits

6 changed files with 153 additions and 22 deletions
+8
View File
@@ -0,0 +1,8 @@
ARG CI_IMAGE=mcr.microsoft.com/playwright:v1.51.1-jammy
FROM ${CI_IMAGE}
ARG NPM_REGISTRY=https://registry.npmmirror.com
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci --prefer-offline --no-audit --progress=false
Vendored
+141 -20
View File
@@ -1,8 +1,20 @@
pipeline { pipeline {
agent any agent any
options {
skipDefaultCheckout(true)
}
parameters {
booleanParam(name: 'RUN_FULL_E2E', defaultValue: false, description: '是否在部署后额外执行全量 E2E(失败仅标记 UNSTABLE')
booleanParam(name: 'RUN_COVERAGE', defaultValue: false, description: '是否在主链路执行覆盖率测试(关闭可提速)')
}
environment { environment {
APP_NAME = 'no-whatever' APP_NAME = 'no-whatever'
CI_IMAGE = 'mcr.microsoft.com/playwright:v1.51.1-jammy'
NPM_REGISTRY = 'https://registry.npmmirror.com'
BUILDX_CACHE_DIR = '.buildx-cache'
} }
triggers { triggers {
@@ -23,39 +35,67 @@ pipeline {
} }
stage('Prepare Test Images') { stage('Prepare Test Images') {
options {
timeout(time: 15, unit: 'MINUTES')
}
steps { steps {
sh ''' retry(2) {
docker pull node:20-bookworm sh '''
docker pull mcr.microsoft.com/playwright:v1.51.1-jammy set -e
'''
if docker image inspect ${CI_IMAGE} >/dev/null 2>&1; then
echo "${CI_IMAGE} already exists, skip pull"
else
docker pull ${CI_IMAGE}
fi
'''
}
} }
} }
stage('Quality Gate') { stage('Prepare CI Deps Image') {
steps {
sh '''
set -e
cid=$(docker create node:20-bookworm sh -lc "cd /workspace && npm ci && npm run lint && npx tsc --noEmit && npm run test:coverage")
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp . "$cid":/workspace
docker start -a "$cid"
'''
}
}
stage('E2E Gate') {
options { options {
timeout(time: 20, unit: 'MINUTES') timeout(time: 20, unit: 'MINUTES')
} }
steps { steps {
script {
env.LOCK_HASH = sh(script: "sha256sum package-lock.json | cut -d ' ' -f1", returnStdout: true).trim()
env.CI_DEPS_IMAGE = "${APP_NAME}-ci-deps:${env.LOCK_HASH}"
}
sh ''' sh '''
set -e set -e
cid=$(docker create --ipc=host mcr.microsoft.com/playwright:v1.51.1-jammy sh -lc "cd /workspace && npm ci && npm run test:e2e") if docker image inspect "${CI_DEPS_IMAGE}" >/dev/null 2>&1; then
echo "deps image cache hit: ${CI_DEPS_IMAGE}"
else
echo "deps image cache miss, building: ${CI_DEPS_IMAGE}"
docker build \
--build-arg CI_IMAGE=${CI_IMAGE} \
--build-arg NPM_REGISTRY=${NPM_REGISTRY} \
-f Dockerfile.ci-deps \
-t "${CI_DEPS_IMAGE}" .
fi
'''
}
}
stage('CI Gate (Lint + Unit + Smoke E2E)') {
options {
timeout(time: 30, unit: 'MINUTES')
}
steps {
sh '''
set -e
rm -rf playwright-report test-results
cid=$(docker create --ipc=host \
-e HOME=/tmp \
-e RUN_COVERAGE=${RUN_COVERAGE} \
${CI_DEPS_IMAGE} \
sh -lc 'set -e; cd /workspace; npx prisma generate; npm run lint; if [ "$RUN_COVERAGE" = "true" ]; then npm run test:coverage; else npm run test; fi; npm run test:e2e:smoke')
cleanup() { cleanup() {
docker rm -f "$cid" >/dev/null 2>&1 || true docker rm -f "$cid" >/dev/null 2>&1 || true
} }
trap cleanup EXIT trap cleanup EXIT
docker cp . "$cid":/workspace git archive --format=tar HEAD | docker cp - "$cid":/workspace
set +e set +e
docker start -a "$cid" docker start -a "$cid"
status=$? status=$?
@@ -77,7 +117,50 @@ pipeline {
withCredentials([ withCredentials([
string(credentialsId: 'amap-api-key', variable: 'AMAP_KEY') string(credentialsId: 'amap-api-key', variable: 'AMAP_KEY')
]) { ]) {
sh "docker build --build-arg NEXT_PUBLIC_AMAP_API_KEY=${AMAP_KEY} -t ${APP_NAME}:${BUILD_NUMBER} -t ${APP_NAME}:latest ." sh '''
set -e
CACHE_DIR="${BUILDX_CACHE_DIR}"
CACHE_NEW_DIR="${BUILDX_CACHE_DIR}-new"
rm -rf "$CACHE_NEW_DIR"
if docker buildx version >/dev/null 2>&1; then
echo "using docker buildx with local cache"
docker buildx create --name "${APP_NAME}-builder" --use >/dev/null 2>&1 || true
docker buildx use "${APP_NAME}-builder" >/dev/null 2>&1 || true
mkdir -p "$CACHE_DIR"
set +e
docker buildx build \
--load \
--build-arg NEXT_PUBLIC_AMAP_API_KEY="$AMAP_KEY" \
--cache-from type=local,src="$CACHE_DIR" \
--cache-to type=local,dest="$CACHE_NEW_DIR",mode=max \
-t "${APP_NAME}:${BUILD_NUMBER}" \
-t "${APP_NAME}:latest" \
.
bx_status=$?
set -e
if [ $bx_status -eq 0 ]; then
rm -rf "$CACHE_DIR"
mv "$CACHE_NEW_DIR" "$CACHE_DIR"
else
echo "buildx build failed, fallback to classic docker build"
docker build \
--build-arg NEXT_PUBLIC_AMAP_API_KEY="$AMAP_KEY" \
-t "${APP_NAME}:${BUILD_NUMBER}" \
-t "${APP_NAME}:latest" \
.
fi
else
echo "docker buildx unavailable, fallback to classic docker build"
docker build \
--build-arg NEXT_PUBLIC_AMAP_API_KEY="$AMAP_KEY" \
-t "${APP_NAME}:${BUILD_NUMBER}" \
-t "${APP_NAME}:latest" \
.
fi
'''
} }
} }
} }
@@ -107,6 +190,44 @@ pipeline {
} }
} }
} }
stage('Full E2E (Optional, Non-Blocking)') {
when {
expression { return params.RUN_FULL_E2E }
}
options {
timeout(time: 30, unit: 'MINUTES')
}
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE') {
sh '''
set -e
rm -rf playwright-report-full test-results-full
cid=$(docker create --ipc=host \
-e HOME=/tmp \
${CI_DEPS_IMAGE} \
sh -lc 'set -e; cd /workspace; npx prisma generate; npm run test:e2e')
cleanup() {
docker rm -f "$cid" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git archive --format=tar HEAD | docker cp - "$cid":/workspace
set +e
docker start -a "$cid"
status=$?
set -e
docker cp "$cid":/workspace/playwright-report ./playwright-report-full 2>/dev/null || true
docker cp "$cid":/workspace/test-results ./test-results-full 2>/dev/null || true
exit $status
'''
}
}
post {
always {
archiveArtifacts artifacts: 'playwright-report-full/**,test-results-full/**', allowEmptyArchive: true
}
}
}
} }
post { post {
+1 -1
View File
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
test("首页模式卡片可正确导航", async ({ page }) => { test("@smoke 首页模式卡片可正确导航", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.getByRole("heading", { name: "NoWhatever" })).toBeVisible(); await expect(page.getByRole("heading", { name: "NoWhatever" })).toBeVisible();
+1 -1
View File
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
test("Panic 手动加入房间会规范化房间号并发起 join 请求", async ({ page }) => { test("@smoke Panic 手动加入房间会规范化房间号并发起 join 请求", async ({ page }) => {
const joinBodies: Array<{ userId?: string }> = []; const joinBodies: Array<{ userId?: string }> = [];
await page.route("**/api/room/*/join", async (route) => { await page.route("**/api/room/*/join", async (route) => {
+1
View File
@@ -11,6 +11,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e:install": "playwright install chromium", "test:e2e:install": "playwright install chromium",
"test:e2e:smoke": "playwright test --grep @smoke",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed" "test:e2e:headed": "playwright test --headed"
}, },
+1
View File
@@ -8,6 +8,7 @@ const shared = {
env: { env: {
AMAP_API_KEY: "test-amap-key", AMAP_API_KEY: "test-amap-key",
DEEPSEEK_API_KEY: "test-deepseek-key", DEEPSEEK_API_KEY: "test-deepseek-key",
JWT_SECRET: "test-jwt-secret",
}, },
} as const; } as const;