diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 8fb35c8e..e7a738fc 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -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 --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。 diff --git a/config/platform-infra/sub2rank.yaml b/config/platform-infra/sub2rank.yaml index 3dd8195e..08a2e83a 100644 --- a/config/platform-infra/sub2rank.yaml +++ b/config/platform-infra/sub2rank.yaml @@ -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 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R5.3_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R5.3_Task_Report.md new file mode 100644 index 00000000..aea48bf7 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R5.3_Task_Report.md @@ -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 业务运行面,也没有触发任何交付链。 diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index 13694149..4c3797eb 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -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)。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index bae6a608..204c7c20 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -955,6 +955,7 @@ function webProbeHelpSummary(): unknown { "bun scripts/cli.ts web-probe console-verify --node --lane --origin internal --profile ", "bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --edition --algorithm ", "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", diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index d8c6feaf..b0940626 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -90,6 +90,7 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe console-verify --node --lane --origin internal --profile ", "bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition --algorithm ", "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 --lane --origin internal --script-file .state/probes/workbench.mjs", "bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900", "bun scripts/cli.ts web-probe observe start --node --lane --origin internal --target-path /workbench --sample-interval-ms 5000", diff --git a/scripts/src/hwlab-node-web-probe-memory-guard.ts b/scripts/src/hwlab-node-web-probe-memory-guard.ts index 2db3ae3a..9e9f056b 100644 --- a/scripts/src/hwlab-node-web-probe-memory-guard.ts +++ b/scripts/src/hwlab-node-web-probe-memory-guard.ts @@ -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", diff --git a/scripts/src/hwlab-node-web-probe-runner-source.ts b/scripts/src/hwlab-node-web-probe-runner-source.ts index f7fe639d..b8164386 100644 --- a/scripts/src/hwlab-node-web-probe-runner-source.ts +++ b/scripts/src/hwlab-node-web-probe-runner-source.ts @@ -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); diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index aeaad73d..9886694d 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -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", diff --git a/scripts/src/platform-infra-sub2rank-config.ts b/scripts/src/platform-infra-sub2rank-config.ts index 54b26207..5bcec814 100644 --- a/scripts/src/platform-infra-sub2rank-config.ts +++ b/scripts/src/platform-infra-sub2rank-config.ts @@ -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; +} + 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): 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 = {}; + 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): 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).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).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): 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, 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, 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, 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`); diff --git a/scripts/src/web-probe-apistate-smoke.ts b/scripts/src/web-probe-apistate-smoke.ts new file mode 100644 index 00000000..37a5b99b --- /dev/null +++ b/scripts/src/web-probe-apistate-smoke.ts @@ -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 { + 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; } + } +}`; +} diff --git a/scripts/src/web-probe-product-smoke.test.ts b/scripts/src/web-probe-product-smoke.test.ts index 4ab4cc3b..03dd2a14 100644 --- a/scripts/src/web-probe-product-smoke.test.ts +++ b/scripts/src/web-probe-product-smoke.test.ts @@ -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); diff --git a/scripts/src/web-probe-product-smoke.ts b/scripts/src/web-probe-product-smoke.ts index 293820a3..b7343ae3 100644 --- a/scripts/src/web-probe-product-smoke.ts +++ b/scripts/src/web-probe-product-smoke.ts @@ -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 { 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, 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;