Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

390 lines
21 KiB
JavaScript

// Contract tests for the Phase-3 refactor that retired the legacy DOM-link
// scraping pattern across explore / user / friends / following / notifications / live.
// Each adapter is now func + Strategy.COOKIE + browser:true with shared helpers
// from utils.js — these tests pin down (a) registration metadata, (b) typed-
// error boundary, (c) build-script invariants (no raw user-input concatenation,
// expected endpoint markers, JSON.stringify-embedded inputs).
import { describe, expect, it, vi } from 'vitest';
import {
ArgumentError,
AuthRequiredError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
import { exploreCommand, __test__ as exploreTest } from './explore.js';
import { friendsCommand, __test__ as friendsTest } from './friends.js';
import { followingCommand, __test__ as followingTest } from './following.js';
import { notificationsCommand, __test__ as notificationsTest } from './notifications.js';
import { liveCommand, __test__ as liveTest } from './live.js';
import { userCommand, __test__ as userTest } from './user.js';
import {
BROWSER_HELPERS,
NOTIFICATION_TYPES,
VIDEO_ITEM_NORMALIZER,
USER_ITEM_NORMALIZER,
LIVE_ITEM_NORMALIZER,
NOTIFICATION_NORMALIZER,
normalizeUsername,
requireLimit,
requireNotificationType,
} from './utils.js';
function makePage(rows) {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(rows),
};
}
function makeFailingPage(error) {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockRejectedValue(error),
};
}
const sampleVideoRow = {
index: 1,
id: '7350000000000000000',
author: 'creator',
url: 'https://www.tiktok.com/@creator/video/7350000000000000000',
cover: 'https://example.com/cover.jpg',
title: 'a fun clip',
desc: 'a fun clip',
plays: 12345,
likes: 678,
comments: 9,
shares: 1,
createTime: 1710000000,
};
const sampleUserVideoRow = {
...sampleVideoRow,
source: 'profile-api',
};
const sampleUserRow = {
index: 1,
username: 'creator',
name: 'Creator',
secUid: 'MS4wLjA',
verified: false,
followers: 1000,
following: 50,
url: 'https://www.tiktok.com/@creator',
};
const sampleNotificationRow = {
index: 1,
id: 'notice-1',
from: 'someone',
text: 'liked your video',
createTime: 1710000000,
};
const sampleLiveRow = {
index: 1,
streamer: 'host1',
name: 'Host One',
title: 'cooking show',
viewers: 500,
likes: 0,
secUid: 'MS4wLjA',
url: 'https://www.tiktok.com/@host1/live',
};
describe('tiktok/utils', () => {
it('requireLimit rejects 0 / negative / non-integer / over-max with ArgumentError', () => {
expect(() => requireLimit(0, { fallback: 10, max: 50 })).toThrow(ArgumentError);
expect(() => requireLimit(-1, { fallback: 10, max: 50 })).toThrow(ArgumentError);
expect(() => requireLimit(1.5, { fallback: 10, max: 50 })).toThrow(ArgumentError);
expect(() => requireLimit(51, { fallback: 10, max: 50 })).toThrow(ArgumentError);
expect(requireLimit(undefined, { fallback: 10, max: 50 })).toBe(10);
expect(requireLimit(20, { fallback: 10, max: 50 })).toBe(20);
});
it('normalizeUsername strips @ and validates allowed charset', () => {
expect(normalizeUsername('@dictogo')).toBe('dictogo');
expect(normalizeUsername(' creator.name ')).toBe('creator.name');
expect(() => normalizeUsername('')).toThrow(ArgumentError);
expect(() => normalizeUsername('bad name')).toThrow(ArgumentError);
expect(() => normalizeUsername('bad/name')).toThrow(ArgumentError);
});
it('requireNotificationType maps known keys and rejects unknown via ArgumentError', () => {
expect(requireNotificationType('all')).toBe('all');
expect(requireNotificationType('LIKES')).toBe('likes');
expect(() => requireNotificationType('bogus')).toThrow(ArgumentError);
});
it('BROWSER_HELPERS is a string template that exposes the expected helper names', () => {
expect(typeof BROWSER_HELPERS).toBe('string');
for (const name of ['asNumber', 'cleanText', 'getCookie', 'fetchJson', 'assertTikTokApiSuccess', 'findUniversalData', 'walkObjects']) {
expect(BROWSER_HELPERS).toContain('function ' + name + '(');
}
const asNumber = new Function(`${BROWSER_HELPERS}; return asNumber;`)();
expect(asNumber(null)).toBeNull();
expect(asNumber('')).toBeNull();
expect(asNumber(0)).toBe(0);
const assertTikTokApiSuccess = new Function(`${BROWSER_HELPERS}; return assertTikTokApiSuccess;`)();
expect(() => assertTikTokApiSuccess({ status_code: 0 }, 'test')).not.toThrow();
expect(() => assertTikTokApiSuccess({ status_code: 8, status_msg: 'login required' }, 'test')).toThrow(/AUTH_REQUIRED/);
expect(() => assertTikTokApiSuccess({ status_code: 1001, status_msg: 'rate limited' }, 'test')).toThrow(/test API failed: rate limited/);
});
it('Item normalizers produce well-formed JS function declarations', () => {
expect(VIDEO_ITEM_NORMALIZER).toContain('function normalizeVideoItem(');
expect(USER_ITEM_NORMALIZER).toContain('function normalizeUserRow(');
expect(LIVE_ITEM_NORMALIZER).toContain('function normalizeLiveItem(');
expect(LIVE_ITEM_NORMALIZER).toContain('room.user_count ?? room.viewerCount');
expect(LIVE_ITEM_NORMALIZER).toContain('room.like_count ?? room.likeCount');
expect(NOTIFICATION_NORMALIZER).toContain('function normalizeNotification(');
});
});
describe('tiktok/explore (page-context refactor)', () => {
it('registers as read-only browser COOKIE adapter with full video columns', () => {
expect(exploreCommand.access).toBe('read');
expect(exploreCommand.browser).toBe(true);
expect(exploreCommand.strategy).toBe('cookie');
expect(exploreCommand.columns).toEqual([
'index', 'id', 'author', 'url', 'cover', 'title', 'desc',
'plays', 'likes', 'comments', 'shares', 'createTime',
]);
});
it('validates --limit upfront before navigating (no silent clamp)', async () => {
const page = makePage([]);
await expect(exploreCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(exploreCommand.func(page, { limit: exploreTest.MAX_LIMIT + 1 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('navigates to /explore and returns the full video row shape from evaluate', async () => {
const page = makePage([sampleVideoRow, { ...sampleVideoRow, id: '7350000000000000001', index: 99 }]);
const rows = await exploreCommand.func(page, { limit: 5 });
expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/explore', { waitUntil: 'load', settleMs: 5000 });
expect(rows).toEqual([sampleVideoRow, { ...sampleVideoRow, id: '7350000000000000001', index: 99 }]);
});
it('maps empty / unrecognised evaluate failures to typed errors', async () => {
await expect(exploreCommand.func(makePage([]), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore')), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore (recommend API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore (recommend API failed: HTTP 403)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore (recommend API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(exploreCommand.func(makeFailingPage(new Error('boom')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('build script targets recommend-feed state + recommend item_list endpoint', () => {
const script = exploreTest.buildExploreScript(15);
expect(script).toContain('/api/recommend/item_list/');
expect(script).toContain("assertTikTokApiSuccess(data, 'recommend')");
expect(script).toContain('findUniversalData');
expect(script).toContain('normalizeVideoItem');
});
});
describe('tiktok/user (page-context refactor)', () => {
it('registers as read-only browser COOKIE adapter with sourced video columns', () => {
expect(userCommand.access).toBe('read');
expect(userCommand.browser).toBe(true);
expect(userCommand.strategy).toBe('cookie');
expect(userCommand.columns).toEqual([
'index', 'id', 'source', 'author', 'url', 'cover', 'title', 'desc',
'plays', 'likes', 'comments', 'shares', 'createTime',
]);
});
it('validates username and --limit upfront before navigating', async () => {
const page = makePage([]);
await expect(userCommand.func(page, { username: '', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
await expect(userCommand.func(page, { username: '@bad/name', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
await expect(userCommand.func(page, { username: 'dictogo', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(userCommand.func(page, { username: 'dictogo', limit: userTest.MAX_LIMIT + 1 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('navigates to the profile and returns full sourced video rows', async () => {
const page = makePage([sampleUserVideoRow]);
await expect(userCommand.func(page, { username: '@dictogo', limit: 5 })).resolves.toEqual([sampleUserVideoRow]);
expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/@dictogo', { waitUntil: 'load', settleMs: 6000 });
});
it('maps empty, auth, upstream, and invalid JSON failures to typed errors', async () => {
await expect(userCommand.func(makePage([]), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo (profile/search API failed: HTTP 500)')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo (profile/search API failed: HTTP 403)')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo (profile/search API failed: invalid JSON)')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('build script resolves secUid, pages post-list, and exact-author search fallback', () => {
const script = userTest.buildUserScript('dictogo', 20);
expect(script).toContain('/api/user/detail/');
expect(script).toContain("assertTikTokApiSuccess(detail, 'user-detail')");
expect(script).toContain('/api/post/item_list/');
expect(script).toContain("assertTikTokApiSuccess(data, 'post-list')");
expect(script).toContain('/api/search/general/full/');
expect(script).toContain("assertTikTokApiSuccess(data, 'search')");
expect(script).toContain("'profile-api'");
expect(script).toContain("'bootstrap'");
expect(script).toContain("'search-fallback'");
expect(script).toContain(JSON.stringify('dictogo'));
expect(script).not.toContain('AUTH_REQUIRED: cannot resolve secUid');
});
});
describe('tiktok/friends (page-context refactor)', () => {
it('registers as read-only COOKIE adapter with user columns', () => {
expect(friendsCommand.access).toBe('read');
expect(friendsCommand.browser).toBe(true);
expect(friendsCommand.strategy).toBe('cookie');
expect(friendsCommand.columns).toEqual([
'index', 'username', 'name', 'secUid', 'verified', 'followers', 'following', 'url',
]);
});
it('validates --limit upfront and never navigates on bad input', async () => {
const page = makePage([]);
await expect(friendsCommand.func(page, { limit: -2 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('navigates to /friends and surfaces empty as EmptyResultError', async () => {
const page = makePage([sampleUserRow]);
await expect(friendsCommand.func(page, { limit: 5 })).resolves.toEqual([sampleUserRow]);
expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/friends', { waitUntil: 'load', settleMs: 5000 });
await expect(friendsCommand.func(makePage([]), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
await expect(friendsCommand.func(makeFailingPage(new Error('No friend suggestions returned by TikTok (recommend-user API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(friendsCommand.func(makeFailingPage(new Error('No friend suggestions returned by TikTok (recommend-user API failed: HTTP 401)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(friendsCommand.func(makeFailingPage(new Error('No friend suggestions returned by TikTok (recommend-user API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('build script targets recommend-user endpoint via state-then-API', () => {
const script = friendsTest.buildFriendsScript(20);
expect(script).toContain('/api/recommend/user/');
expect(script).toContain("assertTikTokApiSuccess(data, 'recommend-user')");
expect(script).toContain('findUniversalData');
expect(script).toContain('normalizeUserRow');
});
});
describe('tiktok/following (page-context refactor)', () => {
it('registers as read-only COOKIE adapter with user columns', () => {
expect(followingCommand.access).toBe('read');
expect(followingCommand.browser).toBe(true);
expect(followingCommand.strategy).toBe('cookie');
expect(followingCommand.columns).toEqual([
'index', 'username', 'name', 'secUid', 'verified', 'followers', 'following', 'url',
]);
});
it('validates --limit upfront', async () => {
const page = makePage([]);
await expect(followingCommand.func(page, { limit: 999 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('maps AUTH_REQUIRED sentinel to AuthRequiredError', async () => {
const page = makeFailingPage(new Error('AUTH_REQUIRED: cannot resolve viewer secUid (login required)'));
await expect(followingCommand.func(page, { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(followingCommand.func(makeFailingPage(new Error('No following entries returned (user-list API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(followingCommand.func(makeFailingPage(new Error('No following entries returned (user-list API failed: HTTP 403)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(followingCommand.func(makeFailingPage(new Error('No following entries returned (user-list API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('navigates to /following and returns the full user row shape', async () => {
const page = makePage([sampleUserRow]);
await expect(followingCommand.func(page, { limit: 5 })).resolves.toEqual([sampleUserRow]);
expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/following', { waitUntil: 'load', settleMs: 5000 });
});
it('build script resolves viewer secUid then pages user-list scene=21', () => {
const script = followingTest.buildFollowingScript(20);
expect(script).toContain('/api/user/list/');
expect(script).toContain("assertTikTokApiSuccess(data, 'user-list')");
expect(script).toContain("scene: '21'");
expect(script).toContain('findViewerSecUid');
expect(script).toContain('normalizeUserRow');
});
});
describe('tiktok/notifications (page-context refactor)', () => {
it('registers as read-only COOKIE adapter with notification columns', () => {
expect(notificationsCommand.access).toBe('read');
expect(notificationsCommand.browser).toBe(true);
expect(notificationsCommand.strategy).toBe('cookie');
expect(notificationsCommand.columns).toEqual([
'index', 'id', 'from', 'text', 'createTime',
]);
});
it('validates --limit and --type upfront before navigating', async () => {
const page = makePage([]);
await expect(notificationsCommand.func(page, { limit: 0, type: 'all' })).rejects.toBeInstanceOf(ArgumentError);
await expect(notificationsCommand.func(page, { limit: 5, type: 'bogus' })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('maps AUTH_REQUIRED sentinel to AuthRequiredError, empty to EmptyResultError', async () => {
await expect(notificationsCommand.func(
makeFailingPage(new Error('AUTH_REQUIRED: TikTok inbox requires login (likes)')),
{ limit: 5, type: 'likes' },
)).rejects.toBeInstanceOf(AuthRequiredError);
await expect(notificationsCommand.func(makePage([]), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(EmptyResultError);
await expect(notificationsCommand.func(makeFailingPage(new Error('No notifications returned for all (notice API failed: HTTP 500)')), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(notificationsCommand.func(makeFailingPage(new Error('No notifications returned for all (notice API failed: HTTP 401)')), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(notificationsCommand.func(makeFailingPage(new Error('No notifications returned for all (notice API failed: invalid JSON)')), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(CommandExecutionError);
const page = makePage([sampleNotificationRow]);
await expect(notificationsCommand.func(page, { limit: 5, type: 'comments' })).resolves.toEqual([sampleNotificationRow]);
});
it('embeds the requested notice_type code from NOTIFICATION_TYPES into the script', () => {
for (const [key, meta] of Object.entries(NOTIFICATION_TYPES)) {
const script = notificationsTest.buildNotificationsScript(15, key);
expect(script).toContain('const noticeType = ' + meta.code);
expect(script).toContain('/api/notice/multi/');
expect(script).toContain("assertTikTokApiSuccess(data, 'notice')");
}
});
});
describe('tiktok/live (page-context refactor)', () => {
it('registers as read-only COOKIE adapter with live columns', () => {
expect(liveCommand.access).toBe('read');
expect(liveCommand.browser).toBe(true);
expect(liveCommand.strategy).toBe('cookie');
expect(liveCommand.columns).toEqual([
'index', 'streamer', 'name', 'title', 'viewers', 'likes', 'secUid', 'url',
]);
});
it('validates --limit upfront', async () => {
const page = makePage([]);
await expect(liveCommand.func(page, { limit: -1 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('navigates to /live and surfaces empty as EmptyResultError', async () => {
const page = makePage([sampleLiveRow]);
await expect(liveCommand.func(page, { limit: 5 })).resolves.toEqual([sampleLiveRow]);
expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/live', { waitUntil: 'load', settleMs: 5000 });
await expect(liveCommand.func(makePage([]), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
await expect(liveCommand.func(makeFailingPage(new Error('No live streams returned (live-discover API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
await expect(liveCommand.func(makeFailingPage(new Error('No live streams returned (live-discover API failed: HTTP 403)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
await expect(liveCommand.func(makeFailingPage(new Error('No live streams returned (live-discover API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('build script targets live-discover endpoint via state-then-API', () => {
const script = liveTest.buildLiveScript(15);
expect(script).toContain('/api/live/discover/get/');
expect(script).toContain("assertTikTokApiSuccess(data, 'live-discover')");
expect(script).toContain('findUniversalData');
expect(script).toContain('normalizeLiveItem');
});
});