Files
wehub-resource-sync 9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

487 lines
20 KiB
JavaScript

// Xiaoe adapter contract + helper tests.
//
// The IIFEs walk Vue's private runtime tree (`__vue__`, `$store`,
// `$children`) which JSDOM cannot reproduce — testing the full IIFE
// against a frozen fixture is not viable here. Instead this file
// exercises:
// 1. The pure helpers each IIFE embeds via `${fn.toString()}` — these
// are also called from JSDOM where the surface (URL building,
// simple DOM walks) does work without a Vue runtime.
// 2. Each adapter's `func`-form wiring: upfront validation, typed
// errors on empty results, rows passed through verbatim on the
// happy path, no wasted `page.goto` on bad input.
// 3. The `build*Script` outputs: the embedded helpers must be
// present, anti-patterns from the legacy adapters (silent column
// drop, silent slice truncation) must NOT regress.
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import {
CONTENT_SELECTORS,
contentCommand,
countXiaoeImages,
pickContentText,
requireXiaoePageUrl,
__test__ as contentTest,
} from './content.js';
import {
buildItemUrl,
catalogCommand,
chapterUrlPath,
typeLabel,
__test__ as catalogTest,
} from './catalog.js';
import {
buildCourseUrl,
coursesCommand,
__test__ as coursesTest,
} from './courses.js';
// ─── Registration contract ──────────────────────────────────────────
describe('xiaoe — adapter registration', () => {
it('content registers as cookie + browser, with the new full column shape', () => {
const reg = getRegistry();
expect(reg.get('xiaoe/content')).toBe(contentCommand);
expect(contentCommand.browser).toBe(true);
expect(contentCommand.strategy).toBe('cookie');
expect(contentCommand.access).toBe('read');
expect(contentCommand.domain).toBe('h5.xet.citv.cn');
// `content` is the new column — restoring the silently-dropped
// text the IIFE always extracted but the legacy `columns`
// declaration discarded.
expect(contentCommand.columns).toEqual([
'title', 'content', 'content_length', 'image_count',
]);
});
it('catalog registers as cookie + browser, columns unchanged', () => {
const reg = getRegistry();
expect(reg.get('xiaoe/catalog')).toBe(catalogCommand);
expect(catalogCommand.browser).toBe(true);
expect(catalogCommand.strategy).toBe('cookie');
expect(catalogCommand.access).toBe('read');
expect(catalogCommand.columns).toEqual([
'ch', 'chapter', 'no', 'title', 'type', 'resource_id', 'url', 'status',
]);
});
it('courses registers as cookie + browser, columns unchanged', () => {
const reg = getRegistry();
expect(reg.get('xiaoe/courses')).toBe(coursesCommand);
expect(coursesCommand.browser).toBe(true);
expect(coursesCommand.strategy).toBe('cookie');
expect(coursesCommand.access).toBe('read');
expect(coursesCommand.domain).toBe('study.xiaoe-tech.com');
expect(coursesCommand.columns).toEqual(['title', 'shop', 'url']);
});
});
// ─── content.js helpers ────────────────────────────────────────────
describe('xiaoe/content — pickContentText', () => {
function htmlDoc(body) {
return new JSDOM(`<html><body>${body}</body></html>`).window.document;
}
it('returns the first selector whose text exceeds the min-length threshold', () => {
const doc = htmlDoc(`
<div class="rich-text-wrap">${'a'.repeat(60)}</div>
<div class="content-wrap">${'b'.repeat(120)}</div>
`);
const text = pickContentText(doc, CONTENT_SELECTORS, 50);
expect(text.startsWith('a')).toBe(true);
expect(text).toHaveLength(60);
});
it('skips selectors whose text is at or below the threshold (legacy used > 50)', () => {
const doc = htmlDoc(`
<div class="rich-text-wrap">${'a'.repeat(50)}</div>
<div class="content-wrap">${'b'.repeat(120)}</div>
`);
const text = pickContentText(doc, CONTENT_SELECTORS, 50);
expect(text.startsWith('b')).toBe(true);
});
it('falls back through main → #app → body when no selector qualifies', () => {
const doc1 = htmlDoc(`<main>${'m'.repeat(80)}</main>`);
expect(pickContentText(doc1, CONTENT_SELECTORS).startsWith('m')).toBe(true);
const doc2 = htmlDoc(`<div id="app">${'p'.repeat(80)}</div>`);
expect(pickContentText(doc2, CONTENT_SELECTORS).startsWith('p')).toBe(true);
const doc3 = htmlDoc(`${'q'.repeat(80)}`);
expect(pickContentText(doc3, CONTENT_SELECTORS).startsWith('q')).toBe(true);
});
it('returns "" when the body is genuinely empty (legitimate empty signal)', () => {
const dom = new JSDOM('<html><body></body></html>');
// Force the body to be missing (edge case JSDOM actually creates one)
const doc = dom.window.document;
// empty body → fallback chain returns ''
expect(pickContentText(doc, CONTENT_SELECTORS)).toBe('');
});
});
describe('xiaoe/content — countXiaoeImages', () => {
function imgDoc(srcs) {
const tags = srcs.map((s) => `<img src="${s}">`).join('');
return new JSDOM(`<html><body>${tags}</body></html>`).window.document;
}
it('counts only xiaoe-hosted, non-data: images', () => {
const doc = imgDoc([
'https://commonresource-1252524126.cdn.xiaoe-tech.com/abc.jpg',
'data:image/png;base64,iVBOR',
'https://other-cdn.com/foo.jpg',
'https://app.xiaoe-tech.com/img/bar.png',
]);
expect(countXiaoeImages(doc)).toBe(2);
});
it('returns 0 when every image is excluded — never silently undefined', () => {
const doc = imgDoc([
'data:image/png;base64,xxx',
'https://avatar.cdn.com/u.jpg',
]);
expect(countXiaoeImages(doc)).toBe(0);
});
it('returns 0 on a doc with no <img> elements', () => {
const doc = new JSDOM('<html><body><p>no images here</p></body></html>').window.document;
expect(countXiaoeImages(doc)).toBe(0);
});
});
describe('xiaoe/content — requireXiaoePageUrl', () => {
it('rejects empty, malformed, and non-xiaoe URLs upfront', () => {
expect(() => requireXiaoePageUrl('', 'content')).toThrow(ArgumentError);
expect(() => requireXiaoePageUrl('not a url', 'content')).toThrow(ArgumentError);
expect(() => requireXiaoePageUrl('http://h5.xet.citv.cn/p/course/ecourse/v_x', 'content')).toThrow(ArgumentError);
expect(() => requireXiaoePageUrl('https://example.com/p/course/ecourse/v_x', 'content')).toThrow(ArgumentError);
});
it('accepts root and shop h5.xet.citv.cn URLs', () => {
expect(requireXiaoePageUrl('https://h5.xet.citv.cn/p/course/ecourse/v_x', 'content'))
.toBe('https://h5.xet.citv.cn/p/course/ecourse/v_x');
expect(requireXiaoePageUrl('https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_x', 'content'))
.toBe('https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_x');
});
});
// ─── catalog.js helpers ────────────────────────────────────────────
describe('xiaoe/catalog — typeLabel', () => {
it('maps known resource types to Chinese labels', () => {
expect(typeLabel(1)).toBe('图文');
expect(typeLabel(2)).toBe('直播');
expect(typeLabel(3)).toBe('音频');
expect(typeLabel(4)).toBe('视频');
expect(typeLabel(6)).toBe('专栏');
expect(typeLabel(8)).toBe('大专栏');
});
it('coerces string-typed resource type ints', () => {
expect(typeLabel('1')).toBe('图文');
expect(typeLabel('8')).toBe('大专栏');
});
it('returns the raw string for unknown types — never silently swallows', () => {
expect(typeLabel(99)).toBe('99');
expect(typeLabel('custom')).toBe('custom');
});
it('returns "" for nullish input (no falsy crash)', () => {
expect(typeLabel(null)).toBe('');
expect(typeLabel(undefined)).toBe('');
expect(typeLabel(0)).toBe('');
});
});
describe('xiaoe/catalog — buildItemUrl', () => {
it('passes through fully-qualified URLs unchanged', () => {
expect(buildItemUrl({ jump_url: 'https://example.com/x' }, 'https://h5.xet.citv.cn'))
.toBe('https://example.com/x');
expect(buildItemUrl({ h5_url: 'http://x.test/p' }, 'https://h5.xet.citv.cn'))
.toBe('http://x.test/p');
});
it('prepends origin to relative URLs', () => {
expect(buildItemUrl({ jump_url: '/p/123' }, 'https://h5.xet.citv.cn'))
.toBe('https://h5.xet.citv.cn/p/123');
});
it('falls through jump_url → h5_url → url priority', () => {
expect(buildItemUrl({ jump_url: '/a', h5_url: '/b', url: '/c' }, 'https://x.test'))
.toBe('https://x.test/a');
expect(buildItemUrl({ h5_url: '/b', url: '/c' }, 'https://x.test'))
.toBe('https://x.test/b');
expect(buildItemUrl({ url: '/c' }, 'https://x.test'))
.toBe('https://x.test/c');
});
it('returns "" when no URL field is present (no synthetic URL)', () => {
expect(buildItemUrl({}, 'https://x.test')).toBe('');
expect(buildItemUrl({ jump_url: '' }, 'https://x.test')).toBe('');
});
});
describe('xiaoe/catalog — chapterUrlPath', () => {
it('returns the right path for known chapter types', () => {
expect(chapterUrlPath(1)).toBe('/v1/course/text/');
expect(chapterUrlPath(2)).toBe('/v2/course/alive/');
expect(chapterUrlPath(3)).toBe('/v1/course/audio/');
expect(chapterUrlPath(4)).toBe('/v1/course/video/');
});
it('coerces string ints', () => {
expect(chapterUrlPath('1')).toBe('/v1/course/text/');
});
it('returns undefined for unknown / nullish — caller decides empty-URL semantics', () => {
expect(chapterUrlPath(99)).toBeUndefined();
expect(chapterUrlPath(0)).toBeUndefined();
expect(chapterUrlPath(null)).toBeUndefined();
});
});
// ─── courses.js helper ─────────────────────────────────────────────
describe('xiaoe/courses — buildCourseUrl', () => {
it('returns h5_url when present (highest priority)', () => {
expect(buildCourseUrl({
h5_url: 'https://h5.xet.citv.cn/p/abc',
app_id: 'appXXX',
resource_id: 'p_999',
})).toBe('https://h5.xet.citv.cn/p/abc');
});
it('returns url when h5_url missing', () => {
expect(buildCourseUrl({ url: 'https://study.xiaoe-tech.com/u/y' }))
.toBe('https://study.xiaoe-tech.com/u/y');
});
it('builds column path for resource_type 6', () => {
expect(buildCourseUrl({
app_id: 'appAAA',
resource_id: 'p_111',
resource_type: 6,
})).toBe('https://appAAA.h5.xet.citv.cn/v1/course/column/p_111?type=3');
});
it('builds ecourse path for non-column types', () => {
expect(buildCourseUrl({
app_id: 'appAAA',
resource_id: 'p_222',
resource_type: 4,
})).toBe('https://appAAA.h5.xet.citv.cn/p/course/ecourse/p_222');
});
it('returns "" when entry has no URL fields and no app_id+resource_id pair (no synthetic URL)', () => {
expect(buildCourseUrl({})).toBe('');
expect(buildCourseUrl({ app_id: 'x' })).toBe(''); // missing resource_id
expect(buildCourseUrl({ resource_id: 'p_1' })).toBe(''); // missing app_id
expect(buildCourseUrl(null)).toBe('');
expect(buildCourseUrl(undefined)).toBe('');
});
});
// ─── buildScript invariants (anti-pattern regression guards) ──────
describe('xiaoe — buildScript embeds helpers + no anti-patterns', () => {
it('content script embeds pickContentText + countXiaoeImages and never silently slices images', () => {
const script = contentTest.buildContentScript();
expect(script).toContain('pickContentText');
expect(script).toContain('countXiaoeImages');
// The legacy adapter did `images.slice(0, 20)` and silently
// dropped everything past the 20th image. The new script
// exposes only `image_count` (counting all images via the
// helper), so a hard `.slice(0, 20)` should not appear.
expect(script).not.toMatch(/\.slice\(0,\s*20\)/);
// Embeds the selector list literally so the IIFE has no
// dependency on the host page's globals.
expect(script).toContain('rich-text-wrap');
});
it('catalog script embeds typeLabel + buildItemUrl + chapterUrlPath', () => {
const script = catalogTest.buildCatalogScript();
expect(script).toContain('typeLabel');
expect(script).toContain('buildItemUrl');
expect(script).toContain('chapterUrlPath');
// Vue private API anchors must remain — these are the only
// stable hook into Xiaoe's SPA. If a future refactor removes
// them, the test will fail and force a deliberate decision.
expect(script).toContain('__vue__');
expect(script).toContain('$store');
});
it('courses script embeds buildCourseUrl', () => {
const script = coursesTest.buildCoursesScript();
expect(script).toContain('buildCourseUrl');
expect(script).toContain('__vue__');
});
});
// ─── Wire tests for the func form ─────────────────────────────────
describe('xiaoe/content — getXiaoeContent func wiring', () => {
function pageMock(rows) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(rows),
};
}
it('throws ArgumentError on missing url BEFORE calling page.goto', async () => {
const page = pageMock([]);
await expect(contentCommand.func(page, {})).rejects.toThrow(ArgumentError);
await expect(contentCommand.func(page, { url: '' })).rejects.toThrow(ArgumentError);
await expect(contentCommand.func(page, { url: ' ' })).rejects.toThrow(ArgumentError);
await expect(contentCommand.func(page, { url: 'https://example.com/p/x' })).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('wraps browser navigation failures as CommandExecutionError', async () => {
const page = {
goto: vi.fn().mockRejectedValue(new Error('net::ERR_ABORTED')),
evaluate: vi.fn(),
};
await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' }))
.rejects.toThrow(CommandExecutionError);
});
it('throws EmptyResultError when no rows are returned', async () => {
const page = pageMock([]);
await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' }))
.rejects.toThrow(EmptyResultError);
});
it('throws EmptyResultError when content is empty (login likely expired — fail-fast not silent empty row)', async () => {
const page = pageMock([{ title: 'shell', content: '', content_length: 0, image_count: 0 }]);
await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' }))
.rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when page.evaluate rejects', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockRejectedValue(new Error('CDP exploded')),
};
await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' }))
.rejects.toThrow(CommandExecutionError);
});
it('returns the rows verbatim on the happy path', async () => {
const fakeRow = {
title: 'demo',
content: 'hello world '.repeat(10),
content_length: 120,
image_count: 3,
};
const page = pageMock([fakeRow]);
const result = await contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' });
expect(result).toEqual([fakeRow]);
expect(page.goto).toHaveBeenCalledWith(
'https://h5.xet.citv.cn/p/x',
expect.objectContaining({ waitUntil: 'load' }),
);
});
});
describe('xiaoe/catalog — getXiaoeCatalog func wiring', () => {
function pageMock(rows) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(rows),
};
}
it('throws ArgumentError on missing url BEFORE calling page.goto', async () => {
const page = pageMock([]);
await expect(catalogCommand.func(page, {})).rejects.toThrow(ArgumentError);
await expect(catalogCommand.func(page, { url: 'https://example.com/p/c' })).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('wraps browser navigation failures as CommandExecutionError', async () => {
const page = {
goto: vi.fn().mockRejectedValue(new Error('Execution context was destroyed')),
evaluate: vi.fn(),
};
await expect(catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' }))
.rejects.toThrow(CommandExecutionError);
});
it('throws EmptyResultError when no chapters extracted (cookie likely expired)', async () => {
const page = pageMock([]);
await expect(catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' }))
.rejects.toThrow(EmptyResultError);
});
it('returns rows verbatim on the happy path', async () => {
const fake = [{
ch: 1, chapter: '入门', no: 1, title: '第一节',
type: '视频', resource_id: 'v_abc', url: 'https://x.test/v/abc', status: '未学',
}];
const page = pageMock(fake);
expect(await catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' }))
.toEqual(fake);
});
it('throws CommandExecutionError when page.evaluate rejects', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockRejectedValue(new Error('boom')),
};
await expect(catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' }))
.rejects.toThrow(CommandExecutionError);
});
});
describe('xiaoe/courses — getXiaoeCourses func wiring', () => {
function pageMock(rows) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(rows),
};
}
it('navigates to the study landing page (no positional arg required)', async () => {
const fake = [{ title: 'Course A', shop: 'Shop A', url: 'https://x.test/c/a' }];
const page = pageMock(fake);
const result = await coursesCommand.func(page, {});
expect(page.goto).toHaveBeenCalledWith(
'https://study.xiaoe-tech.com/',
expect.objectContaining({ waitUntil: 'load' }),
);
expect(result).toEqual(fake);
});
it('throws EmptyResultError when no cards found (cookie likely expired)', async () => {
const page = pageMock([]);
await expect(coursesCommand.func(page, {})).rejects.toThrow(EmptyResultError);
});
it('throws CommandExecutionError when page.evaluate rejects', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockRejectedValue(new Error('cdp')),
};
await expect(coursesCommand.func(page, {})).rejects.toThrow(CommandExecutionError);
});
it('wraps browser navigation failures as CommandExecutionError', async () => {
const page = {
goto: vi.fn().mockRejectedValue(new Error('net::ERR_ABORTED')),
evaluate: vi.fn(),
};
await expect(coursesCommand.func(page, {})).rejects.toThrow(CommandExecutionError);
});
});