diff --git a/docs/handoffs/2026-09-10-doubao-video-page-readiness-01-acceptance-closeout.md b/docs/handoffs/2026-09-10-doubao-video-page-readiness-01-acceptance-closeout.md new file mode 100644 index 0000000..267f12a --- /dev/null +++ b/docs/handoffs/2026-09-10-doubao-video-page-readiness-01-acceptance-closeout.md @@ -0,0 +1,30 @@ +# DOUBAO-VIDEO-PAGE-READINESS-01 验收收口单 + +- 日期:2026-09-10 +- 裁决:PASS +- supersedes:`2026-09-10-doubao-video-page-readiness-01.md` 中“尚未人工验收”的状态 +- 基线:`origin/main@dfbb7f2028bd88caf838c52f5785e3130e594e7a` + +## 治理与冻结范围 + +已按总架构师治理规则复核。冻结范围仍为九阶段视频页就绪、有界稳定采样与脱敏诊断、提交前最多一次页面恢复及提交不确定禁止重发;未扩入调度、下载、额度或 Dola 验证。 + +## 验收证据 + +- 单包专项:3 files / 189 tests PASS。 +- 单包全量:48 files / 1012 tests PASS;TypeScript、工程边界、Renderer/Main build、pack PASS;ESLint 0 error / 147 warning。 +- 与第三包联合候选:49 files / 1030 tests PASS;TypeScript、lint、build、pack、`git diff --check` 全部 PASS。 +- 真实平台:授权范围内完成一次普通 5 秒无素材视频,仅提交一次;页面就绪链未发生重复点击或不确定重发,任务最终绑定真实产物。 +- 重启后保持同一具体会话;没有跳转到 `/chat/` 根页或再次提交。 + +证据目录:`D:\豆包工作室\_acceptance\p2-p3-20260910-204549`。其中 `g1-p2-*`、`g3-integration-*`、`g6-result.json`、`final-acceptance.json` 为本裁决依据。 + +## 五态声明 + +- 已实现:是。 +- CI:尚未运行远端 CI。 +- 人工/真实验收:PASS(授权范围内单次真实平台链)。 +- 已合并:否。 +- 已部署/已发布:否。 + +Dola 仍未验证;未把真实账号数据、Session、任务台账或验收副本纳入仓库。 diff --git a/docs/handoffs/2026-09-10-doubao-video-page-readiness-01.md b/docs/handoffs/2026-09-10-doubao-video-page-readiness-01.md new file mode 100644 index 0000000..46cdbb4 --- /dev/null +++ b/docs/handoffs/2026-09-10-doubao-video-page-readiness-01.md @@ -0,0 +1,74 @@ +# DOUBAO-VIDEO-PAGE-READINESS-01 实施交接单 + +- 日期:2026-09-10 +- 状态:代码已实现、自动化测试通过;未人工验收、未执行真实任务、未提交、未推送、未部署 +- 基线:`origin/main@dfbb7f2028bd88caf838c52f5785e3130e594e7a` +- 分支:`codex/doubao-video-page-readiness-01` +- 工作树:`D:\豆包工作室\doubao-video-page-readiness-01` + +## 治理与冻结验收包 + +开工前已读取总架构师治理规则,包括中央 `memory/INDEX.md`、`DEVELOPMENT_EFFICIENCY_GOVERNANCE.md`、`ACTIVE_DEVELOPMENT_DIRECTION.md`、`CURRENT.md`、`QUEUE.md`,以及豆包仓库 `AGENTS.md`、`ROADMAP.md`、相关源码/测试和 `2026-09-08-doubao-studio-236-t01-t02-production-usage-report-r1.md`。 + +本包冻结为一个核心目标、三个 P0: + +1. 九阶段视频页就绪链:账号、新对话、视频入口、模型、比例、时长、素材、提示词、发送控件。 +2. 每阶段有界等待、连续稳定采样和脱敏诊断;禁止保存页面正文、提示词、素材绝对路径、Cookie、Token 或 Session。 +3. 提交前页面变化最多恢复一次;提交不确定禁止自动重发,只有明确平台回执才进入生成中。 + +非目标:执行意图、依赖策略、多账号观察流水线、local-control、下载归档、真实素材上传和真实额度消费。Dola 未做真实验证,不宣称可用。 + +## 实现结果 + +- 新增统一 `VideoPageReadinessStage` 九阶段模型和版本化诊断 schema。 +- 将旧 `model + composite` 探针拆为 `model + aspectRatio + duration`,连续三次稳定才通过。 +- 页面结构指纹仅编码适配器版本、页面变体与三个能力位;不读取或持久化网页正文。 +- `BrowserPanel` 在九个实际执行节点更新任务快照,重启后可回读当前阶段、耗时、尝试数、稳定样本、恢复次数、缺失控件和结构版本。 +- 模型、比例、时长、素材、提示词和发送控件失败使用独立机器码,不再退化为笼统 `page_changed`。 +- 参数配置发生已知就绪错误时,仅在提交意图产生前重新识别一次;第二次失败立即停止。 +- 沿用原有不可重复副作用保护:先持久化提交意图,点击后无明确回执进入 `submission_uncertain`,不自动重发;明确回读后才进入 `generating`。 +- 旧运行快照阶段与错误码继续可读,持久化归一化对新增字段执行白名单和长度限制。 + +## 精确文件清单 + +Tracked 修改(9): + +1. `main/utils/persistenceNormalization.ts` +2. `packages/contracts/src/domain.ts` +3. `packages/contracts/src/enums.ts` +4. `src/components/BrowserPanel.tsx` +5. `src/utils/doubaoBridge.ts` +6. `src/utils/videoControlReadiness.ts` +7. `tests/unit/generationControlReadiness.test.ts` +8. `tests/unit/persistenceNormalization.test.ts` +9. `tests/unit/videoCapability.test.ts` + +Untracked 新增(1): + +1. `docs/handoffs/2026-09-10-doubao-video-page-readiness-01.md` + +Tracked 删除:无。 + +## 门禁证据 + +- 专项:5 files / 255 pass / 0 fail。 +- TypeScript:contracts、main、renderer、test 四套配置通过。 +- 修改文件 ESLint:0 error / 34 warning;warning 为既有复杂度、React hook、any 等告警。 +- 全量 `pnpm run validate`:PASS(exit code 0)。 + - 全量 ESLint:0 error / 147 warning,未超过 149 上限。 + - 工程/Contracts 边界:PASS。 + - 全量测试:48 files / 1012 pass / 0 fail。 + - Renderer/Main 构建:PASS。 +- `git diff --check`:PASS。 + +## 状态与停止声明 + +- 代码已实现:是。 +- CI:未运行。 +- 人工验收:未执行。 +- 真实任务执行:未执行;未上传素材、未点击平台提交、未消耗额度。 +- Git:全部改动未暂存、未提交、未推送,未创建或修改 PR。 +- 部署/发布:未部署、未启动或重启豆包工作室、未发布。 +- 受保护资产:未修改 Cookie、Token、账号 Session、任务台账、视频产物或真实平台任务。 + +下一步应先由总架构师独立复验本包,再按逐项授权处理候选提交/PR;前三包全部自动化收口后,才使用一次普通 5 秒视频做真实验收。 diff --git a/main/utils/persistenceNormalization.ts b/main/utils/persistenceNormalization.ts index eaa6c8f..d351cdc 100644 --- a/main/utils/persistenceNormalization.ts +++ b/main/utils/persistenceNormalization.ts @@ -441,13 +441,22 @@ function normalizeRuntime(raw: unknown, taskMode: GenerationMode, now: string): submittedAt: (() => { const v = r.submittedAt; return isValidISODate(v) ? v : undefined; })(), conversationUrl: asNonEmptyString(r.conversationUrl) ?? undefined, controlReadiness: isObject(r.controlReadiness) ? { + schemaVersion: r.controlReadiness.schemaVersion === 1 ? 1 : undefined, + ready: typeof r.controlReadiness.ready === 'boolean' ? r.controlReadiness.ready : undefined, + currentStage: optionalOneOf(r.controlReadiness.currentStage, ['account', 'new_conversation', 'video_entry', 'model', 'aspect_ratio', 'duration', 'assets', 'prompt', 'submission_controls']) as NonNullable['currentStage'], modeEntryElapsedMs: asNonNegativeNumber(r.controlReadiness.modeEntryElapsedMs, 0) || undefined, attempts: asNonNegativeNumber(r.controlReadiness.attempts, 0), elapsedMs: asNonNegativeNumber(r.controlReadiness.elapsedMs, 0), + stableSamples: asNonNegativeNumber(r.controlReadiness.stableSamples, 0) || undefined, + recoveryAttempts: asNonNegativeNumber(r.controlReadiness.recoveryAttempts, 0) || undefined, + pageStructureVersion: asNonEmptyString(r.controlReadiness.pageStructureVersion)?.slice(0, 96), + missingControl: optionalOneOf(r.controlReadiness.missingControl, ['account_page', 'conversation_editor', 'video_entry', 'model_control', 'aspect_ratio_control', 'duration_control', 'asset_upload', 'prompt_editor', 'send_control']) as NonNullable['missingControl'], modelVisibleAtMs: asNonNegativeNumber(r.controlReadiness.modelVisibleAtMs, 0) || undefined, + aspectRatioVisibleAtMs: asNonNegativeNumber(r.controlReadiness.aspectRatioVisibleAtMs, 0) || undefined, + durationVisibleAtMs: asNonNegativeNumber(r.controlReadiness.durationVisibleAtMs, 0) || undefined, compositeVisibleAtMs: asNonNegativeNumber(r.controlReadiness.compositeVisibleAtMs, 0) || undefined, stableAtMs: asNonNegativeNumber(r.controlReadiness.stableAtMs, 0) || undefined, - failureStage: optionalOneOf(r.controlReadiness.failureStage, ['mode_entry', 'model_control', 'composite_control', 'stable_readback', 'final_readback']) as NonNullable['failureStage'], + failureStage: optionalOneOf(r.controlReadiness.failureStage, ['account', 'new_conversation', 'video_entry', 'model', 'aspect_ratio', 'duration', 'assets', 'prompt', 'submission_controls', 'mode_entry', 'model_control', 'composite_control', 'stable_readback', 'final_readback']) as NonNullable['failureStage'], } : undefined, generationConfirmation: isObject(r.generationConfirmation) && isValidISODate(r.generationConfirmation.detectedAt) && diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 8102d2c..d589fb8 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -132,13 +132,22 @@ export interface TaskRunSnapshot { submittedAt?: string; conversationUrl?: string; controlReadiness?: { + schemaVersion?: 1; + ready?: boolean; + currentStage?: 'account' | 'new_conversation' | 'video_entry' | 'model' | 'aspect_ratio' | 'duration' | 'assets' | 'prompt' | 'submission_controls'; modeEntryElapsedMs?: number; attempts: number; elapsedMs: number; + stableSamples?: number; + recoveryAttempts?: number; + pageStructureVersion?: string; + missingControl?: 'account_page' | 'conversation_editor' | 'video_entry' | 'model_control' | 'aspect_ratio_control' | 'duration_control' | 'asset_upload' | 'prompt_editor' | 'send_control'; modelVisibleAtMs?: number; + aspectRatioVisibleAtMs?: number; + durationVisibleAtMs?: number; compositeVisibleAtMs?: number; stableAtMs?: number; - failureStage?: 'mode_entry' | 'model_control' | 'composite_control' | 'stable_readback' | 'final_readback'; + failureStage?: 'account' | 'new_conversation' | 'video_entry' | 'model' | 'aspect_ratio' | 'duration' | 'assets' | 'prompt' | 'submission_controls' | 'mode_entry' | 'model_control' | 'composite_control' | 'stable_readback' | 'final_readback'; }; generationConfirmation?: { detectedAt: string; diff --git a/packages/contracts/src/enums.ts b/packages/contracts/src/enums.ts index e05b53b..07eaaa8 100644 --- a/packages/contracts/src/enums.ts +++ b/packages/contracts/src/enums.ts @@ -71,7 +71,14 @@ export type TaskErrorCode = | 'generation_confirmation_required' | 'manual_review_required' | 'video_mode_entry_not_ready' + | 'video_account_not_ready' + | 'video_new_conversation_not_ready' | 'video_model_control_not_ready' + | 'video_aspect_ratio_control_not_ready' + | 'video_duration_control_not_ready' + | 'video_assets_not_ready' + | 'video_prompt_not_ready' + | 'video_submission_controls_not_ready' | 'video_composite_control_not_ready' | 'video_stable_readback_not_ready' | 'video_final_readback_not_ready' diff --git a/src/components/BrowserPanel.tsx b/src/components/BrowserPanel.tsx index a41a61e..592ff65 100755 --- a/src/components/BrowserPanel.tsx +++ b/src/components/BrowserPanel.tsx @@ -65,7 +65,8 @@ import { isExperimentalNoWatermarkEnabled, setExperimentalNoWatermarkEnabled } f import { decideMaterialAuthorizationProgress } from '../utils/materialAuthorization'; import { selectQuotaFallbackAccount } from '../utils/quotaRecovery'; import type { GenerationConfirmationEvidence } from '../utils/generationConfirmation'; -import { VideoControlReadinessError } from '../utils/videoControlReadiness'; +import { createVideoReadinessCheckpoint, VideoControlReadinessError } from '../utils/videoControlReadiness'; +import type { VideoControlReadinessResult, VideoPageReadinessStage } from '../utils/videoControlReadiness'; import { initializationGate } from '../utils/initializationGate'; import type { InitializationLease } from '../utils/initializationGate'; import { @@ -1150,6 +1151,33 @@ const BrowserPanel: React.FC = ({ let initializationLease: InitializationLease | undefined; let autoConfirmMaterialAuthorization = false; let initializationLimit = 1; + let videoReadinessStartedAt = Date.now(); + let videoReadinessRecoveryAttempts = 0; + const persistVideoReadiness = async ( + stage: VideoPageReadinessStage, + diagnostic?: VideoControlReadinessResult, + ): Promise => { + if (mode !== 'video') return; + const current = useTaskStore.getState().tasks.find((item) => item.id === taskId)?.runtime?.controlReadiness; + const checkpoint = diagnostic ?? createVideoReadinessCheckpoint(stage, { + elapsedMs: Date.now() - videoReadinessStartedAt, + recoveryAttempts: videoReadinessRecoveryAttempts, + }); + await updateTaskRuntime(taskId, { + runtime: { + controlReadiness: { + ...current, + ...checkpoint, + currentStage: stage, + recoveryAttempts: videoReadinessRecoveryAttempts, + pageStructureVersion: checkpoint.pageStructureVersion ?? current?.pageStructureVersion, + failureStage: checkpoint.failureStage, + missingControl: checkpoint.missingControl, + }, + lastHeartbeatAt: new Date().toISOString(), + }, + }); + }; const clearKnownPromotion = async (): Promise => { const result = await dismissKnownDesktopDownloadPromotion(webview); if (result === 'failed') { @@ -1177,9 +1205,16 @@ const BrowserPanel: React.FC = ({ await clearKnownPromotion(); const preTaskAvailability = await performAvailabilityCheck(accountId, webview, 'pre_task'); if (availabilityBlocksAutomation(preTaskAvailability)) { + if (mode === 'video') { + await persistVideoReadiness('account', { + ...createVideoReadinessCheckpoint('account'), ready: false, failureStage: 'account', missingControl: 'account_page', + }); + } useAccountStore.getState().selectAccount(accountId); throw new AccountAvailabilityPauseError(preTaskAvailability.message, preTaskAvailability.reason); } + videoReadinessStartedAt = Date.now(); + await persistVideoReadiness('account'); // 视频能力预检:在提交前基于本地已知状态判断是否允许提交 if (mode === 'video' && videoConfig) { @@ -1219,12 +1254,19 @@ const BrowserPanel: React.FC = ({ throw new AccountAvailabilityPauseError(failedAvailability.message, failedAvailability.reason); } // reason 为脱敏状态码(如 PAGE_NOT_READY/EDITOR_NOT_FOUND/SESSION_NOT_EMPTY),不泄露页面细节。 + if (mode === 'video') { + throw new VideoControlReadinessError('new_conversation', { + ...createVideoReadinessCheckpoint('new_conversation'), ready: false, failureStage: 'new_conversation', + missingControl: 'conversation_editor', + }); + } throw new Error(`创建新对话失败(${newConversationResult.reason || 'PAGE_UNKNOWN'})`); } await waitForWebviewReady(webview, 15000); await clearKnownPromotion(); taskConversationUrl = webview.getURL(); await updateTaskRuntime(taskId, { runtime: { conversationUrl: taskConversationUrl } }); + await persistVideoReadiness('new_conversation'); // 根据任务模式切换到对应页面 if (mode && mode !== 'chat') { const modeLabel = mode === 'image' ? '图片' : mode === 'video' ? '视频' : mode === 'music' ? '音乐' : mode; @@ -1247,6 +1289,7 @@ const BrowserPanel: React.FC = ({ } throw new Error(`${modeLabel}模式入口未就绪,已在提交前停止`); } + if (mode === 'video') await persistVideoReadiness('video_entry'); await pause(1500); // 等待 Tab 切换动画 } @@ -1254,24 +1297,25 @@ const BrowserPanel: React.FC = ({ if (mode === 'video') { if (videoConfig) { setAccountAutomationState(accountId, 'injecting', '配置视频参数...', 'configuring'); - await configureVideoOptions(webview, videoConfig, { - onReadiness: async (diagnostic) => { - await updateTaskRuntime(taskId, { - runtime: { - controlReadiness: { - modeEntryElapsedMs, - attempts: diagnostic.attempts, - elapsedMs: diagnostic.elapsedMs, - modelVisibleAtMs: diagnostic.modelVisibleAtMs, - compositeVisibleAtMs: diagnostic.compositeVisibleAtMs, - stableAtMs: diagnostic.stableAtMs, - failureStage: diagnostic.failureStage, - }, - lastHeartbeatAt: new Date().toISOString(), - }, - }); - }, + const configure = () => configureVideoOptions(webview, videoConfig, { + onReadiness: async (diagnostic) => persistVideoReadiness(diagnostic.currentStage || 'model', { + ...diagnostic, modeEntryElapsedMs, + }), }); + try { + await configure(); + } catch (error: unknown) { + if (!(error instanceof VideoControlReadinessError) || videoReadinessRecoveryAttempts >= 1) throw error; + // 仅在尚未产生提交意图时允许一次页面重绘恢复;配置函数逐项回读,重复执行是幂等的。 + videoReadinessRecoveryAttempts += 1; + await persistVideoReadiness(error.diagnostic.currentStage || 'model', { + ...error.diagnostic, recoveryAttempts: videoReadinessRecoveryAttempts, + }); + const recovered = await clickAITab(webview, 'video'); + if (!recovered) throw error; + await pause(750); + await configure(); + } await pause(500); } } @@ -1296,7 +1340,9 @@ const BrowserPanel: React.FC = ({ } } if (fileDataList.length !== attachments.length) { - throw new Error('参考图片读取不完整,已在提交前停止'); + throw new VideoControlReadinessError('assets', { + ...createVideoReadinessCheckpoint('assets'), ready: false, failureStage: 'assets', missingControl: 'asset_upload', + }); } let lastUploadPersistAt = -Infinity; const uploaded = await uploadReferenceImages(webview, fileDataList, { @@ -1311,7 +1357,9 @@ const BrowserPanel: React.FC = ({ }); }, }); - if (!uploaded) throw new Error('参考图片未全部上传稳定,已在提交前停止'); + if (!uploaded) throw new VideoControlReadinessError('assets', { + ...createVideoReadinessCheckpoint('assets'), ready: false, failureStage: 'assets', missingControl: 'asset_upload', + }); } // 有参考音频时上传(仅视频模式) @@ -1325,15 +1373,24 @@ const BrowserPanel: React.FC = ({ const mime = mimeMatch ? mimeMatch[1] : 'audio/mpeg'; const base64 = result.data.replace(/^data:audio\/[\w.+-]+;base64,/, ''); const uploaded = await uploadReferenceAudio(webview, { name: fileName, base64, mime }); - if (!uploaded) throw new Error('参考音频未上传完成,已在提交前停止'); + if (!uploaded) throw new VideoControlReadinessError('assets', { + ...createVideoReadinessCheckpoint('assets'), ready: false, failureStage: 'assets', missingControl: 'asset_upload', + }); } else { - throw new Error('参考音频读取失败,已在提交前停止'); + throw new VideoControlReadinessError('assets', { + ...createVideoReadinessCheckpoint('assets'), ready: false, failureStage: 'assets', missingControl: 'asset_upload', + }); } } catch (error: unknown) { + if (error instanceof VideoControlReadinessError) throw error; const reason = error instanceof Error ? error.message : '参考音频处理失败'; - throw new Error(reason.includes('已在提交前停止') ? reason : '参考音频处理失败,已在提交前停止'); + throw new VideoControlReadinessError('assets', { + ...createVideoReadinessCheckpoint('assets'), ready: false, failureStage: 'assets', missingControl: 'asset_upload', + elapsedMs: reason.includes('超时') ? 60_000 : 0, + }); } } + if (mode === 'video') await persistVideoReadiness('assets'); } else { await waitForWebviewReady(webview, 15000); } @@ -1352,15 +1409,27 @@ const BrowserPanel: React.FC = ({ } setAccountAutomationState(accountId, 'injecting', '正在注入提示词...', 'injecting_prompt'); - const injected = await Promise.race([ - injectPrompt(webview, prompt), - new Promise((_, rej) => setTimeout(() => rej(new Error('注入超时')), 60000)), - ]); - if (!injected) throw new Error('注入失败'); + let injected = false; + try { + injected = await Promise.race([ + injectPrompt(webview, prompt), + new Promise((_, rej) => setTimeout(() => rej(new Error('注入超时')), 60000)), + ]); + } catch { + throw new VideoControlReadinessError('prompt', { + ...createVideoReadinessCheckpoint('prompt'), ready: false, failureStage: 'prompt', missingControl: 'prompt_editor', elapsedMs: 60_000, + }); + } + if (!injected) throw new VideoControlReadinessError('prompt', { + ...createVideoReadinessCheckpoint('prompt'), ready: false, failureStage: 'prompt', missingControl: 'prompt_editor', + }); await pause(800); if (!await verifyPromptReadyForSubmission(webview, prompt)) { - throw new Error('提示词全文回读不一致,可能存在截断或台词缺失;已在提交前停止'); + throw new VideoControlReadinessError('prompt', { + ...createVideoReadinessCheckpoint('prompt'), ready: false, failureStage: 'prompt', missingControl: 'prompt_editor', + }); } + await persistVideoReadiness('prompt'); // 素材确认可能在用户点击后直接继续发送。先取只读基线,确认后只回读, // 绝不能再点一次发送。 @@ -1494,8 +1563,19 @@ const BrowserPanel: React.FC = ({ } if (!authorizationTriggeredSubmission && !await waitForSubmissionControlsStable(webview)) { - throw new Error('发送控件或素材上传状态未连续稳定,已在提交前停止'); + throw new VideoControlReadinessError('submission_controls', { + ...createVideoReadinessCheckpoint('submission_controls', { + elapsedMs: Date.now() - videoReadinessStartedAt, + attempts: 1, + stableSamples: 0, + recoveryAttempts: videoReadinessRecoveryAttempts, + }), + ready: false, + failureStage: 'submission_controls', + missingControl: 'send_control', + }); } + await persistVideoReadiness('submission_controls'); // P0:真实发送是不可重复副作用。先从当前任务快照核对 runId/submittedAt, // 再把本次提交意图持久化;写入失败或运行已变化时绝不点击页面。 @@ -1748,6 +1828,13 @@ const BrowserPanel: React.FC = ({ const availabilityPaused = err instanceof AccountAvailabilityPauseError; const confirmationPaused = err instanceof GenerationConfirmationPauseError; const controlReadinessFailure = err instanceof VideoControlReadinessError; + if (controlReadinessFailure && mode === 'video') { + try { + await persistVideoReadiness(err.diagnostic.currentStage || 'submission_controls', err.diagnostic); + } catch (persistError) { + console.error(`[Automation:${accountId}] 视频就绪诊断持久化失败:`, persistError); + } + } const errorMessage = cancelled ? '用户已取消等待' : (err.message || String(err)); const errorInfo = controlReadinessFailure ? { code: err.code, message: errorMessage, recoverable: true, detectedAt: new Date().toISOString() } diff --git a/src/utils/doubaoBridge.ts b/src/utils/doubaoBridge.ts index 45ed361..3bfb9ce 100755 --- a/src/utils/doubaoBridge.ts +++ b/src/utils/doubaoBridge.ts @@ -3968,9 +3968,16 @@ async function configureVideoOptionsV2( } const click = async (position: string | undefined, label: string): Promise => { - if (!position) throw new Error(`${label}不可见,已在提交前停止`); + const stage = label.includes('模型') ? 'model' : label.includes('时长') ? 'duration' : 'aspect_ratio'; + const missingControl = stage === 'model' ? 'model_control' : stage === 'duration' ? 'duration_control' : 'aspect_ratio_control'; + const fail = (): never => { + throw new VideoControlReadinessError(stage, { + ready: false, failureStage: stage, missingControl, attempts: 1, elapsedMs: 0, + }); + }; + if (!position) return fail(); const [x, y] = position.split(',').map(Number); - if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`${label}坐标无效,已在提交前停止`); + if (!Number.isFinite(x) || !Number.isFinite(y)) fail(); // webview.sendInputEvent 的坐标在新版嵌套页面会落到宿主视口而非创作页。 // 这里按页面内命中元素派发完整指针/鼠标序列,仍只触发用户可见控件。 const activated = await safeExecuteJS(webview, ` @@ -3989,7 +3996,7 @@ async function configureVideoOptionsV2( return true; })() `, 5000, `activate_${label}`); - if (!activated) throw new Error(`${label}无法激活,已在提交前停止`); + if (!activated) fail(); await sleep(80); }; @@ -4018,7 +4025,7 @@ async function configureVideoOptionsV2( // 模式切换后 React 会先显示创作栏、再挂载参数控件。必须等两个权威控件 // 都实际可用后才继续;15 秒后仍未挂载则 fail-closed,不猜测、更不提交。 const waitForAuthoritativeControls = async (): Promise => { - return waitForVideoControlReadiness(async () => page<{ modelReady: boolean; compositeReady: boolean }>(` + return waitForVideoControlReadiness(async () => page<{ modelReady: boolean; aspectRatioReady: boolean; durationReady: boolean; pageVariant: 'composite-v2' }>(` (function () { function usable(el) { if (!el) return false; @@ -4029,12 +4036,15 @@ async function configureVideoOptionsV2( var model = document.querySelector(${JSON.stringify(VIDEO_MODEL_CONTROL_SELECTOR)}); var composite = document.querySelector(${JSON.stringify(VIDEO_COMPOSITE_CONTROL_SELECTOR)}); var compositeText = composite ? (composite.innerText || '').replace(/\\s+/g, ' ').trim() : ''; + var compositeMatch = compositeText.match(/^(?:自动|[0-9]+:[0-9]+)\\s*·\\s*([0-9]+)s$/); return { modelReady: usable(model), - compositeReady: usable(composite) && /^(?:自动|[0-9]+:[0-9]+)\\s*·\\s*[0-9]+s$/.test(compositeText) + aspectRatioReady: usable(composite) && Boolean(compositeMatch), + durationReady: usable(composite) && Boolean(compositeMatch), + pageVariant: 'composite-v2' }; })() - `, 'wait_video_controls').catch(() => ({ modelReady: false, compositeReady: false })), { timeoutMs: 30_000, stableSamples: 3 }); + `, 'wait_video_controls').catch(() => ({ modelReady: false, aspectRatioReady: false, durationReady: false, pageVariant: 'composite-v2' as const })), { timeoutMs: 30_000, stableSamples: 3 }); }; const readiness = await waitForAuthoritativeControls(); @@ -4174,7 +4184,12 @@ async function configureVideoOptionsV2( await click(await findModelTrigger(), '视频模型控件'); if (await membershipBlocked()) throw new Error('membership_required: 视频模型需要会员操作,已停止提交'); await click(await waitForExactOverlayOption(modelLabel, 'video_model'), '视频模型选项'); - if (!await waitForModelVerification()) throw new Error(`视频模型配置回读失败: ${config.model}`); + if (!await waitForModelVerification()) { + throw new VideoControlReadinessError('model', { + ready: false, failureStage: 'model', missingControl: 'model_control', attempts: 1, elapsedMs: 10_000, + pageStructureVersion: readiness.pageStructureVersion, + }); + } } const currentCompositeLabel = async (): Promise => page(` @@ -4224,11 +4239,13 @@ async function configureVideoOptionsV2( const verifyFinalStableReadback = async (): Promise => { const finalResult = await waitForVideoControlReadiness(async () => ({ modelReady: await verifyModel().catch(() => false), - compositeReady: await isCompositeAlreadyConfigured().catch(() => false), + aspectRatioReady: await isCompositeAlreadyConfigured().catch(() => false), + durationReady: await isCompositeAlreadyConfigured().catch(() => false), + pageVariant: 'composite-v2' as const, }), { timeoutMs: 10_000, stableSamples: 3 }); - const reported = finalResult.ready ? finalResult : { ...finalResult, failureStage: 'final_readback' as const }; + const reported = finalResult.ready ? finalResult : { ...finalResult, failureStage: finalResult.failureStage || 'submission_controls' }; await options?.onReadiness?.(reported); - if (!finalResult.ready) throw new VideoControlReadinessError('final_readback', reported); + if (!finalResult.ready) throw new VideoControlReadinessError(reported.failureStage || 'submission_controls', reported); }; if (await membershipBlocked()) throw new Error('membership_required: 视频参数需要会员操作,已停止提交'); if (await isCompositeAlreadyConfigured()) { @@ -4257,7 +4274,12 @@ async function configureVideoOptionsV2( await openComposite(); slider = await findDurationSlider(); } - if (!slider.position) throw new Error(`视频时长控件不可见: ${config.duration}`); + if (!slider.position) { + throw new VideoControlReadinessError('duration', { + ready: false, failureStage: 'duration', missingControl: 'duration_control', attempts: 2, elapsedMs: 700, + pageStructureVersion: readiness.pageStructureVersion, + }); + } if (typeof webview.sendInputEvent === 'function') { await click(slider.position, '视频时长控件'); webview.sendInputEvent({ type: 'keyDown', keyCode: 'Home' }); @@ -4283,7 +4305,11 @@ async function configureVideoOptionsV2( `, 'verify_video_composite'); if (await membershipBlocked()) throw new Error('membership_required: 视频参数需要会员操作,已停止提交'); if (!configured.slider || !configured.composite) { - throw new Error(`视频比例或时长配置回读失败: ${getVideoCompositeLabel(config.aspectRatio, config.duration)}`); + const stage = configured.slider ? 'aspect_ratio' : 'duration'; + throw new VideoControlReadinessError(stage, { + ready: false, failureStage: stage, missingControl: configured.slider ? 'aspect_ratio_control' : 'duration_control', + attempts: 1, elapsedMs: 350, pageStructureVersion: readiness.pageStructureVersion, + }); } await verifyFinalStableReadback(); } diff --git a/src/utils/videoControlReadiness.ts b/src/utils/videoControlReadiness.ts index dc92c1d..70c0461 100644 --- a/src/utils/videoControlReadiness.ts +++ b/src/utils/videoControlReadiness.ts @@ -1,67 +1,151 @@ -export type VideoControlReadinessStage = +export const VIDEO_PAGE_READINESS_SCHEMA_VERSION = 1 as const; + +export type VideoPageReadinessStage = + | 'account' + | 'new_conversation' + | 'video_entry' + | 'model' + | 'aspect_ratio' + | 'duration' + | 'assets' + | 'prompt' + | 'submission_controls'; + +/** 兼容旧快照的阶段名;新写入只使用 VideoPageReadinessStage。 */ +export type VideoControlReadinessStage = VideoPageReadinessStage | 'mode_entry' | 'model_control' | 'composite_control' | 'stable_readback' | 'final_readback'; +export type VideoReadinessMissingControl = + | 'account_page' + | 'conversation_editor' + | 'video_entry' + | 'model_control' + | 'aspect_ratio_control' + | 'duration_control' + | 'asset_upload' + | 'prompt_editor' + | 'send_control'; + export interface VideoControlProbe { modelReady: boolean; - compositeReady: boolean; + /** 旧调用方兼容字段;未提供细分值时同时代表比例与时长。 */ + compositeReady?: boolean; + aspectRatioReady?: boolean; + durationReady?: boolean; + pageVariant?: 'legacy' | 'composite-v2' | 'unknown'; } export interface VideoControlReadinessResult { + schemaVersion?: typeof VIDEO_PAGE_READINESS_SCHEMA_VERSION; ready: boolean; + currentStage?: VideoPageReadinessStage; failureStage?: VideoControlReadinessStage; + missingControl?: VideoReadinessMissingControl; + pageStructureVersion?: string; attempts: number; elapsedMs: number; + stableSamples?: number; + recoveryAttempts?: number; + modeEntryElapsedMs?: number; modelVisibleAtMs?: number; + aspectRatioVisibleAtMs?: number; + durationVisibleAtMs?: number; compositeVisibleAtMs?: number; stableAtMs?: number; } +const FAILURE_CONTROL: Partial> = { + account: 'account_page', new_conversation: 'conversation_editor', video_entry: 'video_entry', mode_entry: 'video_entry', + model: 'model_control', model_control: 'model_control', aspect_ratio: 'aspect_ratio_control', duration: 'duration_control', + assets: 'asset_upload', prompt: 'prompt_editor', submission_controls: 'send_control', composite_control: 'aspect_ratio_control', +}; + export class VideoControlReadinessError extends Error { readonly code: string; readonly diagnostic: VideoControlReadinessResult; constructor(stage: VideoControlReadinessStage, diagnostic: VideoControlReadinessResult) { - const code = `video_${stage}_not_ready`; - super(`${code}: ${stage} 未在有界等待内就绪(${diagnostic.elapsedMs}ms/${diagnostic.attempts}次),已在提交前停止`); + const normalizedStage = stage === 'mode_entry' ? 'video_entry' + : stage === 'model_control' ? 'model' + : stage === 'composite_control' ? 'aspect_ratio' + : stage; + const codeByStage: Record = { + account: 'video_account_not_ready', new_conversation: 'video_new_conversation_not_ready', + video_entry: 'video_mode_entry_not_ready', model: 'video_model_control_not_ready', + aspect_ratio: 'video_aspect_ratio_control_not_ready', duration: 'video_duration_control_not_ready', + assets: 'video_assets_not_ready', prompt: 'video_prompt_not_ready', + submission_controls: 'video_submission_controls_not_ready', + stable_readback: 'video_stable_readback_not_ready', final_readback: 'video_final_readback_not_ready', + }; + const code = codeByStage[normalizedStage] ?? `video_${normalizedStage}_not_ready`; + super(`${code}: ${normalizedStage} 未在有界等待内就绪(${diagnostic.elapsedMs}ms/${diagnostic.attempts}次),已在提交前停止`); this.name = 'VideoControlReadinessError'; this.code = code; - this.diagnostic = { ...diagnostic, ready: false, failureStage: stage }; + this.diagnostic = { + schemaVersion: VIDEO_PAGE_READINESS_SCHEMA_VERSION, ...diagnostic, ready: false, + failureStage: normalizedStage, currentStage: normalizedStage as VideoPageReadinessStage, + missingControl: diagnostic.missingControl ?? FAILURE_CONTROL[stage], + }; } } -/** 有界指数退避;模型与组合控件需连续三次同时就绪。 */ +/** 只编码能力位和适配器版本,不含 DOM 文本、账号、提示词、素材路径或会话数据。 */ +export function buildVideoPageStructureVersion(probe: VideoControlProbe): string { + const ratio = probe.aspectRatioReady ?? probe.compositeReady ?? false; + const duration = probe.durationReady ?? probe.compositeReady ?? false; + return `doubao-video-v2:${probe.pageVariant ?? 'unknown'}:m${Number(probe.modelReady)}r${Number(ratio)}d${Number(duration)}`; +} + +/** 为 BrowserPanel 的九阶段检查点生成最小、脱敏且可持久化的诊断。 */ +export function createVideoReadinessCheckpoint( + stage: VideoPageReadinessStage, + options: { elapsedMs?: number; attempts?: number; stableSamples?: number; recoveryAttempts?: number; pageStructureVersion?: string } = {}, +): VideoControlReadinessResult { + return { + schemaVersion: VIDEO_PAGE_READINESS_SCHEMA_VERSION, ready: stage === 'submission_controls', currentStage: stage, + attempts: options.attempts ?? 1, elapsedMs: Math.max(0, options.elapsedMs ?? 0), stableSamples: options.stableSamples ?? 1, + recoveryAttempts: options.recoveryAttempts ?? 0, pageStructureVersion: options.pageStructureVersion, + }; +} + +/** 有界指数退避;模型、比例与时长控件需连续稳定采样。 */ export async function waitForVideoControlReadiness( probe: () => VideoControlProbe | Promise, - options: { - timeoutMs?: number; - stableSamples?: number; - now?: () => number; - wait?: (ms: number) => Promise; - } = {}, + options: { timeoutMs?: number; stableSamples?: number; recoveryAttempts?: number; now?: () => number; wait?: (ms: number) => Promise } = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 30_000; - const stableSamples = options.stableSamples ?? 3; + const requiredStableSamples = options.stableSamples ?? 3; const now = options.now ?? Date.now; const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); const startedAt = now(); let attempts = 0; let stable = 0; + let lastProbe: VideoControlProbe = { modelReady: false, compositeReady: false, pageVariant: 'unknown' }; let modelVisibleAtMs: number | undefined; - let compositeVisibleAtMs: number | undefined; + let aspectRatioVisibleAtMs: number | undefined; + let durationVisibleAtMs: number | undefined; while (now() - startedAt <= timeoutMs) { attempts += 1; - const snapshot = await probe(); + lastProbe = await probe(); const elapsedMs = Math.max(0, now() - startedAt); - if (snapshot.modelReady && modelVisibleAtMs === undefined) modelVisibleAtMs = elapsedMs; - if (snapshot.compositeReady && compositeVisibleAtMs === undefined) compositeVisibleAtMs = elapsedMs; - stable = snapshot.modelReady && snapshot.compositeReady ? stable + 1 : 0; - if (stable >= stableSamples) { - return { ready: true, attempts, elapsedMs, modelVisibleAtMs, compositeVisibleAtMs, stableAtMs: elapsedMs }; + const ratioReady = lastProbe.aspectRatioReady ?? lastProbe.compositeReady ?? false; + const durationReady = lastProbe.durationReady ?? lastProbe.compositeReady ?? false; + if (lastProbe.modelReady && modelVisibleAtMs === undefined) modelVisibleAtMs = elapsedMs; + if (ratioReady && aspectRatioVisibleAtMs === undefined) aspectRatioVisibleAtMs = elapsedMs; + if (durationReady && durationVisibleAtMs === undefined) durationVisibleAtMs = elapsedMs; + stable = lastProbe.modelReady && ratioReady && durationReady ? stable + 1 : 0; + if (stable >= requiredStableSamples) { + return { + schemaVersion: VIDEO_PAGE_READINESS_SCHEMA_VERSION, ready: true, currentStage: 'duration', attempts, elapsedMs, + stableSamples: stable, recoveryAttempts: options.recoveryAttempts ?? 0, pageStructureVersion: buildVideoPageStructureVersion(lastProbe), + modelVisibleAtMs, aspectRatioVisibleAtMs, durationVisibleAtMs, + compositeVisibleAtMs: Math.max(aspectRatioVisibleAtMs ?? 0, durationVisibleAtMs ?? 0), stableAtMs: elapsedMs, + }; } const remaining = timeoutMs - elapsedMs; if (remaining <= 0) break; @@ -70,10 +154,13 @@ export async function waitForVideoControlReadiness( } const elapsedMs = Math.max(0, now() - startedAt); - const failureStage: VideoControlReadinessStage = modelVisibleAtMs === undefined - ? 'model_control' - : compositeVisibleAtMs === undefined - ? 'composite_control' - : 'stable_readback'; - return { ready: false, failureStage, attempts, elapsedMs, modelVisibleAtMs, compositeVisibleAtMs }; + const ratioReady = lastProbe.aspectRatioReady ?? lastProbe.compositeReady ?? false; + const durationReady = lastProbe.durationReady ?? lastProbe.compositeReady ?? false; + const failureStage: VideoPageReadinessStage = !lastProbe.modelReady ? 'model' : !ratioReady ? 'aspect_ratio' : !durationReady ? 'duration' : 'submission_controls'; + return { + schemaVersion: VIDEO_PAGE_READINESS_SCHEMA_VERSION, ready: false, currentStage: failureStage, failureStage, + missingControl: FAILURE_CONTROL[failureStage], pageStructureVersion: buildVideoPageStructureVersion(lastProbe), attempts, elapsedMs, + stableSamples: stable, recoveryAttempts: options.recoveryAttempts ?? 0, modelVisibleAtMs, aspectRatioVisibleAtMs, durationVisibleAtMs, + compositeVisibleAtMs: aspectRatioVisibleAtMs !== undefined && durationVisibleAtMs !== undefined ? Math.max(aspectRatioVisibleAtMs, durationVisibleAtMs) : undefined, + }; } diff --git a/tests/unit/generationControlReadiness.test.ts b/tests/unit/generationControlReadiness.test.ts index 8e95702..75bf337 100644 --- a/tests/unit/generationControlReadiness.test.ts +++ b/tests/unit/generationControlReadiness.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { classifyGenerationConfirmationText, isGenerationConfirmationTask } from '../../src/utils/generationConfirmation'; -import { VideoControlReadinessError, waitForVideoControlReadiness } from '../../src/utils/videoControlReadiness'; +import { + buildVideoPageStructureVersion, + createVideoReadinessCheckpoint, + VideoControlReadinessError, + waitForVideoControlReadiness, +} from '../../src/utils/videoControlReadiness'; import { matchesSubmissionConversation, requiresSubmissionReconciliation } from '../../src/utils/realSendStateMachine'; describe('视频生成参数确认独立状态', () => { @@ -72,16 +77,40 @@ describe('视频参数控件有界分段等待', () => { }); it.each([ - [{ modelReady: false, compositeReady: true }, 'model_control'], - [{ modelReady: true, compositeReady: false }, 'composite_control'], - ] as const)('缺失权威控件时输出具体失败阶段', async (snapshot, failureStage) => { + [{ modelReady: false, aspectRatioReady: true, durationReady: true }, 'model', 'model_control'], + [{ modelReady: true, aspectRatioReady: false, durationReady: true }, 'aspect_ratio', 'aspect_ratio_control'], + [{ modelReady: true, aspectRatioReady: true, durationReady: false }, 'duration', 'duration_control'], + ] as const)('缺失权威控件时输出具体失败阶段', async (snapshot, failureStage, missingControl) => { let now = 0; const result = await waitForVideoControlReadiness(() => snapshot, { timeoutMs: 1_000, now: () => now, wait: async (ms) => { now += ms; }, }); - expect(result).toMatchObject({ ready: false, failureStage }); + expect(result).toMatchObject({ ready: false, failureStage, missingControl }); }); + it('比例和时长必须独立就绪,组合控件不再掩盖具体缺失项', async () => { + let now = 0; + const result = await waitForVideoControlReadiness( + () => ({ modelReady: true, aspectRatioReady: true, durationReady: false, pageVariant: 'composite-v2' }), + { timeoutMs: 500, now: () => now, wait: async (ms) => { now += ms; } }, + ); + expect(result).toMatchObject({ failureStage: 'duration', pageStructureVersion: 'doubao-video-v2:composite-v2:m1r1d0' }); + }); + + it('结构版本只由白名单能力位生成,不接受页面文本', () => { + const version = buildVideoPageStructureVersion({ modelReady: true, aspectRatioReady: false, durationReady: true, pageVariant: 'composite-v2' }); + expect(version).toBe('doubao-video-v2:composite-v2:m1r0d1'); + expect(version).not.toMatch(/prompt|cookie|session|D:\\|https?:/i); + }); + + it.each(['account', 'new_conversation', 'video_entry', 'model', 'aspect_ratio', 'duration', 'assets', 'prompt', 'submission_controls'] as const)( + '九阶段检查点 %s 使用统一 schema 且不包含业务正文', (stage) => { + const checkpoint = createVideoReadinessCheckpoint(stage, { elapsedMs: 123, recoveryAttempts: 1 }); + expect(checkpoint).toMatchObject({ schemaVersion: 1, currentStage: stage, elapsedMs: 123, recoveryAttempts: 1 }); + expect(JSON.stringify(checkpoint)).not.toMatch(/提示词|素材路径|cookie|token|session/i); + }, + ); + it('指数退避有上限且总等待受 timeout 约束', async () => { let now = 0; const delays: number[] = []; @@ -94,10 +123,18 @@ describe('视频参数控件有界分段等待', () => { expect(delays.reduce((sum, value) => sum + value, 0)).toBe(5_000); }); - it.each(['mode_entry', 'model_control', 'composite_control', 'stable_readback', 'final_readback'] as const)( - '错误 %s 为机器可读阶段码且明确提交前停止', (stage) => { + it.each([ + ['mode_entry', 'video_mode_entry_not_ready'], + ['model_control', 'video_model_control_not_ready'], + ['composite_control', 'video_aspect_ratio_control_not_ready'], + ['duration', 'video_duration_control_not_ready'], + ['submission_controls', 'video_submission_controls_not_ready'], + ['stable_readback', 'video_stable_readback_not_ready'], + ['final_readback', 'video_final_readback_not_ready'], + ] as const)( + '错误 %s 为机器可读阶段码且明确提交前停止', (stage, code) => { const error = new VideoControlReadinessError(stage, { ready: false, failureStage: stage, attempts: 3, elapsedMs: 1000 }); - expect(error.code).toBe(`video_${stage}_not_ready`); + expect(error.code).toBe(code); expect(error.message).toContain('已在提交前停止'); }, ); @@ -126,4 +163,21 @@ describe('源码安全边界', () => { expect(body).not.toContain('submitPromptWithNativeClick('); expect(body).not.toContain('startNewConversation('); }); + + it('提交前恢复严格限制一次,提交后不走配置恢复分支', () => { + expect(panel).toContain('videoReadinessRecoveryAttempts >= 1'); + expect(panel).toContain("if (!submissionMarkedAt)"); + const recoveryStart = panel.indexOf('仅在尚未产生提交意图时允许一次页面重绘恢复'); + const submissionStart = panel.indexOf("const markSubmissionIntent", recoveryStart); + expect(recoveryStart).toBeGreaterThan(-1); + expect(submissionStart).toBeGreaterThan(recoveryStart); + }); + + it('提交不确定仍走安全暂停且只有明确回读才进入 generating', () => { + expect(panel).toContain("throw new SubmissionSafetyPauseError('发送状态不确定"); + expect(panel).toContain("classifySubmissionReadback(evidence) === 'confirmed'"); + const confirmationGuard = panel.indexOf("if (!submissionConfirmed)"); + expect(confirmationGuard).toBeGreaterThan(-1); + expect(confirmationGuard).toBeLessThan(panel.indexOf("'generating'", confirmationGuard)); + }); }); diff --git a/tests/unit/persistenceNormalization.test.ts b/tests/unit/persistenceNormalization.test.ts index 5daf522..579af2e 100644 --- a/tests/unit/persistenceNormalization.test.ts +++ b/tests/unit/persistenceNormalization.test.ts @@ -637,14 +637,41 @@ describe('normalizeTasks', () => { startedAt: NOW, stageStartedAt: NOW, lastHeartbeatAt: NOW, submittedAt: NOW, conversationUrl: 'https://www.doubao.com/chat/conversation-1', input: { prompt: task.prompt, mode: 'video', attachments: [] }, - controlReadiness: { attempts: 5, elapsedMs: 3250, modelVisibleAtMs: 250, compositeVisibleAtMs: 750, stableAtMs: 3250 }, + controlReadiness: { + schemaVersion: 1, currentStage: 'duration', attempts: 5, elapsedMs: 3250, stableSamples: 3, recoveryAttempts: 1, + pageStructureVersion: 'doubao-video-v2:composite-v2:m1r1d1', modelVisibleAtMs: 250, + aspectRatioVisibleAtMs: 500, durationVisibleAtMs: 750, compositeVisibleAtMs: 750, stableAtMs: 3250, + }, generationConfirmation: { detectedAt: NOW, marker: 'parameter_confirmation', model: 'Seedance 2.0 Fast', duration: '5s', aspectRatio: '9:16' }, }; const normalized = normalizeTasks([task], DEFAULT_PROJECT_ID, NOW).data[0]; expect(normalized.status).toBe('waiting_generation_confirmation'); expect(normalized.runtime?.stage).toBe('waiting_generation_confirmation'); expect(normalized.runtime?.generationConfirmation).toEqual(task.runtime.generationConfirmation); - expect(normalized.runtime?.controlReadiness).toMatchObject({ attempts: 5, elapsedMs: 3250, stableAtMs: 3250 }); + expect(normalized.runtime?.controlReadiness).toMatchObject({ + schemaVersion: 1, currentStage: 'duration', attempts: 5, elapsedMs: 3250, stableSamples: 3, + recoveryAttempts: 1, pageStructureVersion: 'doubao-video-v2:composite-v2:m1r1d1', stableAtMs: 3250, + }); + }); + + it('非法页面就绪诊断被白名单归一化且结构版本长度受限', () => { + const task = makeValidTask() as unknown as Record; + task.runtime = { + runId: 'run-invalid-readiness', attempt: 1, stage: 'configuring', message: '配置中', + startedAt: NOW, stageStartedAt: NOW, lastHeartbeatAt: NOW, + input: { prompt: task.prompt, mode: 'video', attachments: [] }, + controlReadiness: { + schemaVersion: 999, currentStage: 'invented', failureStage: 'invented', missingControl: 'cookie', + attempts: -1, elapsedMs: -1, pageStructureVersion: `doubao-video-v2:${'x'.repeat(200)}`, + }, + }; + const readiness = normalizeTasks([task], DEFAULT_PROJECT_ID, NOW).data[0].runtime?.controlReadiness; + expect(readiness).toMatchObject({ attempts: 0, elapsedMs: 0 }); + expect(readiness?.schemaVersion).toBeUndefined(); + expect(readiness?.currentStage).toBeUndefined(); + expect(readiness?.failureStage).toBeUndefined(); + expect(readiness?.missingControl).toBeUndefined(); + expect(readiness?.pageStructureVersion?.length).toBe(96); }); it('人工提交观察与最小化执行诊断跨重启保留,不引入页面文本或素材路径', () => { diff --git a/tests/unit/videoCapability.test.ts b/tests/unit/videoCapability.test.ts index 8a4e4af..fac269c 100644 --- a/tests/unit/videoCapability.test.ts +++ b/tests/unit/videoCapability.test.ts @@ -580,7 +580,7 @@ describe('新版视频控件适配契约', () => { const bridgeSource = readFileSync(resolve(__dirname, '../../src/utils/doubaoBridge.ts'), 'utf8'); expect(bridgeSource).toContain('{ timeoutMs: 30_000, stableSamples: 3 }'); expect(bridgeSource).toContain('throw new VideoControlReadinessError'); - expect(bridgeSource).toContain("'final_readback'"); + expect(bridgeSource).toContain("'submission_controls'"); expect(bridgeSource).toContain('const waitForExactOverlayOption = async'); expect(bridgeSource).toContain("waitForExactOverlayOption(modelLabel, 'video_model')"); expect(bridgeSource).toContain('const waitForModelVerification = async');