/ clis / douyin / draft.test.js
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  });