Merge pull request #2332 from pikasTech/fix/apistate-web-smoke
feat: 收口 ApiState NC01 受控 Web smoke
This commit is contained in:
@@ -67,6 +67,11 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web
|
||||
- 产品 target、semantic origin、认证 sourceRef、登录合同、selector、保存请求、视口和证据上限只从产品 owning YAML 读取;
|
||||
- 结果只保留有界 DOM/网络摘要、report SHA、screenshot SHA、presence 与 fingerprint,禁止输出账号、密码或 cookie;
|
||||
- disabled target 必须拒绝执行,不得用 CLI URL 或凭据参数绕过。
|
||||
- ApiState 多页面只读 smoke:
|
||||
- 使用 `web-probe product-smoke --product apistate --target <target> --profile <profile>`;
|
||||
- 公网 URL、runner、登录字段、Cookie、Secret target key、页面路径、selector、视口和证据上限只从 Sub2Rank owning YAML 读取;
|
||||
- 同一次命令必须完成账号评分、用户用量和额度抽奖页面的 DOM、网络与截图验收;
|
||||
- smoke 不点击评分刷新或抽奖,不允许除登录外的写请求。
|
||||
- 沉淀后的 typed command 必须复用 observer start 时选中的 semantic origin;不得在 command 内嵌 URL/IP,也不得为 internal/public 各复制一份 command。
|
||||
- Workbench 性能卡顿调查优先用 `observe command --type performanceCapture` + `observe analyze` + `observe collect --view performance-summary`;`performance-summary` 必须保持首屏 bounded,只输出 LongTask/LoAF/event-loop gap 摘要、CPU profile hotspots/stacks 和 sourceFiles,下钻完整 profile/report 时再显式读取 artifact。
|
||||
- Project Management/MDTODO closeout 必须区分 `control` 页和被动 `observer` 页:显式 `observe command` 的 command result、control URL 和对应截图是用户动作证据;observer 周期刷新或 stop 后根路由空态只能作为对照信号,不能覆盖 command result。涉及报告的验收要同时记录 `reportPreviewVisible`、`reportFullscreenVisible`、报告 deep link 和截图 SHA。
|
||||
|
||||
@@ -9,6 +9,46 @@ metadata:
|
||||
defaults:
|
||||
targetId: NC01
|
||||
|
||||
webProbe:
|
||||
enabled: true
|
||||
runner:
|
||||
node: NC01
|
||||
lane: v03
|
||||
origin:
|
||||
browserProxyMode: direct
|
||||
authentication:
|
||||
path: /api/login
|
||||
usernameField: username
|
||||
passwordField: password
|
||||
passwordTargetKey: APISTATE_WEB_PASSWORD
|
||||
cookieNames:
|
||||
- apistate_session
|
||||
defaultSmokeProfile: operations
|
||||
smokeProfiles:
|
||||
operations:
|
||||
navigationTimeoutMs: 30000
|
||||
commandTimeoutSeconds: 180
|
||||
settleMs: 500
|
||||
viewport:
|
||||
width: 1920
|
||||
height: 1080
|
||||
pages:
|
||||
- id: scores
|
||||
path: /scores
|
||||
readySelector: '#score-body tr'
|
||||
screenshotName: apistate-scores.png
|
||||
- id: ranking
|
||||
path: /ranking
|
||||
readySelector: '#ranking-body tr'
|
||||
screenshotName: apistate-ranking.png
|
||||
- id: lottery
|
||||
path: /lottery
|
||||
readySelector: '#record-list li'
|
||||
screenshotName: apistate-lottery.png
|
||||
outputLimits:
|
||||
failures: 8
|
||||
network: 24
|
||||
|
||||
application:
|
||||
repository: pikasTech/apistate
|
||||
remote: https://github.com/pikasTech/apistate.git
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# R5.3 任务报告
|
||||
|
||||
## 目标与边界
|
||||
|
||||
- 关联 [UniDesk #2325](https://github.com/pikasTech/unidesk/issues/2325)。
|
||||
- 在 UniDesk 增加 ApiState YAML 驱动的 typed `web-probe product-smoke`。
|
||||
- 不修改 ApiState 或 Sub2API 业务代码、账号、runtime 和生产运行面。
|
||||
- 不触发 PipelineRun、mirror 或 Argo。
|
||||
- 公网拓扑固定为 `client -> NC01 shared public-edge -> sub2rank.platform-infra.svc.cluster.local:8080`。
|
||||
- PK01 仅是 ApiState 后端采集 Sub2API 数据的目标,不是网站公网出口或浏览器 runner。
|
||||
|
||||
## 实现
|
||||
|
||||
- 在 `config/platform-infra/sub2rank.yaml#webProbe` 声明:
|
||||
- NC01/v03 runner;
|
||||
- 公网语义 origin 的代理模式;
|
||||
- 登录路径、字段、Cookie 和 Secret target key;
|
||||
- `operations` profile 的三页路径、就绪 selector、1920x1080 视口、截图名和证据上限。
|
||||
- 新增 `web-probe product-smoke --product apistate --target NC01 --profile operations`。
|
||||
- 命令从 owning YAML 和受控 Secret 声明解析 URL、runner、登录合同和凭据。
|
||||
- smoke 依次只读检查 `/scores`、`/ranking` 和 `/lottery`:
|
||||
- 记录有界 DOM 与 API 网络摘要;
|
||||
- 保存三张截图和 JSON 报告;
|
||||
- 拒绝登录以外的非 GET 请求;
|
||||
- 不点击评分刷新或抽奖按钮。
|
||||
- 与最新 master 的 PikaOA `ui-form-memory` 合并后:
|
||||
- 保留 PikaOA 与 SelfMedia product-smoke;
|
||||
- ApiState 显式使用 `json-post-session-cookie`;
|
||||
- 保留 browser launcher 阶段可见性。
|
||||
|
||||
## 验证
|
||||
|
||||
- `bun test scripts/src/web-probe-product-smoke.test.ts`:
|
||||
- 7 项通过,0 项失败。
|
||||
- `bun scripts/cli.ts check --syntax-only`:
|
||||
- 11 项通过,0 项失败。
|
||||
- `bun scripts/cli.ts platform-infra sub2rank plan --target NC01`:
|
||||
- `ok=true`;
|
||||
- `mutation=false`;
|
||||
- `warnings=[]`;
|
||||
- topology 为 NC01 shared public-edge;
|
||||
- Secret 仅披露 sourceRef、targetKey 和 `valuesPrinted=false`。
|
||||
- `git diff --check`:
|
||||
- 通过。
|
||||
- issue 正文记录既有预合并 run `KS2SZZ`:
|
||||
- 登录成功;
|
||||
- `/scores`、`/ranking`、`/lottery` 三页只读 smoke 完成;
|
||||
- 三张截图无空白、遮挡或溢出。
|
||||
- 当前工作区没有可索引的 `KS2SZZ` artifact,未虚构 report SHA 或 screenshot SHA。
|
||||
|
||||
## 待办与风险
|
||||
|
||||
- R5.3 保持进行中,等待主代理审核并合并 PR。
|
||||
- 合并后必须从 master 正式入口执行:
|
||||
- `bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations`。
|
||||
- 合并后复测需补充有界 issue evidence、report SHA 和三张 screenshot SHA。
|
||||
- 本次没有修改或扰动 ApiState/Sub2API 业务运行面,也没有触发任何交付链。
|
||||
@@ -292,6 +292,10 @@
|
||||
|
||||
解决 [UniDesk #2310](https://github.com/pikasTech/unidesk/issues/2310):彻底废弃 AgentRun 对 task worktree 的 Git 状态校验,使 dirty、ahead、behind 和已有 diff 均不阻断 create/apply/dispatch;只保留显式 targetWorkspace、绝对路径和目录存在性校验,不读取 Git 状态形成 warning 或 blocker,不修改生产运行面,提交独立 PR 且不自行合并,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R5.2_Task_Report.md)。
|
||||
|
||||
### R5.3 [in_progress]
|
||||
|
||||
完成 [UniDesk #2325](https://github.com/pikasTech/unidesk/issues/2325):新增 YAML 驱动的 ApiState 受控浏览器验收入口,从 Sub2Rank owning YAML 解析 NC01 公网 URL、NC01/v03 runner、登录字段、Cookie 和 SecretRef,完成登录、账号评分、用户用量和额度抽奖三页只读 smoke;明确 PK01 仅为后端 Sub2API 数据源,不参与网站 DNS、代理、公网暴露或浏览器 runner;输出截图与有界 DOM/网络证据,不触发刷新或抽奖,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R5.3_Task_Report.md)。
|
||||
|
||||
## R6 [completed]
|
||||
|
||||
处理 `529 Overloaded / API is at capacity` 客户可见错误:以 Sub2API 原生 Ops 证据锁定未触发 failover 的账号和规则缺口,在通用非 auth API-key 临时不可调度模板补 529 一分钟冷却并精准批量应用,排除 lyon9801,完成 runtime 回读和修复后短窗口复核,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R6_Task_Report.md)。
|
||||
|
||||
@@ -955,6 +955,7 @@ function webProbeHelpSummary(): unknown {
|
||||
"bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal --profile <yaml-profile>",
|
||||
"bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --edition <edition-id> --algorithm <algorithm-id>",
|
||||
"bun scripts/cli.ts web-probe product-smoke --product pikaoa --target NC01 --profile admin-mvp",
|
||||
"bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations",
|
||||
"bun scripts/cli.ts web-probe observe start --node NC01 --lane v03 --target-path /workbench --sample-interval-ms 5000",
|
||||
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary",
|
||||
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --turn 1",
|
||||
|
||||
@@ -90,6 +90,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal --profile <yaml-profile>",
|
||||
"bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition <edition-id> --algorithm <algorithm-id>",
|
||||
"bun scripts/cli.ts web-probe product-smoke --product pikaoa --target NC01 --profile admin-mvp",
|
||||
"bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations",
|
||||
"bun scripts/cli.ts web-probe script --node <node> --lane <lane> --origin internal --script-file .state/probes/workbench.mjs",
|
||||
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --origin internal --path /workbench --viewport 1440x900",
|
||||
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin internal --target-path /workbench --sample-interval-ms 5000",
|
||||
|
||||
@@ -157,6 +157,7 @@ export function webProbeGuardedLaunchShell(
|
||||
"done",
|
||||
"flock -u 9",
|
||||
"if [ \"$browser_ready\" != true ]; then",
|
||||
" if [ -n \"${UNIDESK_WEB_PROBE_BROWSER_STAGE_FILE:-}\" ] && [ -f \"$UNIDESK_WEB_PROBE_BROWSER_STAGE_FILE\" ]; then printf 'UNIDESK_WEB_PROBE_BROWSER_LAST_STAGE=%s\\n' \"$(cat \"$UNIDESK_WEB_PROBE_BROWSER_STAGE_FILE\")\" >&2; fi",
|
||||
" printf '%s\\n' 'web-probe browser launch readiness was not confirmed' >&2",
|
||||
" exit 43",
|
||||
"fi",
|
||||
|
||||
@@ -41,9 +41,13 @@ try {
|
||||
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
||||
await mkdir(runDir, { recursive: true, mode: 0o700 });
|
||||
userScriptSha256 = await sha256File(userScript).catch(() => null);
|
||||
await browserStage("launcher-import-start");
|
||||
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
||||
await browserStage("launcher-imported");
|
||||
const { chromium } = await launcher.importPlaywright();
|
||||
await browserStage("chromium-launch-start");
|
||||
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
||||
await browserStage("chromium-launched");
|
||||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||||
if (authProfile.mode === "ui-form-memory") page = await context.newPage();
|
||||
auth = await authenticate(context, page);
|
||||
@@ -118,6 +122,12 @@ try {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
async function browserStage(stage) {
|
||||
process.stderr.write("UNIDESK_WEB_PROBE_BROWSER_STAGE=" + String(stage) + "\n");
|
||||
const stageFile = process.env.UNIDESK_WEB_PROBE_BROWSER_STAGE_FILE;
|
||||
if (stageFile) await writeFile(stageFile, String(stage) + "\n", { mode: 0o600 });
|
||||
}
|
||||
|
||||
async function authenticate(browserContext, loginPage) {
|
||||
if (authProfile.mode === "ui-form-memory") return authenticateWithUiFormMemory(loginPage);
|
||||
return authenticateWithApiRetries(browserContext);
|
||||
|
||||
@@ -820,6 +820,8 @@ export function nodeWebProbeScriptRemoteShell(
|
||||
"mkdir -p \"$run_root\"",
|
||||
"run_dir=$(mktemp -d \"$run_root/run.XXXXXX\")",
|
||||
"report_file=\"$run_dir/web-probe-script-report.json\"",
|
||||
"browser_stage_file=\"$run_dir/browser-stage.txt\"",
|
||||
"export UNIDESK_WEB_PROBE_BROWSER_STAGE_FILE=\"$browser_stage_file\"",
|
||||
"chmod 700 \"$run_dir\"",
|
||||
"printf 'UNIDESK_WEB_PROBE_RUN_DIR=%s\\n' \"$run_dir\" >&2",
|
||||
"printf 'UNIDESK_WEB_PROBE_REPORT_PATH=%s\\n' \"$report_file\" >&2",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Sub2RankConfig {
|
||||
warnings: string[];
|
||||
metadata: { id: string; owner: string; relatedIssues: number[] };
|
||||
defaults: { targetId: string };
|
||||
webProbe: Sub2RankWebProbeConfig;
|
||||
application: {
|
||||
repository: string;
|
||||
remote: string;
|
||||
@@ -35,6 +36,7 @@ export interface Sub2RankConfig {
|
||||
configMatchesSourceCommit: boolean;
|
||||
deploymentBlockPresent: boolean;
|
||||
automaticCreditEnabled: boolean;
|
||||
webAuthUsername: string;
|
||||
server: { listenPort: number; databasePath: string; envKeys: string[] };
|
||||
};
|
||||
image: { repository: string; pullPolicy: "Always" | "IfNotPresent" | "Never" };
|
||||
@@ -87,6 +89,37 @@ export interface Sub2RankConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Sub2RankWebProbePage {
|
||||
id: string;
|
||||
path: string;
|
||||
readySelector: string;
|
||||
screenshotName: string;
|
||||
}
|
||||
|
||||
export interface Sub2RankWebProbeSmokeProfile {
|
||||
navigationTimeoutMs: number;
|
||||
commandTimeoutSeconds: number;
|
||||
settleMs: number;
|
||||
viewport: { width: number; height: number };
|
||||
pages: Sub2RankWebProbePage[];
|
||||
outputLimits: { failures: number; network: number };
|
||||
}
|
||||
|
||||
export interface Sub2RankWebProbeConfig {
|
||||
enabled: boolean;
|
||||
runner: { node: string; lane: string };
|
||||
origin: { browserProxyMode: "auto" | "direct" | "proxy" };
|
||||
authentication: {
|
||||
path: string;
|
||||
usernameField: string;
|
||||
passwordField: string;
|
||||
passwordTargetKey: string;
|
||||
cookieNames: string[];
|
||||
};
|
||||
defaultSmokeProfile: string;
|
||||
smokeProfiles: Record<string, Sub2RankWebProbeSmokeProfile>;
|
||||
}
|
||||
|
||||
export interface Sub2RankTarget extends PublicServiceTarget {
|
||||
enabled: boolean;
|
||||
publicExposure: PublicServiceExposure & {
|
||||
@@ -124,11 +157,13 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
if (version !== 1) throw new Error(`${sub2RankConfigLabel}.version must be 1`);
|
||||
const metadataRecord = y.objectField(root, "metadata", "");
|
||||
const defaultsRecord = y.objectField(root, "defaults", "");
|
||||
const webProbeRecord = y.objectField(root, "webProbe", "");
|
||||
const applicationRecord = y.objectField(root, "application", "");
|
||||
const imageRecord = y.objectField(root, "image", "");
|
||||
const deliveryRecord = y.objectField(root, "delivery", "");
|
||||
const runtimeRecord = y.objectField(root, "runtime", "");
|
||||
const defaults = { targetId: simpleId(y.stringField(defaultsRecord, "targetId", "defaults"), "defaults.targetId") };
|
||||
const webProbe = parseWebProbe(webProbeRecord);
|
||||
const application = parseApplication(applicationRecord);
|
||||
const image = parseImage(imageRecord);
|
||||
const delivery = parseDelivery(deliveryRecord);
|
||||
@@ -136,7 +171,7 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId);
|
||||
const targets = parseTargets(root.targets, secret);
|
||||
if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`);
|
||||
const warnings = validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets);
|
||||
const warnings = validateResolvedConfig(application, image, delivery, webProbe, runtimeBase, secret, targets);
|
||||
return {
|
||||
version,
|
||||
kind: "platform-infra-sub2rank",
|
||||
@@ -147,6 +182,7 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
relatedIssues: y.numberArrayField(metadataRecord, "relatedIssues", "metadata"),
|
||||
},
|
||||
defaults,
|
||||
webProbe,
|
||||
application,
|
||||
image,
|
||||
delivery,
|
||||
@@ -155,6 +191,67 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebProbe(record: Record<string, unknown>): Sub2RankWebProbeConfig {
|
||||
const runner = y.objectField(record, "runner", "webProbe");
|
||||
const origin = y.objectField(record, "origin", "webProbe");
|
||||
const authentication = y.objectField(record, "authentication", "webProbe");
|
||||
const profilesRecord = y.objectField(record, "smokeProfiles", "webProbe");
|
||||
const smokeProfiles: Record<string, Sub2RankWebProbeSmokeProfile> = {};
|
||||
for (const [profileName, value] of Object.entries(profilesRecord)) {
|
||||
const profilePath = `webProbe.smokeProfiles.${profileName}`;
|
||||
const profile = y.asRecord(value, profilePath);
|
||||
const viewport = y.objectField(profile, "viewport", profilePath);
|
||||
const outputLimits = y.objectField(profile, "outputLimits", profilePath);
|
||||
const pages = y.arrayOfRecords(profile.pages, `${profilePath}.pages`).map((page, index) => {
|
||||
const pagePath = `${profilePath}.pages[${index}]`;
|
||||
const screenshotName = y.stringField(page, "screenshotName", pagePath);
|
||||
if (!/^[A-Za-z0-9._-]+[.]png$/u.test(screenshotName)) throw new Error(`${sub2RankConfigLabel}.${pagePath}.screenshotName must be a safe png filename`);
|
||||
return {
|
||||
id: simpleId(y.stringField(page, "id", pagePath), `${pagePath}.id`),
|
||||
path: y.apiPathField(page, "path", pagePath),
|
||||
readySelector: nonEmptyText(y.stringField(page, "readySelector", pagePath), `${pagePath}.readySelector`),
|
||||
screenshotName,
|
||||
};
|
||||
});
|
||||
if (pages.length === 0) throw new Error(`${sub2RankConfigLabel}.${profilePath}.pages must not be empty`);
|
||||
smokeProfiles[simpleId(profileName, profilePath)] = {
|
||||
navigationTimeoutMs: positiveInteger(profile, "navigationTimeoutMs", profilePath),
|
||||
commandTimeoutSeconds: positiveInteger(profile, "commandTimeoutSeconds", profilePath),
|
||||
settleMs: nonNegativeInteger(profile.settleMs, `${profilePath}.settleMs`),
|
||||
viewport: {
|
||||
width: positiveInteger(viewport, "width", `${profilePath}.viewport`),
|
||||
height: positiveInteger(viewport, "height", `${profilePath}.viewport`),
|
||||
},
|
||||
pages,
|
||||
outputLimits: {
|
||||
failures: positiveInteger(outputLimits, "failures", `${profilePath}.outputLimits`),
|
||||
network: positiveInteger(outputLimits, "network", `${profilePath}.outputLimits`),
|
||||
},
|
||||
};
|
||||
}
|
||||
const defaultSmokeProfile = simpleId(y.stringField(record, "defaultSmokeProfile", "webProbe"), "webProbe.defaultSmokeProfile");
|
||||
if (smokeProfiles[defaultSmokeProfile] === undefined) throw new Error(`${sub2RankConfigLabel}.webProbe.defaultSmokeProfile is not declared`);
|
||||
return {
|
||||
enabled: y.booleanField(record, "enabled", "webProbe"),
|
||||
runner: {
|
||||
node: simpleId(y.stringField(runner, "node", "webProbe.runner"), "webProbe.runner.node"),
|
||||
lane: simpleId(y.stringField(runner, "lane", "webProbe.runner"), "webProbe.runner.lane"),
|
||||
},
|
||||
origin: {
|
||||
browserProxyMode: y.enumField(origin, "browserProxyMode", "webProbe.origin", ["auto", "direct", "proxy"] as const),
|
||||
},
|
||||
authentication: {
|
||||
path: y.apiPathField(authentication, "path", "webProbe.authentication"),
|
||||
usernameField: simpleId(y.stringField(authentication, "usernameField", "webProbe.authentication"), "webProbe.authentication.usernameField"),
|
||||
passwordField: simpleId(y.stringField(authentication, "passwordField", "webProbe.authentication"), "webProbe.authentication.passwordField"),
|
||||
passwordTargetKey: y.envKeyField(authentication, "passwordTargetKey", "webProbe.authentication"),
|
||||
cookieNames: y.stringArrayField(authentication, "cookieNames", "webProbe.authentication").map((name) => simpleId(name, "webProbe.authentication.cookieNames")),
|
||||
},
|
||||
defaultSmokeProfile,
|
||||
smokeProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSub2RankTarget(config: Sub2RankConfig, targetId: string | null): Sub2RankTarget {
|
||||
const selected = targetId ?? config.defaults.targetId;
|
||||
const target = config.targets.find((item) => item.id === selected);
|
||||
@@ -191,6 +288,9 @@ function parseApplication(record: Record<string, unknown>): Sub2RankConfig["appl
|
||||
if (typeof automaticCredit !== "object" || automaticCredit === null || Array.isArray(automaticCredit)) throw new Error(`${configRef}.lottery.automaticCredit must be an object`);
|
||||
const automaticCreditEnabled = (automaticCredit as Record<string, unknown>).enabled;
|
||||
if (typeof automaticCreditEnabled !== "boolean") throw new Error(`${configRef}.lottery.automaticCredit.enabled must be a boolean`);
|
||||
const webAuth = app.webAuth;
|
||||
if (typeof webAuth !== "object" || webAuth === null || Array.isArray(webAuth)) throw new Error(`${configRef}.webAuth must be an object`);
|
||||
const webAuthUsername = simpleId(stringValue((webAuth as Record<string, unknown>).username, `${configRef}.webAuth.username`), `${configRef}.webAuth.username`);
|
||||
const sourceCommit = gitText(sourceRoot, ["rev-parse", "HEAD"], "resolve Sub2Rank source commit");
|
||||
if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error(`${sourceRoot} HEAD must resolve to a full Git commit`);
|
||||
const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true);
|
||||
@@ -216,6 +316,7 @@ function parseApplication(record: Record<string, unknown>): Sub2RankConfig["appl
|
||||
configMatchesSourceCommit: committedConfig === configText.trimEnd(),
|
||||
deploymentBlockPresent: app.deployment !== undefined,
|
||||
automaticCreditEnabled,
|
||||
webAuthUsername,
|
||||
server: {
|
||||
listenPort: integerValue(server.listenPort, `${configRef}.runtime.serverTargets.${runtimeTarget}.listenPort`),
|
||||
databasePath: absolutePathValue(server.databasePath, `${configRef}.runtime.serverTargets.${runtimeTarget}.databasePath`),
|
||||
@@ -457,6 +558,7 @@ function validateResolvedConfig(
|
||||
application: Sub2RankConfig["application"],
|
||||
image: Sub2RankConfig["image"],
|
||||
delivery: Sub2RankConfig["delivery"],
|
||||
webProbe: Sub2RankConfig["webProbe"],
|
||||
runtime: ReturnType<typeof parseRuntime>,
|
||||
secret: ResolvedSecretDeclaration,
|
||||
targets: Sub2RankTarget[],
|
||||
@@ -467,6 +569,10 @@ function validateResolvedConfig(
|
||||
if (!application.sourceRemoteMatches) warnings.push("application.remote 与 application.sourceRoot 的 origin 不一致");
|
||||
if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) warnings.push("application.repository 与 application.remote 指向的仓库不一致");
|
||||
if (!delivery.enabled) warnings.push("delivery.enabled=false,自动交付当前关闭");
|
||||
if (!webProbe.enabled) warnings.push("webProbe.enabled=false,受控浏览器验收当前关闭");
|
||||
if (!secret.mappings.some((item) => item.targetKey === webProbe.authentication.passwordTargetKey)) {
|
||||
throw new Error(`${sub2RankConfigLabel}.webProbe.authentication.passwordTargetKey is not provided by runtime.secret.declarationId`);
|
||||
}
|
||||
if (delivery.gitops.maxPushAttempts > 5) warnings.push("delivery.gitops.maxPushAttempts 大于 5,失败时可能延长流水线耗时");
|
||||
if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) warnings.push("delivery.gitops.manifestPath 不在 application.path 的直接子路径下");
|
||||
if (!delivery.build.proxy.noProxy.includes("hyueapi.com") || !delivery.build.proxy.noProxy.includes(".hyueapi.com")) throw new Error(`${sub2RankConfigLabel}.delivery.build.proxy.noProxy must retain hyueapi.com and .hyueapi.com`);
|
||||
@@ -494,6 +600,17 @@ function positiveInteger(record: Record<string, unknown>, key: string, path: str
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, path: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) throw new Error(`${sub2RankConfigLabel}.${path} must be >= 0`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function nonEmptyText(value: string, path: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0 || normalized.length > 240) throw new Error(`${sub2RankConfigLabel}.${path} must be 1..240 characters`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function disabledField(record: Record<string, unknown>, key: string, path: string): false {
|
||||
const value = y.booleanField(record, key, path);
|
||||
if (value) throw new Error(`${sub2RankConfigLabel}.${path}.${key} must remain false because the resource is shared and owned outside Sub2Rank`);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
|
||||
import { readSub2RankConfig, resolveSub2RankTarget, sub2RankConfigLabel, type Sub2RankWebProbeSmokeProfile } from "./platform-infra-sub2rank-config";
|
||||
import { readDeclaredSecretValues } from "./secrets";
|
||||
import type { NodeWebProbeScriptOptions } from "./hwlab-node/entry";
|
||||
import { runManagedNodeWebProbeScript } from "./hwlab-node/web-observe-scripts";
|
||||
|
||||
export interface ApiStateProductSmokeOptions {
|
||||
readonly target: string;
|
||||
readonly profile: string | null;
|
||||
}
|
||||
|
||||
export function runWebProbeApiStateSmoke(options: ApiStateProductSmokeOptions): Record<string, unknown> {
|
||||
const config = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(config, options.target);
|
||||
const webProbe = config.webProbe;
|
||||
if (!webProbe.enabled) throw new Error(`${sub2RankConfigLabel}.webProbe.enabled=false,禁止执行 ApiState Web smoke`);
|
||||
const profileName = options.profile ?? webProbe.defaultSmokeProfile;
|
||||
const profile = webProbe.smokeProfiles[profileName];
|
||||
if (profile === undefined) throw new Error(`未知 --profile ${profileName};可用项:${Object.keys(webProbe.smokeProfiles).sort().join(", ")}`);
|
||||
if (!isHwlabRuntimeLane(webProbe.runner.lane)) throw new Error(`ApiState Web smoke runner lane 未在 HWLAB YAML 中声明:${webProbe.runner.lane}`);
|
||||
const laneSpec = hwlabRuntimeLaneSpecForNode(webProbe.runner.lane, webProbe.runner.node);
|
||||
const passwordKey = webProbe.authentication.passwordTargetKey;
|
||||
const declared = readDeclaredSecretValues({
|
||||
configPath: config.runtime.secret.configRef,
|
||||
targetId: config.runtime.secret.targetId,
|
||||
secretName: config.runtime.secret.declarationId,
|
||||
targetKeys: [passwordKey],
|
||||
});
|
||||
const password = declared.values[passwordKey];
|
||||
if (password === undefined) throw new Error("ApiState Web smoke 密码声明不完整");
|
||||
const configRef = `${sub2RankConfigLabel}#webProbe.smokeProfiles.${profileName}`;
|
||||
const commandLabel = `web-probe product-smoke --product apistate --target ${target.id} --profile ${profileName}`;
|
||||
const scriptText = apiStateOperationsSmokeScript(profileName, profile, configRef);
|
||||
const scriptOptions: NodeWebProbeScriptOptions = {
|
||||
action: "script",
|
||||
node: webProbe.runner.node,
|
||||
lane: webProbe.runner.lane,
|
||||
url: target.publicExposure.publicBaseUrl,
|
||||
originName: "public",
|
||||
originMode: "public",
|
||||
originConfigPath: `${sub2RankConfigLabel}#targets.${target.id}.publicExposure.publicBaseUrl`,
|
||||
browserProxyMode: webProbe.origin.browserProxyMode,
|
||||
browserProxyModeSource: "yaml-origin",
|
||||
timeoutMs: profile.navigationTimeoutMs,
|
||||
viewport: `${profile.viewport.width}x${profile.viewport.height}`,
|
||||
commandTimeoutSeconds: profile.commandTimeoutSeconds,
|
||||
scriptText,
|
||||
commandLabel,
|
||||
suppressAdHocWarning: true,
|
||||
generatedHints: [
|
||||
"该命令由 Sub2Rank owning YAML 驱动,并复用受控认证、远端浏览器、内存门禁和证据恢复链。",
|
||||
"smoke 只读取评分、用量和抽奖页面,不点击刷新或抽奖按钮。",
|
||||
],
|
||||
generatedPreferredCommands: { rerun: commandLabel },
|
||||
scriptSource: {
|
||||
kind: "generated",
|
||||
path: `builtin:product-smoke/apistate/${profileName}`,
|
||||
byteCount: Buffer.byteLength(scriptText),
|
||||
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
|
||||
},
|
||||
};
|
||||
const result = runManagedNodeWebProbeScript(
|
||||
scriptOptions,
|
||||
laneSpec,
|
||||
{ username: config.application.webAuthUsername, password },
|
||||
{ product: "apistate", target: target.id, source: declared.summary, valuesPrinted: false },
|
||||
{
|
||||
mode: "json-post-session-cookie",
|
||||
loginPath: webProbe.authentication.path,
|
||||
usernameField: webProbe.authentication.usernameField,
|
||||
passwordField: webProbe.authentication.passwordField,
|
||||
cookieNames: webProbe.authentication.cookieNames,
|
||||
},
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
productSmoke: { product: "apistate", target: target.id, profile: profileName, configRef, mutation: false, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function apiStateOperationsSmokeScript(
|
||||
profileName: string,
|
||||
profile: Sub2RankWebProbeSmokeProfile,
|
||||
configRef: string,
|
||||
): string {
|
||||
const config = { profileName, configRef, ...profile };
|
||||
return String.raw`const config = ${JSON.stringify(config)};
|
||||
|
||||
export default async function apiStateOperationsSmoke({ page, goto, wait, screenshot, jsonArtifact, recordStep }) {
|
||||
const failures = [];
|
||||
const network = [];
|
||||
const pages = [];
|
||||
const screenshots = [];
|
||||
let networkCount = 0;
|
||||
const addFailure = (value) => {
|
||||
if (failures.length < config.outputLimits.failures) failures.push(String(value).replace(/\s+/gu, " ").slice(0, 300));
|
||||
};
|
||||
const addNetwork = (value) => {
|
||||
networkCount += 1;
|
||||
if (network.length < config.outputLimits.network) network.push(value);
|
||||
};
|
||||
page.on("response", (response) => {
|
||||
const parsed = safeUrl(response.url());
|
||||
if (parsed === null || !parsed.pathname.startsWith("/api/")) return;
|
||||
addNetwork({ method: response.request().method(), path: parsed.pathname, status: response.status() });
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const parsed = safeUrl(request.url());
|
||||
if (parsed !== null) addNetwork({ method: request.method(), path: parsed.pathname, status: null, failure: String(request.failure()?.errorText || "request failed").slice(0, 160) });
|
||||
});
|
||||
|
||||
for (const item of config.pages) {
|
||||
try {
|
||||
await goto(item.path, { selectors: [item.readySelector], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 });
|
||||
await page.waitForFunction((pageId) => {
|
||||
const fatal = document.querySelector(".fatal-state")?.textContent?.trim();
|
||||
if (fatal) return false;
|
||||
if (pageId === "scores") return document.querySelector("#score-state")?.getAttribute("data-state") !== null;
|
||||
if (pageId === "ranking") return document.querySelector("#ranking-range")?.textContent?.trim() !== "读取中";
|
||||
if (pageId === "lottery") return document.querySelector("#lottery-mode")?.textContent?.trim() !== "读取中";
|
||||
return false;
|
||||
}, item.id, { timeout: config.navigationTimeoutMs });
|
||||
if (config.settleMs > 0) await wait(config.settleMs);
|
||||
const dom = await page.evaluate((pageId) => ({
|
||||
pageId: document.body.dataset.page || null,
|
||||
title: document.title,
|
||||
heading: document.querySelector("h1")?.textContent?.trim() || null,
|
||||
activeNav: document.querySelector('nav a[aria-current="page"]')?.textContent?.trim() || null,
|
||||
rowCount: pageId === "scores"
|
||||
? document.querySelectorAll("#score-body tr").length
|
||||
: pageId === "ranking"
|
||||
? document.querySelectorAll("#ranking-body tr").length
|
||||
: document.querySelectorAll("#record-list li").length,
|
||||
fatal: document.querySelector(".fatal-state")?.textContent?.trim() || null,
|
||||
drawButtonDisabled: pageId === "lottery" ? document.querySelector("#draw-button")?.disabled === true : null,
|
||||
}), item.id);
|
||||
if (dom.pageId !== item.id || dom.fatal) throw new Error("页面 " + item.id + " DOM 未就绪");
|
||||
const shot = await screenshot(item.screenshotName);
|
||||
screenshots.push(shot);
|
||||
pages.push({ id: item.id, path: new URL(page.url()).pathname, dom, screenshot: shot });
|
||||
recordStep("读取 " + item.id + " 页面", { ok: true, path: new URL(page.url()).pathname, rowCount: dom.rowCount });
|
||||
} catch (error) {
|
||||
addFailure(item.id + ": " + (error instanceof Error ? error.message : String(error)));
|
||||
recordStep("读取 " + item.id + " 页面失败", { ok: false, failure: failures[failures.length - 1] || "unknown" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const mutatingRequests = network.filter((item) => item.method !== "GET" && item.path !== "/api/login");
|
||||
if (mutatingRequests.length > 0) addFailure("smoke 观察到非登录写请求");
|
||||
const evidence = {
|
||||
profileName: config.profileName,
|
||||
configRef: config.configRef,
|
||||
pageCount: pages.length,
|
||||
pages,
|
||||
network: { count: networkCount, items: network, mutatingRequests },
|
||||
failures,
|
||||
mutation: false,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
const report = await jsonArtifact("apistate-operations-smoke.json", evidence).catch((error) => {
|
||||
addFailure("报告写入失败:" + (error instanceof Error ? error.message : String(error)));
|
||||
return null;
|
||||
});
|
||||
const ok = failures.length === 0 && pages.length === config.pages.length && mutatingRequests.length === 0;
|
||||
return {
|
||||
ok,
|
||||
status: ok ? "pass" : "blocked",
|
||||
...evidence,
|
||||
report,
|
||||
screenshots,
|
||||
issueEvidence: {
|
||||
ok,
|
||||
status: ok ? "pass" : "blocked",
|
||||
failedCondition: ok ? null : failures[0] || "ApiState 多页面 smoke 未完成",
|
||||
result: { pageCount: pages.length, pages, network: evidence.network, mutation: false },
|
||||
reportPath: report?.path || null,
|
||||
reportSha256: report?.sha256 || null,
|
||||
screenshots,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
|
||||
function safeUrl(value) {
|
||||
try { return new URL(value); } catch { return null; }
|
||||
}
|
||||
}`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
import { readSub2RankConfig } from "./platform-infra-sub2rank-config";
|
||||
import { readDeclaredSecretValues } from "./secrets";
|
||||
import { resolveSelfMediaDeliveryTarget } from "./selfmedia-config";
|
||||
import { pikaoaDevelopmentRuntimeConfigRef, resolvePikaoaDevelopmentRuntimeTargetByConfigRef } from "./pikaoa-runtime";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
parseWebProbeProductSmokeOptions,
|
||||
resolveWebProbeProductSmoke,
|
||||
} from "./web-probe-product-smoke";
|
||||
import { apiStateOperationsSmokeScript } from "./web-probe-apistate-smoke";
|
||||
|
||||
const fixture = {
|
||||
product: "selfmedia",
|
||||
@@ -70,6 +72,31 @@ test("PikaOA admin-mvp 由 development runtime YAML 解析且不要求 SelfMedia
|
||||
assert.throws(() => resolveWebProbeProductSmoke({ ...options, edition: "not-used" }), /不接受 --edition/u);
|
||||
});
|
||||
|
||||
test("ApiState product-smoke 只需要产品、目标和 YAML profile", () => {
|
||||
assert.deepEqual(parseWebProbeProductSmokeOptions([
|
||||
"--product", "apistate",
|
||||
"--target", "NC01",
|
||||
"--profile", "operations",
|
||||
]), {
|
||||
product: "apistate",
|
||||
target: "NC01",
|
||||
profile: "operations",
|
||||
edition: null,
|
||||
algorithm: null,
|
||||
});
|
||||
const config = readSub2RankConfig();
|
||||
const profile = config.webProbe.smokeProfiles.operations;
|
||||
assert.ok(profile);
|
||||
assert.equal(config.webProbe.authentication.path, "/api/login");
|
||||
assert.equal(config.webProbe.authentication.passwordTargetKey, "APISTATE_WEB_PASSWORD");
|
||||
assert.deepEqual(profile.pages.map((item) => item.id), ["scores", "ranking", "lottery"]);
|
||||
const script = apiStateOperationsSmokeScript("operations", profile, "fixture#operations");
|
||||
assert.doesNotThrow(() => new Function(script.replace("export default ", "")));
|
||||
assert.doesNotMatch(script, /\.click\(/u);
|
||||
assert.match(script, /mutatingRequests/u);
|
||||
assert.match(script, /screenshot\(item\.screenshotName\)/u);
|
||||
});
|
||||
|
||||
test("生成脚本可解析并复用 managed web-probe,无临时脚本警告", () => {
|
||||
const resolved = resolveWebProbeProductSmoke(fixture);
|
||||
assert.equal(resolved.scriptOptions.action, "script");
|
||||
@@ -108,6 +135,7 @@ test("managed runner 的登录路径、字段和 cookie 都来自显式认证 pr
|
||||
assert.match(runner, /\[authProfile\.usernameField\]/u);
|
||||
assert.match(runner, /\[authProfile\.passwordField\]/u);
|
||||
assert.match(runner, /authProfile\.cookieNames\.some/u);
|
||||
assert.match(runner, /UNIDESK_WEB_PROBE_BROWSER_STAGE/u);
|
||||
assert.match(managed, /UNIDESK_WEB_PROBE_AUTH_PROFILE/u);
|
||||
assert.match(managed, /runManagedNodeWebProbeScript/u);
|
||||
assert.match(runner, /ui-form-memory/u);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { runWebProbeApiStateSmoke } from "./web-probe-apistate-smoke";
|
||||
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
|
||||
import { readDeclaredSecretValues } from "./secrets";
|
||||
import {
|
||||
@@ -59,8 +60,8 @@ export function parseWebProbeProductSmokeOptions(args: string[]): WebProbeProduc
|
||||
export function resolveWebProbeProductSmoke(options: WebProbeProductSmokeOptions): ResolvedProductSmoke {
|
||||
if (options.product === "pikaoa") return resolvePikaoaProductSmoke(options);
|
||||
if (options.product !== "selfmedia") throw new Error(`未知 --product ${options.product};当前声明的产品为 pikaoa、selfmedia`);
|
||||
if (options.edition === null) throw new Error("SelfMedia product-smoke 缺少 --edition");
|
||||
if (options.algorithm === null) throw new Error("SelfMedia product-smoke 缺少 --algorithm");
|
||||
const edition = requiredProductValue(options.edition, "--edition", options.product);
|
||||
const algorithm = requiredProductValue(options.algorithm, "--algorithm", options.product);
|
||||
const target = resolveSelfMediaDeliveryTarget(options.target);
|
||||
const webProbe = target.webProbe;
|
||||
if (webProbe.productId !== options.product) {
|
||||
@@ -81,10 +82,10 @@ export function resolveWebProbeProductSmoke(options: WebProbeProductSmokeOptions
|
||||
`--product ${options.product}`,
|
||||
`--target ${target.id}`,
|
||||
`--profile ${profileName}`,
|
||||
`--edition ${options.edition}`,
|
||||
`--algorithm ${options.algorithm}`,
|
||||
`--edition ${edition}`,
|
||||
`--algorithm ${algorithm}`,
|
||||
].join(" ");
|
||||
const scriptText = selfMediaVoiceAlgorithmSmokeScript(options.edition, options.algorithm, profileName, profile, configRef);
|
||||
const scriptText = selfMediaVoiceAlgorithmSmokeScript(edition, algorithm, profileName, profile, configRef);
|
||||
const scriptOptions: NodeWebProbeScriptOptions = {
|
||||
action: "script",
|
||||
node: webProbe.runner.node,
|
||||
@@ -118,6 +119,10 @@ export function resolveWebProbeProductSmoke(options: WebProbeProductSmokeOptions
|
||||
|
||||
export function runWebProbeProductSmoke(args: string[]): Record<string, unknown> {
|
||||
const parsed = parseWebProbeProductSmokeOptions(args);
|
||||
if (parsed.product === "apistate") {
|
||||
if (parsed.edition !== null || parsed.algorithm !== null) throw new Error("ApiState product-smoke 不接受 --edition 或 --algorithm");
|
||||
return runWebProbeApiStateSmoke({ target: parsed.target, profile: parsed.profile });
|
||||
}
|
||||
const resolved = resolveWebProbeProductSmoke(parsed);
|
||||
const webProbe = resolved.target.webProbe;
|
||||
const credentials = webProbe.authentication.credentials;
|
||||
@@ -469,6 +474,11 @@ function requiredValue(values: Map<string, string>, key: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredProductValue(value: string | null, key: string, product: string): string {
|
||||
if (value === null) throw new Error(`web-probe product-smoke --product ${product} 缺少 ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function simpleId(value: string, key: string): string {
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value) || value.length > 120) throw new Error(`${key} 必须是最长 120 字符的简单标识`);
|
||||
return value;
|
||||
|
||||
Reference in New Issue
Block a user