feat: 增加 ApiState 受控 Web smoke

This commit is contained in:
Codex
2026-07-16 13:14:46 +02:00
parent dcdca121e2
commit e5ce58bea6
13 changed files with 474 additions and 6 deletions
+5
View File
@@ -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。
+40
View File
@@ -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)。
+1
View File
@@ -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",
+1
View File
@@ -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",
+118 -1
View File
@@ -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`);
+192
View File
@@ -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);
+15 -5
View File
@@ -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;