draft.test.js
1 import * as fs from 'node:fs'; 2 import * as os from 'node:os'; 3 import * as path from 'node:path'; 4 import { afterAll, describe, expect, it, vi } from 'vitest'; 5 import { wrapForEval } from '@jackwener/opencli/browser/utils'; 6 import { getRegistry } from '@jackwener/opencli/registry'; 7 import { buildCoverCheckPanelTextJs } from './draft.js'; 8 // ─── Shared test helpers ──────────────────────────────────────────── 9 const tempDirs = []; 10 function createTempVideo(name = 'demo.mp4') { 11 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-douyin-draft-')); 12 tempDirs.push(dir); 13 const filePath = path.join(dir, name); 14 fs.writeFileSync(filePath, Buffer.from([0, 0, 0, 20, 102, 116, 121, 112])); 15 return filePath; 16 } 17 function createTempCover(videoPath, name = 'cover.jpg') { 18 const filePath = path.join(path.dirname(videoPath), name); 19 fs.writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); 20 return filePath; 21 } 22 function getDraftCommand() { 23 const registry = getRegistry(); 24 const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'draft'); 25 if (!cmd?.func) 26 throw new Error('douyin draft command not registered'); 27 return cmd; 28 } 29 afterAll(() => { 30 for (const dir of tempDirs) { 31 fs.rmSync(dir, { recursive: true, force: true }); 32 } 33 }); 34 function createFakeTree(text, children = []) { 35 const node = { 36 textContent: text, 37 parentElement: null, 38 querySelectorAll: () => [], 39 }; 40 node.querySelectorAll = () => { 41 const descendants = []; 42 for (const child of children) { 43 descendants.push(child, ...child.querySelectorAll('*')); 44 } 45 return descendants; 46 }; 47 for (const child of children) { 48 child.parentElement = node; 49 } 50 return node; 51 } 52 function createPageMock(evaluateResults, overrides = {}) { 53 const evaluate = vi.fn(); 54 for (const result of evaluateResults) { 55 evaluate.mockResolvedValueOnce(result); 56 } 57 return { 58 goto: vi.fn().mockResolvedValue(undefined), 59 evaluate, 60 getCookies: vi.fn().mockResolvedValue([]), 61 snapshot: vi.fn().mockResolvedValue(undefined), 62 click: vi.fn().mockResolvedValue(undefined), 63 typeText: vi.fn().mockResolvedValue(undefined), 64 pressKey: vi.fn().mockResolvedValue(undefined), 65 scrollTo: vi.fn().mockResolvedValue(undefined), 66 getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), 67 wait: vi.fn().mockResolvedValue(undefined), 68 tabs: vi.fn().mockResolvedValue([]), 69 selectTab: vi.fn().mockResolvedValue(undefined), 70 networkRequests: vi.fn().mockResolvedValue([]), 71 consoleMessages: vi.fn().mockResolvedValue([]), 72 scroll: vi.fn().mockResolvedValue(undefined), 73 autoScroll: vi.fn().mockResolvedValue(undefined), 74 installInterceptor: vi.fn().mockResolvedValue(undefined), 75 getInterceptedRequests: vi.fn().mockResolvedValue([]), 76 waitForCapture: vi.fn().mockResolvedValue(undefined), 77 screenshot: vi.fn().mockResolvedValue(''), 78 setFileInput: vi.fn().mockResolvedValue(undefined), 79 ...overrides, 80 }; 81 } 82 describe('douyin draft registration', () => { 83 it('registers the draft command', () => { 84 const registry = getRegistry(); 85 const values = [...registry.values()]; 86 const cmd = values.find(c => c.site === 'douyin' && c.name === 'draft'); 87 expect(cmd).toBeDefined(); 88 }); 89 it('extracts the higher quick-check panel instead of stopping at a header-only ancestor', () => { 90 const marker = createFakeTree('快速检测'); 91 const state = createFakeTree('重新检测'); 92 const header = createFakeTree('快速检测', [marker]); 93 const status = createFakeTree('重新检测', [state]); 94 const panel = createFakeTree('快速检测重新检测', [header, status]); 95 const body = createFakeTree('body', [panel]); 96 const g = globalThis; 97 const originalDocument = g.document; 98 g.document = { 99 body, 100 querySelectorAll: () => [marker, state], 101 }; 102 try { 103 expect(eval(buildCoverCheckPanelTextJs())()).toBe('快速检测重新检测'); 104 } 105 finally { 106 g.document = originalDocument; 107 } 108 }); 109 it('returns empty when only header text exists and no exact quick-check state node is present', () => { 110 const marker = createFakeTree('快速检测'); 111 const note = createFakeTree('检测说明'); 112 const header = createFakeTree('快速检测检测说明', [marker, note]); 113 const body = createFakeTree('body', [header]); 114 const g = globalThis; 115 const originalDocument = g.document; 116 g.document = { 117 body, 118 querySelectorAll: () => [marker, note], 119 }; 120 try { 121 expect(eval(buildCoverCheckPanelTextJs())()).toBe(''); 122 } 123 finally { 124 g.document = originalDocument; 125 } 126 }); 127 it('extracts the quick-check panel when busy state is rendered as a single 封面检测中 node', () => { 128 const marker = createFakeTree('快速检测'); 129 const busy = createFakeTree('封面检测中'); 130 const header = createFakeTree('快速检测', [marker]); 131 const status = createFakeTree('封面检测中', [busy]); 132 const panel = createFakeTree('快速检测封面检测中', [header, status]); 133 const body = createFakeTree('body', [panel]); 134 const g = globalThis; 135 const originalDocument = g.document; 136 g.document = { 137 body, 138 querySelectorAll: () => [marker, busy], 139 }; 140 try { 141 expect(eval(buildCoverCheckPanelTextJs())()).toBe('快速检测封面检测中'); 142 } 143 finally { 144 g.document = originalDocument; 145 } 146 }); 147 it('uploads through the official creator draft page and saves the draft session', async () => { 148 const cmd = getDraftCommand(); 149 const videoPath = createTempVideo('demo.mp4'); 150 const page = createPageMock([ 151 undefined, 152 { href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' }, 153 undefined, 154 true, 155 true, 156 true, 157 { ok: true, text: '暂存离开', creationId: 'creation-001' }, 158 { 159 href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish', 160 bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃', 161 }, 162 ]); 163 const rows = await cmd.func(page, { 164 video: videoPath, 165 title: '最小修复验证', 166 caption: 'opencli draft e2e', 167 cover: '', 168 visibility: 'friends', 169 }); 170 expect(page.goto).toHaveBeenCalledWith('https://creator.douyin.com/creator-micro/content/upload'); 171 expect(page.wait).toHaveBeenCalledWith({ 172 selector: 'input[type="file"]', 173 timeout: 20, 174 }); 175 expect(page.setFileInput).toHaveBeenCalledWith([videoPath], 'input[type="file"]'); 176 const evaluateCalls = page.evaluate.mock.calls.map((args) => String(args[0])); 177 expect(evaluateCalls.some((code) => code.includes('填写作品标题'))).toBe(true); 178 expect(evaluateCalls.some((code) => code.includes('好友可见'))).toBe(true); 179 expect(evaluateCalls.some((code) => code.includes('暂存离开'))).toBe(true); 180 expect(rows).toEqual([ 181 { 182 status: '✅ 草稿已保存,可在创作中心继续编辑', 183 draft_id: 'creation-001', 184 }, 185 ]); 186 }); 187 it('waits for the composer when upload processing is slower than the first few polls', async () => { 188 const cmd = getDraftCommand(); 189 const videoPath = createTempVideo('slow.mp4'); 190 const page = createPageMock([ 191 undefined, 192 { href: 'https://creator.douyin.com/creator-micro/content/upload', ready: false, bodyText: '上传中 42%' }, 193 { href: 'https://creator.douyin.com/creator-micro/content/upload', ready: false, bodyText: '转码中' }, 194 { href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' }, 195 undefined, 196 true, 197 true, 198 { ok: true, text: '暂存离开', creationId: 'creation-slow' }, 199 { 200 href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish', 201 bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃', 202 }, 203 ]); 204 const rows = await cmd.func(page, { 205 video: videoPath, 206 title: '慢上传验证', 207 caption: '', 208 cover: '', 209 visibility: 'public', 210 }); 211 expect(rows).toEqual([ 212 { 213 status: '✅ 草稿已保存,可在创作中心继续编辑', 214 draft_id: 'creation-slow', 215 }, 216 ]); 217 expect(page.wait).toHaveBeenCalledWith({ time: 0.5 }); 218 const shortWaitCalls = page.wait.mock.calls.filter(([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 })); 219 expect(shortWaitCalls).toHaveLength(2); 220 }); 221 it('fails fast when the save action does not expose a draft creation id', async () => { 222 const cmd = getDraftCommand(); 223 const videoPath = createTempVideo('missing-id.mp4'); 224 const page = createPageMock([ 225 undefined, 226 { href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' }, 227 undefined, 228 true, 229 true, 230 { ok: true, text: '暂存离开', creationId: '' }, 231 ]); 232 await expect(cmd.func(page, { 233 video: videoPath, 234 title: '缺失 creation id', 235 caption: '', 236 cover: '', 237 visibility: 'public', 238 })).rejects.toThrow('点击草稿按钮失败: creation-id-missing'); 239 }); 240 it('uses the dedicated cover upload input when a custom cover is provided', async () => { 241 const cmd = getDraftCommand(); 242 const videoPath = createTempVideo('demo.mp4'); 243 const coverPath = createTempCover(videoPath); 244 const page = createPageMock([ 245 undefined, 246 { href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' }, 247 undefined, 248 1, 249 { ok: false, reason: 'cover-input-pending' }, 250 { ok: true, selector: '[data-opencli-cover-input="1"]' }, 251 '快速检测检测中', 252 '快速检测重新检测', 253 true, 254 true, 255 { ok: true, text: '暂存离开', creationId: 'creation-002' }, 256 { 257 href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish', 258 bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃', 259 }, 260 ]); 261 const rows = await cmd.func(page, { 262 video: videoPath, 263 title: '封面上传验证', 264 caption: '', 265 cover: coverPath, 266 visibility: 'public', 267 }); 268 expect(page.setFileInput).toHaveBeenNthCalledWith(1, [videoPath], 'input[type="file"]'); 269 expect(page.setFileInput).toHaveBeenNthCalledWith(2, [coverPath], '[data-opencli-cover-input="1"]'); 270 const shortWaitCalls = page.wait.mock.calls.filter(([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 })); 271 expect(shortWaitCalls).toHaveLength(2); 272 const evaluateCalls = page.evaluate.mock.calls.map((args) => String(args[0])); 273 expect(evaluateCalls.some((code) => code.includes('上传新封面'))).toBe(true); 274 expect(evaluateCalls.some((code) => code.includes("text.includes('快速检测检测')"))).toBe(false); 275 expect(() => { 276 for (const code of evaluateCalls) { 277 new Function(wrapForEval(code)); 278 } 279 }).not.toThrow(); 280 expect(rows).toEqual([ 281 { 282 status: '✅ 草稿已保存,可在创作中心继续编辑', 283 draft_id: 'creation-002', 284 }, 285 ]); 286 }); 287 it('waits for a late cover-section update before treating the custom cover as ready', async () => { 288 const cmd = getDraftCommand(); 289 const videoPath = createTempVideo('cover-race.mp4'); 290 const coverPath = createTempCover(videoPath, 'cover-race.jpg'); 291 const page = createPageMock([ 292 undefined, 293 { href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' }, 294 undefined, 295 1, 296 { ok: true, selector: '[data-opencli-cover-input="1"]' }, 297 '快速检测重新检测', 298 '快速检测重新检测', 299 '快速检测重新检测', 300 '快速检测检测中', 301 '快速检测横/竖双封面缺失', 302 true, 303 true, 304 { ok: true, text: '暂存离开', creationId: 'creation-cover-race' }, 305 { 306 href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish', 307 bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃', 308 }, 309 ]); 310 const rows = await cmd.func(page, { 311 video: videoPath, 312 title: '封面竞态验证', 313 caption: '', 314 cover: coverPath, 315 visibility: 'public', 316 }); 317 expect(rows).toEqual([ 318 { 319 status: '✅ 草稿已保存,可在创作中心继续编辑', 320 draft_id: 'creation-cover-race', 321 }, 322 ]); 323 const shortWaitCalls = page.wait.mock.calls.filter(([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 })); 324 expect(shortWaitCalls).toHaveLength(4); 325 }); 326 it('accepts the same ready label after cover busy state when the quick-check panel actually transitioned', async () => { 327 const cmd = getDraftCommand(); 328 const videoPath = createTempVideo('cover-same-ready.mp4'); 329 const coverPath = createTempCover(videoPath, 'cover-same-ready.jpg'); 330 const page = createPageMock([ 331 undefined, 332 { href: 'https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page', ready: true, bodyText: '' }, 333 undefined, 334 1, 335 { ok: true, selector: '[data-opencli-cover-input="1"]' }, 336 '快速检测重新检测', 337 '快速检测重新检测', 338 '快速检测检测中', 339 '快速检测重新检测', 340 true, 341 true, 342 { ok: true, text: '暂存离开', creationId: 'creation-cover-same-ready' }, 343 { 344 href: 'https://creator.douyin.com/creator-micro/content/upload?enter_from=publish', 345 bodyText: '你还有上次未发布的视频,是否继续编辑?继续编辑放弃', 346 }, 347 ]); 348 const rows = await cmd.func(page, { 349 video: videoPath, 350 title: '封面同文案验证', 351 caption: '', 352 cover: coverPath, 353 visibility: 'public', 354 }); 355 expect(rows).toEqual([ 356 { 357 status: '✅ 草稿已保存,可在创作中心继续编辑', 358 draft_id: 'creation-cover-same-ready', 359 }, 360 ]); 361 const shortWaitCalls = page.wait.mock.calls.filter(([arg]) => JSON.stringify(arg) === JSON.stringify({ time: 0.5 })); 362 expect(shortWaitCalls).toHaveLength(3); 363 }); 364 });