From e1666e1d2148317233ce4bcf69a9cb91b47863f1 Mon Sep 17 00:00:00 2001 From: Lukin Date: Sat, 16 May 2026 23:35:05 +0800 Subject: [PATCH 1/6] fix: extend YouTube publish upload waits Co-Authored-By: Cody --- cli-manifest.json | 7 +++++++ clis/youtube/publish.js | 24 +++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index dba1d0eb7..b7c1aed10 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -27878,6 +27878,13 @@ "default": false, "required": false, "help": "Save as draft (currently returns unsupported_capability)" + }, + { + "name": "timeout", + "type": "int", + "default": 420, + "required": false, + "help": "Max seconds for the full YouTube publish flow" } ], "columns": [ diff --git a/clis/youtube/publish.js b/clis/youtube/publish.js index 87342af75..699a96296 100644 --- a/clis/youtube/publish.js +++ b/clis/youtube/publish.js @@ -1,5 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { AuthRequiredError } from '@jackwener/opencli/errors'; +import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors'; import { buildDescriptionWithTags, PUBLISH_ERROR_CODES, @@ -22,8 +22,24 @@ const FILE_SELECTORS = [ ]; const UPLOAD_TIMEOUT_MS = 240_000; const POLL_MS = 1500; -const DIALOG_TIMEOUT_MS = 60_000; -const PUBLISH_TIMEOUT_MS = 120_000; +const DEFAULT_COMMAND_TIMEOUT_SECONDS = 420; +const DIALOG_TIMEOUT_MS = envTimeoutMs('OPENCLI_YOUTUBE_DIALOG_TIMEOUT', 180_000); +const PUBLISH_TIMEOUT_MS = envTimeoutMs('OPENCLI_YOUTUBE_PUBLISH_TIMEOUT', 180_000); + +function envTimeoutMs(name, fallbackMs) { + const raw = process.env[name]; + if (!raw) return fallbackMs; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed * 1000 : fallbackMs; +} + +function requirePositiveTimeoutSeconds(value) { + const parsed = Number(value ?? DEFAULT_COMMAND_TIMEOUT_SECONDS); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new ArgumentError('youtube publish --timeout must be a positive integer (seconds)'); + } + return parsed; +} function unsupportedForInput(input) { if (input.schedule) { @@ -321,6 +337,7 @@ export const publishCommand = cli({ { name: 'privacy', default: 'public', choices: ['public', 'unlisted', 'private'], help: 'YouTube visibility' }, { name: 'account', default: '', help: 'Channel/account selector (currently returns unsupported_capability)' }, { name: 'draft', type: 'bool', default: false, help: 'Save as draft (currently returns unsupported_capability)' }, + { name: 'timeout', type: 'int', default: DEFAULT_COMMAND_TIMEOUT_SECONDS, help: 'Max seconds for the full YouTube publish flow' }, ], columns: ['ok', 'platform', 'status', 'code', 'capability', 'message', 'url', 'draft'], func: async (page, kwargs) => { @@ -329,6 +346,7 @@ export const publishCommand = cli({ maxDescriptionLength: 5000, validateCover: false, }); + requirePositiveTimeoutSeconds(kwargs.timeout); const unsupported = unsupportedForInput(input); if (unsupported) return unsupported; From ae189ee1a5ccdc74d6975ef3af898e6ec1dd2c24 Mon Sep 17 00:00:00 2001 From: Lukin Date: Sat, 16 May 2026 23:48:06 +0800 Subject: [PATCH 2/6] fix: pass YouTube publish timeout to waits Co-Authored-By: Cody --- clis/youtube/publish.js | 20 ++++++++++++------- clis/youtube/publish.test.js | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/clis/youtube/publish.js b/clis/youtube/publish.js index 699a96296..adeafb965 100644 --- a/clis/youtube/publish.js +++ b/clis/youtube/publish.js @@ -41,6 +41,11 @@ function requirePositiveTimeoutSeconds(value) { return parsed; } +function timeoutMsFromSeconds(timeoutSeconds, fallbackMs) { + const parsed = Number(timeoutSeconds); + return Number.isInteger(parsed) && parsed > 0 ? parsed * 1000 : fallbackMs; +} + function unsupportedForInput(input) { if (input.schedule) { return unsupportedResult(PLATFORM, 'schedule', 'YouTube publish adapter currently supports immediate publish only; scheduled publish is reported as unsupported.'); @@ -98,8 +103,8 @@ async function openUploadDialog(page) { } } -async function waitForDetailsDialog(page) { - const deadline = Date.now() + DIALOG_TIMEOUT_MS; +async function waitForDetailsDialog(page, timeoutSeconds = DEFAULT_COMMAND_TIMEOUT_SECONDS) { + const deadline = Date.now() + timeoutMsFromSeconds(timeoutSeconds, DIALOG_TIMEOUT_MS); while (Date.now() < deadline) { const result = await page.evaluate(` (() => { @@ -300,8 +305,8 @@ async function clickPublish(page) { } } -async function waitForYouTubePublishResult(page, privacy) { - const deadline = Date.now() + PUBLISH_TIMEOUT_MS; +async function waitForYouTubePublishResult(page, privacy, timeoutSeconds = DEFAULT_COMMAND_TIMEOUT_SECONDS) { + const deadline = Date.now() + timeoutMsFromSeconds(timeoutSeconds, PUBLISH_TIMEOUT_MS); while (Date.now() < deadline) { const result = await page.evaluateWithArgs(` (() => { @@ -346,7 +351,7 @@ export const publishCommand = cli({ maxDescriptionLength: 5000, validateCover: false, }); - requirePositiveTimeoutSeconds(kwargs.timeout); + const timeoutSeconds = requirePositiveTimeoutSeconds(kwargs.timeout); const unsupported = unsupportedForInput(input); if (unsupported) return unsupported; @@ -355,13 +360,13 @@ export const publishCommand = cli({ await assertYouTubeLoggedIn(page); await openUploadDialog(page); await setFileInput(page, [input.videoPath], FILE_SELECTORS, PLATFORM); - await waitForDetailsDialog(page); + await waitForDetailsDialog(page, timeoutSeconds); const description = buildDescriptionWithTags(input.description, input.tags); await fillYouTubeDetails(page, input.title, description); await goThroughChecks(page, input.privacy); await clickPublish(page); - const publishResult = await waitForYouTubePublishResult(page, input.privacy); + const publishResult = await waitForYouTubePublishResult(page, input.privacy, timeoutSeconds); return successResult(PLATFORM, publishResult.message || 'YouTube publish completed', { url: publishResult.url || '', @@ -377,5 +382,6 @@ export const __test__ = { goThroughChecks, clickAndVerifyYouTubeRadio, classifyYouTubePublishState, + waitForDetailsDialog, waitForYouTubePublishResult, }; diff --git a/clis/youtube/publish.test.js b/clis/youtube/publish.test.js index b66ef483f..26342d8bd 100644 --- a/clis/youtube/publish.test.js +++ b/clis/youtube/publish.test.js @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors'; import { publishCommand, __test__ } from './publish.js'; @@ -22,6 +22,9 @@ function pageReturning(result) { } describe('youtube publish adapter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); it('registers a write publish command with structured columns', () => { const cmd = [...getRegistry().values()].find((c) => c.site === 'youtube' && c.name === 'publish'); expect(cmd).toBeDefined(); @@ -43,6 +46,38 @@ describe('youtube publish adapter', () => { await expect(publishCommand.func({}, { video, title: 'x', account: 'brand' })).resolves.toMatchObject([{ code: 'unsupported_capability', capability: 'account' }]); }); + it('passes --timeout into the upload details inner wait', async () => { + const video = tempVideo(); + let now = 0; + vi.spyOn(Date, 'now').mockImplementation(() => { + const current = now; + now += 1000; + return current; + }); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn(async (script) => { + const code = String(script); + if (code.includes('daily upload limit')) return null; + if (code.includes('YouTube Studio requires login')) return { ok: true }; + if (code.includes("document.querySelector('input[type=\"file\"]')")) return true; + return null; + }), + evaluateWithArgs: vi.fn().mockResolvedValue('input[type="file"]'), + wait: vi.fn().mockResolvedValue(undefined), + setFileInput: vi.fn().mockResolvedValue(undefined), + }; + + await expect(publishCommand.func(page, { + video, + title: 'Timeout propagation', + privacy: 'unlisted', + timeout: 2, + })).rejects.toMatchObject({ code: 'upload_failed' }); + + expect(page.wait).toHaveBeenCalledTimes(1); + }); + it('does not fail Shorts-style upload flow when made-for-kids radio is omitted', async () => { const evaluateWithArgsResults = [ { ok: false, message: 'made-for-kids radio was not found' }, From 39085e2c6af973f178380ab17366a54002c7a589 Mon Sep 17 00:00:00 2001 From: Lukin Date: Fri, 15 May 2026 11:49:59 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E6=94=B6=E7=AA=84=20YouTube=20publi?= =?UTF-8?q?sh=20=E6=B5=8F=E8=A7=88=E5=99=A8=E8=84=9A=E6=9C=AC=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cody --- clis/youtube/publish.js | 35 +++++++++++++++++++++++------------ clis/youtube/publish.test.js | 13 ++++++++++--- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/clis/youtube/publish.js b/clis/youtube/publish.js index adeafb965..ccc52079d 100644 --- a/clis/youtube/publish.js +++ b/clis/youtube/publish.js @@ -46,6 +46,10 @@ function timeoutMsFromSeconds(timeoutSeconds, fallbackMs) { return Number.isInteger(parsed) && parsed > 0 ? parsed * 1000 : fallbackMs; } +function browserLiteral(value) { + return JSON.stringify(value).replace(/ { + const videoTitle = ${browserLiteral(title)}; + const videoDescription = ${browserLiteral(description)}; ${visibleElementScript()} const fields = Array.from(document.querySelectorAll('[contenteditable="true"], textarea, input[type="text"]')) .filter(isVisible) @@ -145,7 +151,7 @@ async function fillYouTubeDetails(page, title, description) { if (fields[1]) setNativeText(fields[1], videoDescription); return { ok: true, fields: fields.length }; })() - `, { videoTitle: title, videoDescription: description }); + `); classifyPlatformFailure(PLATFORM, DOMAIN, result, 'YouTube details fill failed'); } @@ -191,10 +197,12 @@ export function classifyYouTubePublishState({ text = '', anchors = [], privacy = } async function clickAndVerifyYouTubeRadio(page, labels, settingName, { required = true } = {}) { - const result = await page.evaluateWithArgs(` + const result = await page.evaluate(` (() => { + const radioLabels = ${browserLiteral(labels)}; + const settingName = ${browserLiteral(settingName)}; ${visibleElementScript()} - const wanted = labels.map((label) => String(label).toLowerCase()); + const wanted = radioLabels.map((label) => String(label).toLowerCase()); const candidates = Array.from(document.querySelectorAll('tp-yt-paper-radio-button, ytcp-radio-button, [role="radio"], label')); function isChecked(el) { return el.checked === true @@ -214,7 +222,7 @@ async function clickAndVerifyYouTubeRadio(page, labels, settingName, { required } return { ok: false, message: settingName + ' radio was not found' }; })() - `, { labels, settingName }); + `); if (!result?.ok) { if (!required && /radio was not found/i.test(result?.message || '')) { return { ok: false, skipped: true, message: result?.message || `YouTube ${settingName} radio was not found` }; @@ -223,9 +231,11 @@ async function clickAndVerifyYouTubeRadio(page, labels, settingName, { required } await page.wait({ time: 0.3 }); - const verified = await page.evaluateWithArgs(` + const verified = await page.evaluate(` (() => { - const wanted = labels.map((label) => String(label).toLowerCase()); + const radioLabels = ${browserLiteral(labels)}; + const settingName = ${browserLiteral(settingName)}; + const wanted = radioLabels.map((label) => String(label).toLowerCase()); const candidates = Array.from(document.querySelectorAll('tp-yt-paper-radio-button, ytcp-radio-button, [role="radio"], label')); function radioSelected(el) { const nodes = [el, el.closest?.('[role="radio"]'), el.querySelector?.('[role="radio"]'), el.querySelector?.('input[type="radio"]')].filter(Boolean); @@ -245,7 +255,7 @@ async function clickAndVerifyYouTubeRadio(page, labels, settingName, { required } return { ok: false, message: settingName + ' radio selection could not be confirmed after click' }; })() - `, { labels, settingName }); + `); if (!verified?.ok) { throwPublishFailure(PUBLISH_ERROR_CODES.platformError, verified?.message || `YouTube ${settingName} radio selection could not be confirmed`); } @@ -285,12 +295,12 @@ async function goThroughChecks(page, privacy) { await page.wait({ time: 1.2 }); } - const labels = privacy === 'private' + const privacyLabels = privacy === 'private' ? ['Private', '私享', '私密'] : privacy === 'unlisted' ? ['Unlisted', '不公开列出'] : ['Public', '公开']; - await clickAndVerifyYouTubeRadio(page, labels, 'privacy'); + await clickAndVerifyYouTubeRadio(page, privacyLabels, 'privacy'); } async function clickPublish(page) { @@ -308,13 +318,14 @@ async function clickPublish(page) { async function waitForYouTubePublishResult(page, privacy, timeoutSeconds = DEFAULT_COMMAND_TIMEOUT_SECONDS) { const deadline = Date.now() + timeoutMsFromSeconds(timeoutSeconds, PUBLISH_TIMEOUT_MS); while (Date.now() < deadline) { - const result = await page.evaluateWithArgs(` + const result = await page.evaluate(` (() => { + const privacy = ${browserLiteral(privacy)}; const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim(); const anchors = Array.from(document.querySelectorAll('a[href*="watch?v="], a[href*="youtu.be/"]')).map((a) => a.href).filter(Boolean); return { text, anchors, privacy }; })() - `, { privacy }); + `); const state = classifyYouTubePublishState(result); if (state?.ok) return state; classifyPlatformFailure(PLATFORM, DOMAIN, state, 'YouTube publish failed'); diff --git a/clis/youtube/publish.test.js b/clis/youtube/publish.test.js index 26342d8bd..b39d253b2 100644 --- a/clis/youtube/publish.test.js +++ b/clis/youtube/publish.test.js @@ -12,6 +12,10 @@ function tempVideo() { fs.writeFileSync(file, 'fake video'); return file; } +function assertBrowserScriptParses(script) { + expect(() => new Function(script)).not.toThrow(); +} + function pageReturning(result) { return { @@ -79,24 +83,27 @@ describe('youtube publish adapter', () => { }); it('does not fail Shorts-style upload flow when made-for-kids radio is omitted', async () => { - const evaluateWithArgsResults = [ + const evaluateResults = [ { ok: false, message: 'made-for-kids radio was not found' }, + { ok: false }, { ok: false, message: 'made-for-kids radio was not found' }, ]; const calls = []; const page = { async evaluate(script) { + assertBrowserScriptParses(script); calls.push(script); - return { ok: false }; + return evaluateResults.shift(); }, async evaluateWithArgs() { - return evaluateWithArgsResults.shift(); + throw new Error('YouTube publish flow should keep arguments scoped locally instead of using BasePage.evaluateWithArgs'); }, async wait() {}, }; await expect(__test__.chooseNotMadeForKids(page, false)).resolves.toMatchObject({ skipped: true }); expect(calls.some((script) => script.includes('Show more'))).toBe(true); + expect(calls.every((script) => !script.trim().startsWith('const '))).toBe(true); }); it('still requires privacy radio selection after optional audience skip', async () => { From d444e948a24e82514eb340448575c8b38eabd664 Mon Sep 17 00:00:00 2001 From: Lukin Date: Sun, 17 May 2026 01:38:58 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=20YouTube=20?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=B6=85=E6=97=B6=E4=B8=8E=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cody --- clis/_shared/video-publish.js | 4 +- clis/youtube/publish.js | 128 ++++++++++++++++++++++++++++------ clis/youtube/publish.test.js | 69 ++++++++++++------ 3 files changed, 157 insertions(+), 44 deletions(-) diff --git a/clis/_shared/video-publish.js b/clis/_shared/video-publish.js index e49c8e326..8aca68c90 100644 --- a/clis/_shared/video-publish.js +++ b/clis/_shared/video-publish.js @@ -183,8 +183,8 @@ export async function waitForAnySelector(page, selectors, timeoutMs = 30_000, in return ''; } -export async function setFileInput(page, files, selectors, platform) { - const selector = await waitForAnySelector(page, selectors, 45_000, 750); +export async function setFileInput(page, files, selectors, platform, timeoutMs = 45_000) { + const selector = await waitForAnySelector(page, selectors, timeoutMs, 750); if (!selector) { throwPublishFailure(PUBLISH_ERROR_CODES.uploadFailed, `${platform} upload failed: file input was not found`); } diff --git a/clis/youtube/publish.js b/clis/youtube/publish.js index ccc52079d..8bd5f3eae 100644 --- a/clis/youtube/publish.js +++ b/clis/youtube/publish.js @@ -20,18 +20,9 @@ const FILE_SELECTORS = [ 'input[type="file"][accept*="video"]', 'input[type="file"]', ]; -const UPLOAD_TIMEOUT_MS = 240_000; const POLL_MS = 1500; const DEFAULT_COMMAND_TIMEOUT_SECONDS = 420; -const DIALOG_TIMEOUT_MS = envTimeoutMs('OPENCLI_YOUTUBE_DIALOG_TIMEOUT', 180_000); -const PUBLISH_TIMEOUT_MS = envTimeoutMs('OPENCLI_YOUTUBE_PUBLISH_TIMEOUT', 180_000); -function envTimeoutMs(name, fallbackMs) { - const raw = process.env[name]; - if (!raw) return fallbackMs; - const parsed = Number(raw); - return Number.isInteger(parsed) && parsed > 0 ? parsed * 1000 : fallbackMs; -} function requirePositiveTimeoutSeconds(value) { const parsed = Number(value ?? DEFAULT_COMMAND_TIMEOUT_SECONDS); @@ -46,10 +37,22 @@ function timeoutMsFromSeconds(timeoutSeconds, fallbackMs) { return Number.isInteger(parsed) && parsed > 0 ? parsed * 1000 : fallbackMs; } +function createFlowDeadline(timeoutSeconds = DEFAULT_COMMAND_TIMEOUT_SECONDS) { + return Date.now() + timeoutMsFromSeconds(timeoutSeconds, DEFAULT_COMMAND_TIMEOUT_SECONDS * 1000); +} + +function remainingTimeoutMs(deadlineMs) { + return Math.max(0, Number(deadlineMs) - Date.now()); +} + function browserLiteral(value) { return JSON.stringify(value).replace(/ { + const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim(); + const textboxes = Array.from(document.querySelectorAll('[contenteditable="true"], textarea, input[type="text"]')); + const fileInputs = Array.from(document.querySelectorAll('input[type="file"]')).map((input) => ({ + count: input.files?.length || 0, + names: Array.from(input.files || []).map((file) => file.name).filter(Boolean), + accept: input.getAttribute('accept') || '', + visible: !!(input.offsetWidth || input.offsetHeight || input.getClientRects().length), + })); + return { + url: location.href, + title: document.title || '', + modalTitle: document.querySelector('ytcp-uploads-dialog #title, [role="dialog"] #title')?.innerText || '', + text: text.slice(0, 1000), + fileInputs, + filePickerVisible: /select files|选择文件|drag and drop|拖放/i.test(text) || !!document.querySelector('ytcp-uploads-file-picker'), + detailsReady: textboxes.length >= 1 && /details|video details|title|description|详情|标题|说明/i.test(text), + }; + })() + `); + } catch (error) { + return { diagnosticError: error instanceof Error ? error.message : String(error) }; + } +} + +function formatUploadDiagnostics(diagnostics = {}) { + const inputs = Array.isArray(diagnostics.fileInputs) + ? diagnostics.fileInputs.map((input, index) => { + const names = Array.isArray(input.names) && input.names.length ? input.names.join('|') : 'none'; + return `#${index}:count=${input.count || 0},names=${names},accept=${input.accept || 'none'}`; + }).join('; ') + : 'unknown'; + const text = String(diagnostics.text || '').replace(/\s+/g, ' ').trim().slice(0, 240); + return [ + `url=${diagnostics.url || 'unknown'}`, + `modalTitle=${diagnostics.modalTitle || 'unknown'}`, + `filePickerVisible=${diagnostics.filePickerVisible === true}`, + `detailsReady=${diagnostics.detailsReady === true}`, + `fileInputs=[${inputs}]`, + diagnostics.diagnosticError ? `diagnosticError=${diagnostics.diagnosticError}` : '', + text ? `text="${text}"` : '', + ].filter(Boolean).join('; '); +} + +async function verifyYouTubeFileSelected(page, expectedPath) { + const diagnostics = await collectUploadDiagnostics(page); + if (diagnostics?.detailsReady) return diagnostics; + + const selectedInputs = Array.isArray(diagnostics?.fileInputs) + ? diagnostics.fileInputs.filter((input) => Number(input.count) > 0) + : []; + if (selectedInputs.length === 0) { + throwPublishFailure( + PUBLISH_ERROR_CODES.uploadFailed, + `YouTube upload failed: file input has no selected file after setFileInput (${formatUploadDiagnostics(diagnostics)})`, + ); + } + + const expectedName = basename(expectedPath); + const selectedNames = selectedInputs.flatMap((input) => Array.isArray(input.names) ? input.names : []); + if (expectedName && selectedNames.length > 0 && !selectedNames.includes(expectedName)) { + throwPublishFailure( + PUBLISH_ERROR_CODES.uploadFailed, + `YouTube upload failed: selected file name did not match ${expectedName} (${formatUploadDiagnostics(diagnostics)})`, + ); + } + return diagnostics; +} + +async function waitForDetailsDialog(page, flowDeadlineMs = createFlowDeadline()) { + while (Date.now() < flowDeadlineMs) { const result = await page.evaluate(` (() => { const text = (document.body?.innerText || '').replace(/\s+/g, ' '); @@ -128,9 +202,14 @@ async function waitForDetailsDialog(page, timeoutSeconds = DEFAULT_COMMAND_TIMEO `); if (result?.ok) return; classifyPlatformFailure(PLATFORM, DOMAIN, result, 'YouTube upload failed'); - await page.wait({ time: POLL_MS / 1000 }); + const waitMs = Math.min(POLL_MS, remainingTimeoutMs(flowDeadlineMs)); + if (waitMs > 0) await page.wait({ time: waitMs / 1000 }); } - throwPublishFailure(PUBLISH_ERROR_CODES.uploadFailed, 'YouTube upload details dialog did not appear before timeout'); + const diagnostics = await collectUploadDiagnostics(page); + throwPublishFailure( + PUBLISH_ERROR_CODES.uploadFailed, + `YouTube upload details dialog did not appear before timeout (${formatUploadDiagnostics(diagnostics)})`, + ); } async function fillYouTubeDetails(page, title, description) { @@ -315,9 +394,8 @@ async function clickPublish(page) { } } -async function waitForYouTubePublishResult(page, privacy, timeoutSeconds = DEFAULT_COMMAND_TIMEOUT_SECONDS) { - const deadline = Date.now() + timeoutMsFromSeconds(timeoutSeconds, PUBLISH_TIMEOUT_MS); - while (Date.now() < deadline) { +async function waitForYouTubePublishResult(page, privacy, flowDeadlineMs = createFlowDeadline()) { + while (Date.now() < flowDeadlineMs) { const result = await page.evaluate(` (() => { const privacy = ${browserLiteral(privacy)}; @@ -329,7 +407,8 @@ async function waitForYouTubePublishResult(page, privacy, timeoutSeconds = DEFAU const state = classifyYouTubePublishState(result); if (state?.ok) return state; classifyPlatformFailure(PLATFORM, DOMAIN, state, 'YouTube publish failed'); - await page.wait({ time: POLL_MS / 1000 }); + const waitMs = Math.min(POLL_MS, remainingTimeoutMs(flowDeadlineMs)); + if (waitMs > 0) await page.wait({ time: waitMs / 1000 }); } throwPublishFailure(PUBLISH_ERROR_CODES.platformError, 'YouTube publish/save clicked but final publish state was not confirmed before timeout; check YouTube Studio manually.'); } @@ -363,6 +442,7 @@ export const publishCommand = cli({ validateCover: false, }); const timeoutSeconds = requirePositiveTimeoutSeconds(kwargs.timeout); + const flowDeadlineMs = createFlowDeadline(timeoutSeconds); const unsupported = unsupportedForInput(input); if (unsupported) return unsupported; @@ -370,14 +450,15 @@ export const publishCommand = cli({ await page.goto(STUDIO_URL, { waitUntil: 'load', settleMs: 4000 }); await assertYouTubeLoggedIn(page); await openUploadDialog(page); - await setFileInput(page, [input.videoPath], FILE_SELECTORS, PLATFORM); - await waitForDetailsDialog(page, timeoutSeconds); + await setFileInput(page, [input.videoPath], FILE_SELECTORS, PLATFORM, remainingTimeoutMs(flowDeadlineMs)); + await verifyYouTubeFileSelected(page, input.videoPath); + await waitForDetailsDialog(page, flowDeadlineMs); const description = buildDescriptionWithTags(input.description, input.tags); await fillYouTubeDetails(page, input.title, description); await goThroughChecks(page, input.privacy); await clickPublish(page); - const publishResult = await waitForYouTubePublishResult(page, input.privacy, timeoutSeconds); + const publishResult = await waitForYouTubePublishResult(page, input.privacy, flowDeadlineMs); return successResult(PLATFORM, publishResult.message || 'YouTube publish completed', { url: publishResult.url || '', @@ -393,6 +474,11 @@ export const __test__ = { goThroughChecks, clickAndVerifyYouTubeRadio, classifyYouTubePublishState, + collectUploadDiagnostics, + formatUploadDiagnostics, + verifyYouTubeFileSelected, + createFlowDeadline, + remainingTimeoutMs, waitForDetailsDialog, waitForYouTubePublishResult, }; diff --git a/clis/youtube/publish.test.js b/clis/youtube/publish.test.js index b39d253b2..73adef0ca 100644 --- a/clis/youtube/publish.test.js +++ b/clis/youtube/publish.test.js @@ -50,36 +50,63 @@ describe('youtube publish adapter', () => { await expect(publishCommand.func({}, { video, title: 'x', account: 'brand' })).resolves.toMatchObject([{ code: 'unsupported_capability', capability: 'account' }]); }); - it('passes --timeout into the upload details inner wait', async () => { - const video = tempVideo(); - let now = 0; - vi.spyOn(Date, 'now').mockImplementation(() => { - const current = now; - now += 1000; - return current; + it('treats --timeout as one full-flow deadline instead of resetting per wait', async () => { + vi.spyOn(Date, 'now') + .mockReturnValueOnce(1000) + .mockReturnValueOnce(2500) + .mockReturnValueOnce(3500); + + const deadline = __test__.createFlowDeadline(2); + + expect(deadline).toBe(3000); + expect(__test__.remainingTimeoutMs(deadline)).toBe(500); + expect(__test__.remainingTimeoutMs(deadline)).toBe(0); + }); + + it('fails fast with upload diagnostics when setFileInput leaves the picker empty', async () => { + const page = { + evaluate: vi.fn().mockResolvedValue({ + url: 'https://studio.youtube.com/channel/demo', + modalTitle: '上传视频', + text: '上传视频 将要上传的视频文件拖放到此处 选择文件', + filePickerVisible: true, + detailsReady: false, + fileInputs: [{ count: 0, names: [], accept: '' }], + }), + }; + + await expect(__test__.verifyYouTubeFileSelected(page, '/tmp/video.mp4')).rejects.toMatchObject({ + code: 'upload_failed', + message: expect.stringContaining('file input has no selected file'), }); + await expect(__test__.verifyYouTubeFileSelected(page, '/tmp/video.mp4')).rejects.toMatchObject({ + message: expect.stringContaining('filePickerVisible=true'), + }); + }); + + it('adds upload diagnostics to details-dialog timeout errors', async () => { const page = { - goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn(async (script) => { const code = String(script); - if (code.includes('daily upload limit')) return null; - if (code.includes('YouTube Studio requires login')) return { ok: true }; - if (code.includes("document.querySelector('input[type=\"file\"]')")) return true; + if (code.includes('fileInputs')) { + return { + url: 'https://studio.youtube.com/channel/demo', + modalTitle: '上传视频', + text: '选择文件', + filePickerVisible: true, + detailsReady: false, + fileInputs: [{ count: 0, names: [] }], + }; + } return null; }), - evaluateWithArgs: vi.fn().mockResolvedValue('input[type="file"]'), wait: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn().mockResolvedValue(undefined), }; - await expect(publishCommand.func(page, { - video, - title: 'Timeout propagation', - privacy: 'unlisted', - timeout: 2, - })).rejects.toMatchObject({ code: 'upload_failed' }); - - expect(page.wait).toHaveBeenCalledTimes(1); + await expect(__test__.waitForDetailsDialog(page, Date.now() - 1)).rejects.toMatchObject({ + code: 'upload_failed', + message: expect.stringContaining('fileInputs=[#0:count=0'), + }); }); it('does not fail Shorts-style upload flow when made-for-kids radio is omitted', async () => { From c9bee3fba23b2d9ef3f47c1755cf7670f4285a6f Mon Sep 17 00:00:00 2001 From: Lukin Date: Sun, 17 May 2026 01:45:12 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=20YouTube=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E8=A7=A6=E5=8F=91=E5=88=97=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cody --- clis/youtube/publish.js | 40 ++++++++++++++++++------------------ clis/youtube/publish.test.js | 30 +++++++++++++-------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/clis/youtube/publish.js b/clis/youtube/publish.js index 8bd5f3eae..314519278 100644 --- a/clis/youtube/publish.js +++ b/clis/youtube/publish.js @@ -116,20 +116,20 @@ async function collectUploadDiagnostics(page) { (() => { const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim(); const textboxes = Array.from(document.querySelectorAll('[contenteditable="true"], textarea, input[type="text"]')); - const fileInputs = Array.from(document.querySelectorAll('input[type="file"]')).map((input) => ({ + const inputs = Array.from(document.querySelectorAll('input[type="file"]')).map((input) => ({ count: input.files?.length || 0, names: Array.from(input.files || []).map((file) => file.name).filter(Boolean), accept: input.getAttribute('accept') || '', visible: !!(input.offsetWidth || input.offsetHeight || input.getClientRects().length), })); return { - url: location.href, - title: document.title || '', - modalTitle: document.querySelector('ytcp-uploads-dialog #title, [role="dialog"] #title')?.innerText || '', - text: text.slice(0, 1000), - fileInputs, - filePickerVisible: /select files|选择文件|drag and drop|拖放/i.test(text) || !!document.querySelector('ytcp-uploads-file-picker'), - detailsReady: textboxes.length >= 1 && /details|video details|title|description|详情|标题|说明/i.test(text), + pageUrl: location.href, + pageTitle: document.title || '', + dialogTitle: document.querySelector('ytcp-uploads-dialog #title, [role="dialog"] #title')?.innerText || '', + bodyText: text.slice(0, 1000), + inputs, + pickerVisible: /select files|选择文件|drag and drop|拖放/i.test(text) || !!document.querySelector('ytcp-uploads-file-picker'), + isDetailsReady: textboxes.length >= 1 && /details|video details|title|description|详情|标题|说明/i.test(text), }; })() `); @@ -139,30 +139,30 @@ async function collectUploadDiagnostics(page) { } function formatUploadDiagnostics(diagnostics = {}) { - const inputs = Array.isArray(diagnostics.fileInputs) - ? diagnostics.fileInputs.map((input, index) => { + const inputs = Array.isArray(diagnostics.inputs) + ? diagnostics.inputs.map((input, index) => { const names = Array.isArray(input.names) && input.names.length ? input.names.join('|') : 'none'; return `#${index}:count=${input.count || 0},names=${names},accept=${input.accept || 'none'}`; }).join('; ') : 'unknown'; - const text = String(diagnostics.text || '').replace(/\s+/g, ' ').trim().slice(0, 240); + const text = String(diagnostics.bodyText || '').replace(/\s+/g, ' ').trim().slice(0, 240); return [ - `url=${diagnostics.url || 'unknown'}`, - `modalTitle=${diagnostics.modalTitle || 'unknown'}`, - `filePickerVisible=${diagnostics.filePickerVisible === true}`, - `detailsReady=${diagnostics.detailsReady === true}`, - `fileInputs=[${inputs}]`, + `pageUrl=${diagnostics.pageUrl || 'unknown'}`, + `dialogTitle=${diagnostics.dialogTitle || 'unknown'}`, + `pickerVisible=${diagnostics.pickerVisible === true}`, + `isDetailsReady=${diagnostics.isDetailsReady === true}`, + `inputs=[${inputs}]`, diagnostics.diagnosticError ? `diagnosticError=${diagnostics.diagnosticError}` : '', - text ? `text="${text}"` : '', + text ? `bodyText="${text}"` : '', ].filter(Boolean).join('; '); } async function verifyYouTubeFileSelected(page, expectedPath) { const diagnostics = await collectUploadDiagnostics(page); - if (diagnostics?.detailsReady) return diagnostics; + if (diagnostics?.isDetailsReady) return diagnostics; - const selectedInputs = Array.isArray(diagnostics?.fileInputs) - ? diagnostics.fileInputs.filter((input) => Number(input.count) > 0) + const selectedInputs = Array.isArray(diagnostics?.inputs) + ? diagnostics.inputs.filter((input) => Number(input.count) > 0) : []; if (selectedInputs.length === 0) { throwPublishFailure( diff --git a/clis/youtube/publish.test.js b/clis/youtube/publish.test.js index 73adef0ca..89b90c3b4 100644 --- a/clis/youtube/publish.test.js +++ b/clis/youtube/publish.test.js @@ -66,12 +66,12 @@ describe('youtube publish adapter', () => { it('fails fast with upload diagnostics when setFileInput leaves the picker empty', async () => { const page = { evaluate: vi.fn().mockResolvedValue({ - url: 'https://studio.youtube.com/channel/demo', - modalTitle: '上传视频', - text: '上传视频 将要上传的视频文件拖放到此处 选择文件', - filePickerVisible: true, - detailsReady: false, - fileInputs: [{ count: 0, names: [], accept: '' }], + pageUrl: 'https://studio.youtube.com/channel/demo', + dialogTitle: '上传视频', + bodyText: '上传视频 将要上传的视频文件拖放到此处 选择文件', + pickerVisible: true, + isDetailsReady: false, + inputs: [{ count: 0, names: [], accept: '' }], }), }; @@ -80,7 +80,7 @@ describe('youtube publish adapter', () => { message: expect.stringContaining('file input has no selected file'), }); await expect(__test__.verifyYouTubeFileSelected(page, '/tmp/video.mp4')).rejects.toMatchObject({ - message: expect.stringContaining('filePickerVisible=true'), + message: expect.stringContaining('pickerVisible=true'), }); }); @@ -88,14 +88,14 @@ describe('youtube publish adapter', () => { const page = { evaluate: vi.fn(async (script) => { const code = String(script); - if (code.includes('fileInputs')) { + if (code.includes('inputs')) { return { - url: 'https://studio.youtube.com/channel/demo', - modalTitle: '上传视频', - text: '选择文件', - filePickerVisible: true, - detailsReady: false, - fileInputs: [{ count: 0, names: [] }], + pageUrl: 'https://studio.youtube.com/channel/demo', + dialogTitle: '上传视频', + bodyText: '选择文件', + pickerVisible: true, + isDetailsReady: false, + inputs: [{ count: 0, names: [] }], }; } return null; @@ -105,7 +105,7 @@ describe('youtube publish adapter', () => { await expect(__test__.waitForDetailsDialog(page, Date.now() - 1)).rejects.toMatchObject({ code: 'upload_failed', - message: expect.stringContaining('fileInputs=[#0:count=0'), + message: expect.stringContaining('inputs=[#0:count=0'), }); }); From b4d72afd5b8badefa27acca00b206aea181cfd1b Mon Sep 17 00:00:00 2001 From: Lukin Date: Sun, 17 May 2026 01:49:02 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20YouTube=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=9C=AA=E7=9F=A5=E5=93=A8=E5=85=B5=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cody --- clis/youtube/publish.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clis/youtube/publish.js b/clis/youtube/publish.js index 314519278..83a4339e6 100644 --- a/clis/youtube/publish.js +++ b/clis/youtube/publish.js @@ -147,8 +147,8 @@ function formatUploadDiagnostics(diagnostics = {}) { : 'unknown'; const text = String(diagnostics.bodyText || '').replace(/\s+/g, ' ').trim().slice(0, 240); return [ - `pageUrl=${diagnostics.pageUrl || 'unknown'}`, - `dialogTitle=${diagnostics.dialogTitle || 'unknown'}`, + diagnostics.pageUrl ? `pageUrl=${diagnostics.pageUrl}` : '', + diagnostics.dialogTitle ? `dialogTitle=${diagnostics.dialogTitle}` : '', `pickerVisible=${diagnostics.pickerVisible === true}`, `isDetailsReady=${diagnostics.isDetailsReady === true}`, `inputs=[${inputs}]`,