Build advertiser console with scoped page URLs and runner-based deployment tests
Advertiser console CI and test deployment / build-deploy (push) Has been cancelled

This commit is contained in:
2026-09-16 15:00:52 +08:00
parent 0f8d3ddbf5
commit d64eb72ff2
18 changed files with 3331 additions and 130 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
.env.*
!.env.example
+2 -1
View File
@@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000
# Leave empty to use Vite's local API proxy / production Nginx proxy.
VITE_API_BASE_URL=
+55
View File
@@ -0,0 +1,55 @@
name: Advertiser console CI and test deployment
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: adcloud-advertiser-console-test
cancel-in-progress: false
jobs:
build-deploy:
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
runs-on: ubuntu-latest
container:
image: docker:27-cli
timeout-minutes: 30
env:
SOURCE_SERVER: ${{ gitea.server_url }}
SOURCE_REPOSITORY: ${{ gitea.repository }}
SOURCE_REVISION: ${{ gitea.sha }}
DEPLOY_PORT: ${{ vars.ADCLOUD_ADVERTISER_PORT || '18081' }}
BASE_URL: ${{ vars.ADCLOUD_ADVERTISER_PUBLIC_URL || 'http://47.76.92.159:18081' }}
API_UPSTREAM: ${{ vars.ADCLOUD_ADVERTISER_API_UPSTREAM || 'http://host.docker.internal:18080' }}
steps:
- name: Prepare runner tools
run: apk add --no-cache python3 bash curl
- name: Fetch the exact repository revision
env:
SOURCE_TOKEN: ${{ secrets.GITEA_TOKEN || gitea.token }}
run: |
python3 - <<'PY'
import io, os, shutil, tarfile, tempfile, urllib.request
from pathlib import Path
server = os.environ['SOURCE_SERVER'].rstrip('/')
repo = os.environ['SOURCE_REPOSITORY']
revision = os.environ['SOURCE_REVISION']
request = urllib.request.Request(server + '/api/v1/repos/' + repo + '/archive/' + revision + '.tar.gz', headers={'Authorization': 'token ' + os.environ['SOURCE_TOKEN']})
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read()
with tempfile.TemporaryDirectory() as directory:
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
archive.extractall(directory, filter='data')
roots = list(Path(directory).iterdir())
if len(roots) != 1 or not roots[0].is_dir():
raise RuntimeError('Unexpected repository archive structure')
shutil.copytree(roots[0], Path.cwd(), dirs_exist_ok=True)
print('Checked out exact commit:', revision)
PY
- name: Typecheck and build image
run: docker build --tag "adcloud-advertiser:$SOURCE_REVISION" .
- name: Build browser test image
run: docker build --target browser-tests --tag "adcloud-advertiser:$SOURCE_REVISION-tests" .
- name: Deploy test console and verify
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
run: bash deploy/deploy.sh
+2
View File
@@ -4,3 +4,5 @@ dist/
.env
.env.*
!.env.example
playwright-report/
test-results/
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM mcr.microsoft.com/playwright:v1.58.2-noble AS browser-tests
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tests ./tests
COPY src/navigation.ts ./src/navigation.ts
COPY playwright.config.ts ./
CMD ["npm", "run", "test:ci"]
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY deploy/default.conf.template /etc/nginx/templates/default.conf.template
ENV API_UPSTREAM=http://host.docker.internal:18080
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
+31 -31
View File
@@ -1,45 +1,45 @@
# AdCloud · 广告商控制台
# AdCloud · 广告主控制台
管理广告、投放计划与效果数据。 React + TypeScript + Vite 独立前端项目。
React + TypeScript + Vite,独立 AdCloud 品牌的广告主网站。前后端发布与测试全部通过 Gitea Actions 执行。
## 启动
## 页面和 URL
需要 Node.js 20.19+ 或 22.12+。
| 页面 | URL |
| ----------------------- | ----------------------------------------------------------------------------- |
| Landing / 登录 / 注册 | `/`、`/login`、`/register` |
| 工作台 | `/workbench` |
| 全部主账户 / 指定主账户 | `/account-manage`、`/account-manage/:accountId` |
| 新建 / 编辑主账户 | `/account-manage/new`、`/account-manage/:accountId/edit` |
| 投放子账户 | `/account-manage/:accountId/promotion-manage/:promotionId` |
| 新建 / 编辑子账户 | 主账户路径下的 `/promotion-manage/new`、`/promotion-manage/:promotionId/edit` |
| 新建项目 | 子账户路径下的 `/create-project/:step` |
| 编辑项目 | 子账户路径下的 `/projects/:projectId/edit/:step` |
```sh
npm ci
cp .env.example .env
npm run dev
```
`step` 依次为 `goal`、`product`、`audience`、`budget`、`tracking`、`name`,对应营销目标、推广产品、受众、排期预算、监测链接和项目名称。步骤切换产生浏览器历史记录。已保存项目可复制链接、在新标签页打开、刷新恢复和前进后退;未保存的表单编辑仍是临时状态。
开发地址:`http://localhost:5173`。通过 `VITE_API_BASE_URL` 配置共享后端地址,默认 `http://localhost:8000`。
`VITE_` 配置会进入浏览器代码,不用于保存服务器密钥。
`accountId` 是主账户,`promotionId` 是进入投放工作区的子账户 ID,`projectId` 是项目 ID。`promotion-manage` 是功能名,不是额外的数据实体。URL 层级会与后端归属校验相互验证;非法或无权限的 ID 不会回退到其他账户。未登录访问受保护页面会跳转登录,登录后回到原链接。
```sh
npm run typecheck
npm run build
npm run preview
```
注册创建独立广告主用户、主账户及默认子账户;所有账户和已保存项目进入后端关系数据库。终端流量用户的 `identity` 表和广告主身份完全分离。
## 当前范围
## Gitea CI/CD
提供导航、空数据概览、内存中的广告草稿预览,以及共享后端的 `/health` 状态检查。
草稿刷新即清空;尚未实现登录、持久化、广告发布或业务报表。界面中的 `—` 表示尚无真实数据。
- `.gitea/workflows/deploy.yml`:main 推送或手动触发,复用 `ubuntu-latest` runner(现有 Docker 宿主机)。
- Docker 多阶段构建:Node 22 执行 `npm ci`、TypeScript 检查和 Vite 打包;Nginx 提供页面与同源广告主 API 代理。
- Playwright 测试镜像运行在 runner,访问真实部署的 `BASE_URL`。覆盖注册登录、主子账户 URL、六步项目创建、修改、深链接刷新、前进后退、重新登录返回、错误账户和移动端工作台。
- 独立容器 `adcloud-advertiser-console`、并发组与默认端口 `18081`,后端继续使用 `18080`。
- 先验证候选容器和认证 API,再替换前端;公开端口或浏览器验证失败恢复上一个前端容器。
- 同仓库 PR 只构建;外部 fork PR 不在拥有 Docker socket 的 runner 执行。
后续业务范围:广告创建与审核、素材管理、预算及投放计划、投放报表。
仓库变量:`ADCLOUD_ADVERTISER_PORT`(18081)、`ADCLOUD_ADVERTISER_API_UPSTREAM`(`http://host.docker.internal:18080`)、`ADCLOUD_ADVERTISER_PUBLIC_URL`(`http://47.76.92.159:18081`)。拉取源码优先使用现有 `GITEA_TOKEN` secret,否则使用 Actions 自动提供的短期仓库 token。无需把密码写入仓库。
## 项目边界
先部署后端的 `/v1/advertiser` 接口,并在其 CORS 中允许前端公网 origin。测试 HTTP 环境使用 `COOKIE_SECURE=false`;生产需要 HTTPS 和 `COOKIE_SECURE=true`。Nginx 支持所有前端深链接 fallback,API 路径单独代理,API 404 不会伪装成 HTML。
- `adcloud-advertiser-console`:广告商投放、预算和效果管理。
- `adcloud-developer-console`:开发者应用、广告位、接入和收益管理。
- `adcloud-server`:共享后端,统一负责身份认证、角色权限、埋点、广告投放和报表 API。
- `adcloud-react-sdk`:提供给第三方应用集成的 SDK。
CI 浏览器验证使用带 `CI` 标记的测试广告主和项目,保留在测试环境,不能视为真实客户数据。依赖数据库的后端集成阶段不允许跳过测试。
两套控制台独立构建和部署。角色权限与数据隔离需要由后端校验。
本项目目前只调用 `/health`;后端业务接口未定义,之后可按 OpenAPI 生成共用管理 API 客户端。
## 开发配置
后端默认 CORS 允许 3000、5173 端口。要同时运行两套控制台,在 `adcloud-server` 目录运行:
Node.js 22.12+。默认 `VITE_API_BASE_URL` 留空,Vite 开发代理指向 `127.0.0.1:8000`;部署使用 Nginx 同源代理。不要在 `VITE_` 配置放密钥。依据当前工作约定,测试与发布验证在 Gitea runner 和部署服务器完成,而非本机。
```sh
CORS_ORIGINS=http://localhost:5173,http://localhost:5174 uv run uvicorn adcloud_server.main:app --reload
```
## 首版边界
项目只有“草稿”和“已暂停”,没有实际投放、资金消耗或结算;报表展示空态。尚无成员邀请与细分角色、邮箱验证、密码找回、资质审核、素材和投放引擎。监测链接当前只保存配置,不主动请求外部地址。
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location = /healthz { access_log off; return 200 'ok'; }
location /v1/advertiser/ {
proxy_pass ${API_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
add_header Cache-Control "no-store" always;
}
location /assets/ { try_files $uri =404; expires 1y; add_header Cache-Control "public, immutable"; }
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
name=adcloud-advertiser-console
image="adcloud-advertiser:${SOURCE_REVISION:?}"
port="${DEPLOY_PORT:-18081}"
[[ "$port" =~ ^[0-9]+$ ]] && (( port > 0 && port < 65536 )) || { echo 'Invalid deploy port'; exit 1; }
case "${API_UPSTREAM:?}" in http://*|https://*) ;; *) echo 'Invalid API upstream'; exit 1;; esac
# A unique candidate verifies the proxy before replacing the published service.
candidate="${name}-candidate-${SOURCE_REVISION:0:12}"
cleanup() { docker rm -f "$candidate" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker run -d --name "$candidate" --add-host host.docker.internal:host-gateway -e API_UPSTREAM "$image"
ready=false
for attempt in {1..30}; do
if docker exec "$candidate" wget -q -O /dev/null http://127.0.0.1/healthz; then ready=true; break; fi
sleep 1
done
[[ "$ready" == true ]] || { echo 'Candidate failed health check'; exit 1; }
# Auth endpoint should exist and reject unauthenticated requests, rather than 404/502.
docker exec "$candidate" sh -c 'wget -S -O /dev/null http://127.0.0.1/v1/advertiser/me 2>&1 | grep "401 Unauthorized"'
old=false
if docker container inspect "$name" >/dev/null 2>&1; then
docker stop "$name"
docker rename "$name" "${name}-previous"
old=true
fi
rollback() {
docker rm -f "$name" >/dev/null 2>&1 || true
if [[ "$old" == true ]]; then docker rename "${name}-previous" "$name"; docker start "$name"; fi
}
if ! docker run -d --name "$name" --restart unless-stopped --add-host host.docker.internal:host-gateway -p "$port:80" -e API_UPSTREAM "$image"; then rollback; exit 1; fi
ready=false
for attempt in {1..30}; do
if docker exec "$name" wget -q -O /dev/null http://127.0.0.1/healthz; then ready=true; break; fi
sleep 1
done
if [[ "$ready" != true ]]; then rollback; exit 1; fi
if ! docker exec "$candidate" wget -q -O /dev/null "http://host.docker.internal:$port/healthz"; then
echo "Published port probe failed; restoring previous deployment"
rollback
exit 1
fi
# Browser tests run on this runner against the actual deployed public URL.
if ! docker run --rm --network host --ipc=host -e BASE_URL "adcloud-advertiser:$SOURCE_REVISION-tests"; then
echo 'Deployed browser checks failed; restoring previous frontend'
rollback
exit 1
fi
if [[ "$old" == true ]]; then docker rm "${name}-previous"; fi
echo "Advertiser console deployed on port $port, revision $SOURCE_REVISION"
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AdCloud · 广告商控制台</title>
<title>AdCloud · 广告主控制台</title>
</head>
<body>
<div id="root"></div>
+64
View File
@@ -12,6 +12,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0",
@@ -477,6 +478,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
@@ -975,6 +992,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -7,13 +7,15 @@
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:ci": "playwright test"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0",
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 60000,
expect: { timeout: 10000 },
reporter: "list",
use: {
baseURL: process.env.BASE_URL,
browserName: "chromium",
viewport: { width: 1440, height: 1000 },
trace: "off",
},
});
+1378 -54
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
export interface User {
id: string;
name: string;
email: string;
}
export interface Account {
id: string;
parent_id: string | null;
name: string;
company: string;
industry: string;
}
export interface Project {
id?: string;
name: string;
objective: string;
ad_type: string;
scene: string;
budget: number;
bid: number;
audience: string;
region: string;
start_date: string;
end_date: string;
landing_url: string;
status: string;
product_name: string;
conversion_goal: string;
tracking_url: string;
}
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
export async function api<T>(
path: string,
method = "GET",
body?: unknown,
): Promise<T> {
const response = await fetch(`${base}/v1/advertiser${path}`, {
method,
credentials: "include",
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (response.status === 401 && !["/login", "/me"].includes(path))
window.dispatchEvent(new Event("session-expired"));
throw new Error(
typeof data.detail === "string"
? data.detail
: response.status === 422
? "请检查必填项、预算及投放日期"
: "服务暂时不可用,请稍后重试",
);
}
return response.status === 204 ? (undefined as T) : response.json();
}
export const emptyProject = (): Project => ({
name: "",
objective: "销售线索",
ad_type: "通投广告",
scene: "短视频与图文",
budget: 300,
bid: 30,
audience: "智能推荐",
region: "全国",
start_date: new Date().toLocaleDateString("en-CA"),
end_date: "",
landing_url: "",
status: "草稿",
product_name: "",
conversion_goal: "获取线索",
tracking_url: "",
});
+119
View File
@@ -0,0 +1,119 @@
import { useSyncExternalStore } from "react";
export const steps = [
"营销目标与场景",
"推广产品与目标",
"受众倾向与过滤",
"排期与预算出价",
"监测链接",
"项目名称",
];
export const stepSlugs = [
"goal",
"product",
"audience",
"budget",
"tracking",
"name",
];
export type Route = {
kind:
| "landing"
| "login"
| "register"
| "workbench"
| "accounts"
| "account-form"
| "promotion"
| "project"
| "not-found";
accountId?: string;
promotionId?: string;
projectId?: string;
step?: number;
edit?: boolean;
};
export function parseRoute(pathname: string): Route {
const parts = pathname.split("/").filter(Boolean);
if (!parts.length) return { kind: "landing" };
if (
parts.length === 1 &&
["login", "register", "workbench"].includes(parts[0])
)
return { kind: parts[0] as Route["kind"] };
if (parts[0] !== "account-manage") return { kind: "not-found" };
if (parts.length === 1) return { kind: "accounts" };
if (parts.length === 2 && parts[1] === "new") return { kind: "account-form" };
const accountId = parts[1];
if (parts.length === 2) return { kind: "accounts", accountId };
if (parts.length === 3 && parts[2] === "edit")
return { kind: "account-form", accountId, edit: true };
if (parts[2] !== "promotion-manage") return { kind: "not-found" };
if (parts.length === 4 && parts[3] === "new")
return { kind: "account-form", accountId };
const promotionId = parts[3];
if (parts.length === 4) return { kind: "promotion", accountId, promotionId };
if (parts.length === 5 && parts[4] === "edit")
return { kind: "account-form", accountId, promotionId, edit: true };
if (
parts[4] === "create-project" &&
parts.length === 6 &&
stepSlugs.includes(parts[5])
)
return {
kind: "project",
accountId,
promotionId,
step: stepSlugs.indexOf(parts[5]),
};
if (
parts[4] === "projects" &&
parts[6] === "edit" &&
parts.length === 8 &&
stepSlugs.includes(parts[7])
)
return {
kind: "project",
accountId,
promotionId,
projectId: parts[5],
step: stepSlugs.indexOf(parts[7]),
};
return { kind: "not-found" };
}
export const accountPath = (id?: string) =>
`/account-manage${id ? `/${encodeURIComponent(id)}` : ""}`;
export const promotionPath = (parent: string, child: string) =>
`${accountPath(parent)}/promotion-manage/${encodeURIComponent(child)}`;
export const projectPath = (
parent: string,
child: string,
id?: string,
step = 0,
) =>
`${promotionPath(parent, child)}/${id ? `projects/${encodeURIComponent(id)}/edit` : "create-project"}/${stepSlugs[step]}`;
export function navigate(url: string, replace = false) {
if (url === window.location.pathname + window.location.search) return;
window.history[replace ? "replaceState" : "pushState"](null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
window.scrollTo(0, 0);
}
function subscribe(callback: () => void) {
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
}
export function useLocation() {
return useSyncExternalStore(
subscribe,
() => window.location.pathname + window.location.search,
);
}
export function safeReturnTo(search: string) {
const target = new URLSearchParams(search).get("returnTo") || "/workbench";
const parsed = parseRoute(target.split("?")[0]);
return target.startsWith("/") &&
!target.startsWith("//") &&
!["login", "register", "landing", "not-found"].includes(parsed.kind)
? target
: "/workbench";
}
+1334 -40
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
import { test, expect } from "@playwright/test";
import {
parseRoute,
projectPath,
promotionPath,
safeReturnTo,
stepSlugs,
} from "../src/navigation";
test("URL hierarchy and wizard steps resolve independently", () => {
for (const [step] of stepSlugs.entries()) {
expect(
parseRoute(projectPath("main-id", "child-id", "project-id", step)),
).toEqual({
kind: "project",
accountId: "main-id",
promotionId: "child-id",
projectId: "project-id",
step,
});
expect(
parseRoute(projectPath("main-id", "child-id", undefined, step)),
).toEqual({
kind: "project",
accountId: "main-id",
promotionId: "child-id",
step,
});
}
expect(parseRoute(promotionPath("main-id", "child-id"))).toEqual({
kind: "promotion",
accountId: "main-id",
promotionId: "child-id",
});
expect(
parseRoute(
"/account-manage/main-id/promotion-manage/child-id/create-project/unknown",
).kind,
).toBe("not-found");
expect(safeReturnTo("?returnTo=https://external.example")).toBe("/workbench");
expect(safeReturnTo("?returnTo=//external.example")).toBe("/workbench");
});
test("deployed console supports registration, scoped URLs, editing, refresh and login return", async ({
page,
context,
}) => {
const email = `ci-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
const password = `CI-${crypto.randomUUID()}`;
await page.goto("/register");
await page.getByLabel("联系人姓名", { exact: true }).fill("CI 自动验证");
await page
.getByLabel("企业 / 主账户名称", { exact: true })
.fill("CI 广告主验收");
await page.getByLabel("邮箱", { exact: true }).fill(email);
await page.getByLabel("密码", { exact: true }).fill(password);
await page
.getByRole("button", { name: "注册并进入工作台", exact: true })
.click();
await expect(page).toHaveURL(/\/workbench$/);
await page.getByRole("button", { name: "账户管理", exact: true }).click();
await expect(page).toHaveURL(/\/account-manage$/);
await page.getByRole("link", { name: "CI 广告主验收", exact: true }).click();
await expect(page).toHaveURL(/\/account-manage\/[^/]+$/);
const parentUrl = new URL(page.url()).pathname;
await page
.getByRole("button", { name: "+ 新建子账户", exact: true })
.click();
await expect(page).toHaveURL(/\/promotion-manage\/new$/);
await page.getByLabel("账户名称", { exact: true }).fill("CI 第二投放账户");
await page.getByRole("button", { name: "保存账户", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`${parentUrl}$`));
await page
.getByRole("link", { name: "CI 第二投放账户", exact: true })
.click();
await expect(page).toHaveURL(/\/promotion-manage\/[^/]+$/);
const promotionUrl = new URL(page.url()).pathname;
await page.getByRole("button", { name: "+ 新建项目", exact: true }).click();
await expect(page).toHaveURL(/\/create-project\/goal$/);
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
await expect(page).toHaveURL(/\/create-project\/product$/);
await page.getByLabel("推广产品或服务", { exact: true }).fill("秋季新品");
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
await page.getByLabel("投放地域", { exact: true }).fill("上海");
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
await page.getByLabel("日预算(元,最低 100)", { exact: true }).fill("500");
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
await page
.getByLabel("落地页地址(可选)", { exact: true })
.fill("https://example.com/product");
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
await page.getByLabel("项目名称", { exact: true }).fill("CI 六步创建项目");
await page.getByRole("button", { name: "保存项目", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`${promotionUrl}$`));
await page
.getByRole("link", { name: "CI 六步创建项目", exact: true })
.click();
await expect(page).toHaveURL(/\/projects\/[^/]+\/edit\/goal$/);
const editRoot = new URL(page.url()).pathname.replace(/goal$/, "");
await page.goto(editRoot + "name");
await expect(page.getByLabel("项目名称", { exact: true })).toHaveValue(
"CI 六步创建项目",
);
await page.getByLabel("项目名称", { exact: true }).fill("CI 已修改项目");
await page.getByRole("button", { name: "保存项目", exact: true }).click();
await expect(
page.getByRole("link", { name: "CI 已修改项目", exact: true }),
).toBeVisible();
await page.goto(editRoot + "budget");
await page.reload();
await expect(
page.getByLabel("日预算(元,最低 100)", { exact: true }),
).toHaveValue("500");
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
await expect(page).toHaveURL(/\/edit\/tracking$/);
await page.goBack();
await expect(page).toHaveURL(/\/edit\/budget$/);
await page.goForward();
await expect(page).toHaveURL(/\/edit\/tracking$/);
await page.getByRole("button", { name: "退出", exact: true }).click();
await page.goto(editRoot + "name");
await expect(page).toHaveURL(/\/login\?returnTo=/);
await page.getByLabel("邮箱", { exact: true }).fill(email);
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`${editRoot}name$`));
await expect(page.getByLabel("项目名称", { exact: true })).toHaveValue(
"CI 已修改项目",
);
await page.goto(promotionUrl.replace(/[^/]+$/, "invalid-id"));
await expect(
page.getByRole("heading", { name: "页面或账户不存在" }),
).toBeVisible();
await page.goto(promotionUrl);
await page
.getByRole("combobox", { name: "当前投放子账户" })
.selectOption({ label: "CI 广告主验收 · 默认投放账户" });
await expect(page).not.toHaveURL(new RegExp(`${promotionUrl}$`));
await expect(
page.getByRole("heading", { name: "还没有推广项目" }),
).toBeVisible();
// A second tab can open the exact project and restore its account context.
const other = await context.newPage();
await other.goto(editRoot + "name");
await expect(other.getByLabel("项目名称", { exact: true })).toHaveValue(
"CI 已修改项目",
);
await other.close();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/workbench");
await expect(
page.getByRole("heading", { name: "你好,CI 自动验证" }),
).toBeVisible();
});
+3 -2
View File
@@ -1,3 +1,4 @@
import { defineConfig } from "vite";
export default defineConfig({ server: { port: 5173, strictPort: true } });
export default defineConfig({
server: { proxy: { "/v1": "http://127.0.0.1:8000" } },
});