SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,264 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { FriendDressError, getFriendDressWebAPI } from '@snowluma/protocol/web/friend-dress';
import { RequestUtil } from '@snowluma/protocol/web/request-util';
// 真实装扮页 SSR HTML(脱敏:uin 改为 10000、头像 hash 已打码)。
// __INITIAL_ASYNCDATA__ 里 7 项装扮:气泡(2)/名片(15)/彩色屏保(22)/来电(17)/
// 挂件(4)/头像(23)/头像双击动作(47) —— 其中 2/23 属服务器不回真值的废数据。
const SAMPLE_HTML = readFileSync(
fileURLToPath(new URL('./fixtures/friend-dress-sample.html', import.meta.url)),
'utf8',
);
const cookies = { uin: 'o10001', skey: 'SKEY', p_uin: 'o10001', p_skey: 'PSKEY' };
/** 把任意 asyncData 对象包成最小可解析的 SSR HTML。 */
const wrapHtml = (data: unknown): string =>
`<html><script>window.__INITIAL_ASYNCDATA__=${JSON.stringify(data)};(function(){})()</script></html>`;
const mockHtml = (html: string) =>
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(html as never);
describe('friend-dress / real sample parsing', () => {
afterEach(() => vi.restoreAllMocks());
it('sends cookie + mobile UA to zb.vip.qq.com with the target uin', async () => {
const request = mockHtml(SAMPLE_HTML);
await getFriendDressWebAPI(cookies, '10000');
const [url, method, , headers, limits] = request.mock.calls[0]!;
expect(url).toContain('https://zb.vip.qq.com/v2/pages/aioDressPage?');
expect(url).toContain('targetUin=10000');
// traceDetail 是预编码的 base64 串,必须原样出现(不能被二次编码成 base64%2D…)。
expect(url).toContain('&traceDetail=base64-');
expect(method).toBe('GET');
expect(headers).toMatchObject({
Cookie: 'uin=o10001; skey=SKEY; p_uin=o10001; p_skey=PSKEY',
});
expect((headers as Record<string, string>)['User-Agent']).toContain('QQ/');
expect(limits).toEqual({
timeoutMs: 10_000,
maxResponseBytes: 2 * 1024 * 1024,
});
});
it('parses the real page: svip, avatar, and only resolvable dress items', async () => {
mockHtml(SAMPLE_HTML);
const dress = await getFriendDressWebAPI(cookies, '10000');
expect(dress.target_uin).toBe('10000');
expect(dress.is_svip).toBe(true);
expect(dress.avatar_url).toBe('https://q.qlogo.cn/g?b=qq&nk=10000&s=100');
// 气泡(2)/头像(23) 是服务器永远回默认款的废数据,必须被剔除。
const appIds = dress.items.map((i) => i.app_id);
expect(appIds).not.toContain(2);
expect(appIds).not.toContain(23);
expect(appIds).toEqual([15, 22, 17, 4, 47]);
});
it('maps item fields: kind, itemId default 0, price default 0', async () => {
mockHtml(SAMPLE_HTML);
const dress = await getFriendDressWebAPI(cookies, '10000');
const card = dress.items.find((i) => i.app_id === 15)!;
expect(card.kind).toBe('名片');
expect(card.item_id).toBe(201534);
expect(card.name).toBe('二次元少女');
expect(card.preview_url).toBe(
'https://tianquan.gtimg.cn/card/original/201534/templateThumb.jpg',
);
expect(card.price).toBe(0);
// 默认挂件没有 itemId 字段 → 0。
const widget = dress.items.find((i) => i.app_id === 4)!;
expect(widget.item_id).toBe(0);
expect(widget.kind).toBe('挂件');
});
it('derives the funcall video url from the real web_image.jpg preview', async () => {
mockHtml(SAMPLE_HTML);
const dress = await getFriendDressWebAPI(cookies, '10000');
const funcall = dress.items.find((i) => i.app_id === 17)!;
expect(funcall.preview_url).toBe(
'https://tianquan.gtimg.cn/funcall/funCall/2730/web_image.jpg',
);
expect(funcall.video_url).toBe('https://tianquan.gtimg.cn/funcall/funCall/2730/media.mp4');
});
});
describe('friend-dress / synthetic payloads', () => {
afterEach(() => vi.restoreAllMocks());
const base = {
targetUin: '10000',
isSvip: false,
avatarImage: '',
rawUsingList: [] as unknown[],
};
it('an empty rawUsingList is a valid result, not an error', async () => {
mockHtml(wrapHtml(base));
const dress = await getFriendDressWebAPI(cookies, '10000');
expect(dress.items).toEqual([]);
expect(dress.is_svip).toBe(false);
});
it('does NOT return the image url as video when a funcall preview has an unexpected name', async () => {
mockHtml(
wrapHtml({
...base,
rawUsingList: [
{
appId: 17,
itemId: 1,
name: 'x',
image: 'https://cdn.example/funCall/1/preview.png',
},
],
}),
);
const dress = await getFriendDressWebAPI(cookies, '10000');
expect(dress.items[0]!.video_url).toBe('');
expect(dress.items[0]!.preview_url).toBe('https://cdn.example/funCall/1/preview.png');
});
it('extracts the card video from immersiveMaterial', async () => {
mockHtml(
wrapHtml({
...base,
rawUsingList: [
{
appId: 15,
itemId: 1,
name: 'a',
image: 'https://cdn.example/a.jpg',
extraappinfo: {
extraInfo: {
immersiveMaterial: '{"videoUrl":"https://cdn.example/a.mp4"}',
},
},
},
],
}),
);
const dress = await getFriendDressWebAPI(cookies, '10000');
expect(dress.items[0]!.video_url).toBe('https://cdn.example/a.mp4');
});
it('labels unknown appIds without dropping them', async () => {
mockHtml(
wrapHtml({
...base,
rawUsingList: [{ appId: 999, itemId: 7, name: 'n', image: '' }],
}),
);
const dress = await getFriendDressWebAPI(cookies, '10000');
expect(dress.items[0]!.kind).toBe('appId=999');
});
});
describe('friend-dress / error classification', () => {
afterEach(() => vi.restoreAllMocks());
const expectKind = async (uin: string, kind: string) => {
const e = await getFriendDressWebAPI(cookies, uin).catch((err: unknown) => err);
expect(e).toBeInstanceOf(FriendDressError);
expect((e as FriendDressError).kind).toBe(kind);
return e as FriendDressError;
};
it('network: transport failure is not folded into a parse error', async () => {
vi.spyOn(RequestUtil, 'HttpGetText').mockRejectedValue(
new Error('Unexpected status code: 403'),
);
const e = await expectKind('10000', 'network');
expect(e.message).toContain('403');
});
it('parse: html without __INITIAL_ASYNCDATA__ (login-wall / risk-control shell)', async () => {
mockHtml('<html><body>请先登录</body></html>');
await expectKind('10000', 'parse');
});
it('parse: __INITIAL_ASYNCDATA__ present but not valid JSON', async () => {
mockHtml('<script>window.__INITIAL_ASYNCDATA__={oops};(function(){})()</script>');
await expectKind('10000', 'parse');
});
it('parse: a top-level array never matches the SSR object shape', async () => {
mockHtml(wrapHtml([1, 2]));
await expectKind('10000', 'parse');
});
it('structure: malformed immersiveMaterial is not silently discarded', async () => {
mockHtml(
wrapHtml({
targetUin: '10000',
rawUsingList: [
{
appId: 15,
itemId: 2,
name: 'broken',
image: 'https://cdn.example/b.jpg',
extraappinfo: { extraInfo: { immersiveMaterial: '{not json' } },
},
],
}),
);
const e = await expectKind('10000', 'structure');
expect(e.message).toContain('immersiveMaterial');
});
it.each([
['not a string', { videoUrl: 'https://cdn.example/video.mp4' }],
['an array', '[]'],
['missing videoUrl', '{}'],
['non-string videoUrl', '{"videoUrl":42}'],
])('structure: immersiveMaterial %s is not silently discarded', async (_label, material) => {
mockHtml(
wrapHtml({
targetUin: '10000',
rawUsingList: [
{
appId: 15,
itemId: 2,
name: 'broken',
image: 'https://cdn.example/b.jpg',
extraappinfo: { extraInfo: { immersiveMaterial: material } },
},
],
}),
);
const e = await expectKind('10000', 'structure');
expect(e.message).toContain('immersiveMaterial');
});
it.each([
['rawUsingList missing', { targetUin: '10000' }],
['rawUsingList not an array', { targetUin: '10000', rawUsingList: 'nope' }],
['item not an object', { targetUin: '10000', rawUsingList: ['nope'] }],
['item appId missing', { targetUin: '10000', rawUsingList: [{}] }],
['item appId not a number', { targetUin: '10000', rawUsingList: [{ appId: 'four' }] }],
['targetUin not a string', { targetUin: 10000, rawUsingList: [] }],
])('structure: %s', async (_label, payload) => {
mockHtml(wrapHtml(payload));
await expectKind('10000', 'structure');
});
it('uin_mismatch: page answers for a different account than requested', async () => {
mockHtml(wrapHtml({ targetUin: '20000', rawUsingList: [] }));
const e = await expectKind('10000', 'uin_mismatch');
expect(e.message).toContain('20000');
expect(e.message).toContain('10000');
});
it('structure: targetUin missing cannot prove which account owns the response', async () => {
mockHtml(wrapHtml({ rawUsingList: [] }));
const e = await expectKind('10000', 'structure');
expect(e.message).toContain('targetUin');
});
});