Files
fangke/sdk/test/context.test.ts
T
浅念 22d89a3f42 feat: 移动端设备信息最大化采集(型号/UA-CH/视口/方向/触点/内存/网络/深浅色)
SDK:buildEnvContext 同步扩展 12 键(dpr/viewport/orientation/maxTouchPoints/
deviceMemory/languages/colorScheme/standalone/network downlink·rtt·saveData/
微信版本),逐键 try/catch 静默缺则不挂;新增 collectHighEntropyHints(UA-CH
model/platformVersion/architecture/bitness),init 后 Promise.race 2s 超时
异步并入 __ctk_ctx——批次 context flush 时读取,不阻塞首屏。

后端:Visitor 加 device_model/env_extra(迁移 0005);ingest 首批快照
EXTENDED_ENV_KEYS 白名单(值截 100),非首批不覆盖、唯 device_model 空且
本批有值时补填一次(晚到 hints);列表/详情 API 随行输出。

前端:详情访问环境卡设备行升级显示型号(手机 · Pixel 7),追加像素比/
视口/屏幕方向/触点/内存/网络质量/深浅色/微信版本行(env_extra 逐键判空,
旧数据整块不显示);列表设备列主行拼型号(手机·Pixel 7)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 19:12:20 +08:00

325 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, afterEach, vi } from "vitest";
import {
buildEnvContext,
collectHighEntropyHints,
parseUtm,
sanitizeUrl,
pageContext,
} from "../src/context";
// userAgent 是 Navigator.prototype 上的 accessor;实例上 defineProperty 覆盖,delete 还原
function stubUA(ua: string) {
Object.defineProperty(navigator, "userAgent", { value: ua, configurable: true });
}
afterEach(() => {
delete (navigator as { userAgent?: string }).userAgent;
});
const UA_IPHONE_SAFARI =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (Version/17.0 Mobile/15E148 Safari/604.1";
const UA_TABLET =
"Mozilla/5.0 (X11; Tablet; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
// 真实 iPad Safari UA 恒含 Mobile 串——规格修订:iPad/Tablet 先判,归"平板"
const UA_IPAD_REAL =
"Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (Version/16.0 Mobile/15E148 Safari/604.1";
// Firefox Android 平板 UA:显式 "; Tablet;" 且含 Android 无 Mobile——旧顺序会误判手机
const UA_ANDROID_TABLET_FF =
"Mozilla/5.0 (Android 13; Tablet; rv:121.0) Gecko/121.0 Firefox/121.0";
// iOS Firefox UA:FxiOS + 尾部恒含 Safari/604.1——Firefox 检测须先于 Safari
const UA_IOS_FIREFOX =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/121.0 Mobile/15E148 Safari/604.1";
const UA_WIN_CHROME =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
const UA_WIN_EDGE =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0";
const UA_ANDROID_WECHAT =
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49";
describe("parseUtm", () => {
it("只保留 utm_ 前缀参数", () => {
expect(parseUtm("?utm_source=google&utm_medium=cpc&other=1&q=中文")).toEqual({
utm_source: "google",
utm_medium: "cpc",
});
});
it("键值各截 100 字符", () => {
const long = "x".repeat(150);
const out = parseUtm(`?utm_${long}=${long}`);
expect(Object.keys(out)).toHaveLength(1);
const key = Object.keys(out)[0]!;
expect(key).toHaveLength(100);
expect(out[key]).toHaveLength(100);
});
it("无 utm 参数时返回空对象", () => {
expect(parseUtm("?a=1&b=2")).toEqual({});
});
});
describe("sanitizeUrl", () => {
it("删除黑名单参数,保留其余(含空值),不误伤 monkey", () => {
expect(sanitizeUrl("/p?email=a@b.com&token=x&id=3&utm_source=g&monkey=1")).toBe(
"/p?id=3&utm_source=g&monkey=1",
);
});
it("空值参数保留", () => {
expect(sanitizeUrl("/p?empty=&id=1")).toBe("/p?empty=&id=1");
});
it("黑名单整键精确匹配(大小写不敏感),子串参数不删", () => {
expect(sanitizeUrl("/p?Token=1&email=x@y.z&monkey=1&utoken=2")).toBe("/p?monkey=1&utoken=2");
});
it("全部被删时不残留 ?,无查询串时原样返回", () => {
expect(sanitizeUrl("/p?token=1&password=2")).toBe("/p");
expect(sanitizeUrl("/p/plain")).toBe("/p/plain");
});
});
describe("buildEnvContext", () => {
it("happy-dom 默认 UA:含全部必需键且 device_type 合法", () => {
// 默认 UA: Mozilla/5.0 (X11; Win32 x64) AppleWebKit/537.36 ... HappyDOM/0.0.0
const env = buildEnvContext();
expect(env.device_type).toBe("PC"); // 默认 UA 无 Mobile/Android/iPhone/iPad/Tablet
expect(["PC", "手机", "平板"]).toContain(env.device_type);
expect(typeof env.os).toBe("string");
expect(typeof env.browser).toBe("string");
expect(typeof env.language).toBe("string");
expect(env.language).toBe(navigator.language);
expect(typeof env.timezone).toBe("string");
expect(env.screen).toBe(`${screen.width}x${screen.height}`);
expect(typeof env.hardware_concurrency).toBe("number"); // happy-dom 提供
expect("connection" in env).toBe(false); // happy-dom 无 connection
});
it("iPhone UA → 手机 + iOS + Safari", () => {
stubUA(UA_IPHONE_SAFARI);
const env = buildEnvContext();
expect(env.device_type).toBe("手机");
expect(env.os).toBe("iOS");
expect(env.browser).toBe("Safari");
});
it("Tablet UA(无 Mobile/Android/iPhone 串)→ 平板", () => {
stubUA(UA_TABLET);
expect(buildEnvContext().device_type).toBe("平板");
});
it("真实 iPad UA(含 Mobile 串)→ 平板:规格修订 iPad/Tablet 先于 Mobile 判", () => {
stubUA(UA_IPAD_REAL);
const env = buildEnvContext();
expect(env.device_type).toBe("平板");
expect(env.os).toBe("iOS");
expect(env.browser).toBe("Safari");
});
it("Firefox Android 平板 UA(Android + '; Tablet;')→ 平板", () => {
stubUA(UA_ANDROID_TABLET_FF);
const env = buildEnvContext();
expect(env.device_type).toBe("平板");
expect(env.os).toBe("Android");
expect(env.browser).toBe("Firefox");
});
it("iOS Firefox UA(FxiOS + Safari 串)→ Firefox:FxiOS 先于 Safari 判", () => {
stubUA(UA_IOS_FIREFOX);
const env = buildEnvContext();
expect(env.browser).toBe("Firefox");
expect(env.device_type).toBe("手机");
expect(env.os).toBe("iOS");
});
it("Windows Chrome UA → PC + Windows + Chrome", () => {
stubUA(UA_WIN_CHROME);
const env = buildEnvContext();
expect(env.device_type).toBe("PC");
expect(env.os).toBe("Windows");
expect(env.browser).toBe("Chrome");
});
it("Edg UA(内嵌 Chrome 串)→ browser 为 Edge,验证 Edg 先于 Chrome", () => {
stubUA(UA_WIN_EDGE);
const env = buildEnvContext();
expect(env.browser).toBe("Edge");
expect(env.os).toBe("Windows");
});
it("微信 UA(内嵌 Chrome/Safari 串)→ browser 为 WeChat", () => {
stubUA(UA_ANDROID_WECHAT);
const env = buildEnvContext();
expect(env.browser).toBe("WeChat");
expect(env.device_type).toBe("手机");
expect(env.os).toBe("Android");
});
});
describe("buildEnvContext 扩展采集(移动端设备信息最大化)", () => {
const restore: Array<() => void> = [];
/** 实例属性覆盖 + 注册还原(navigator/screen/window 各 API 均 accessor,defineProperty 覆盖) */
const stub = (obj: object, key: string, value: unknown) => {
Object.defineProperty(obj, key, { value, configurable: true });
restore.push(() => delete (obj as Record<string, unknown>)[key]);
};
/** 按查询串精确命中的 matchMedia 桩(orientation/color_scheme/standalone 共用) */
const stubMatchMedia = (matched: string[] = []) => {
vi.stubGlobal(
"matchMedia",
(q: string) => ({ matches: matched.includes(q), media: q }),
);
};
afterEach(() => {
restore.splice(0).forEach((fn) => fn());
vi.unstubAllGlobals();
});
it("同步扩展键全量采集(dpr/视口/方向/触点/内存/语言/深浅色/standalone/网络/微信版本)", () => {
stubUA(UA_ANDROID_WECHAT);
stub(window, "devicePixelRatio", 3);
stub(window, "innerWidth", 390);
stub(window, "innerHeight", 844);
stub(screen, "orientation", { type: "portrait-primary" });
stub(navigator, "maxTouchPoints", 5);
stub(navigator, "deviceMemory", 8);
stub(navigator, "languages", ["zh-CN", "en", "ja"]);
stub(navigator, "connection", {
effectiveType: "4g",
downlink: 10,
rtt: 50,
saveData: true,
});
stubMatchMedia(["(prefers-color-scheme: dark)"]); // orientation 走 screen.orientation,深色走桩
const env = buildEnvContext();
expect(env.device_pixel_ratio).toBe(3);
expect(env.viewport).toBe("390x844");
expect(env.orientation).toBe("portrait-primary"); // screen.orientation.type 优先
expect(env.max_touch_points).toBe(5);
expect(env.device_memory).toBe(8);
expect(env.languages).toBe("zh-CN,en,ja");
expect(env.color_scheme).toBe("dark");
expect(env.standalone).toBe("0"); // 桩未命中 standalone 查询
expect(env.network_downlink).toBe(10);
expect(env.network_rtt).toBe(50);
expect(env.save_data).toBe("1");
expect(env.wechat_version).toBe("8.0.49"); // UA 的 MicroMessenger/8.0.49
});
it("screen.orientation 缺失 → matchMedia 兜底 portrait/landscape", () => {
stubMatchMedia(["(orientation: portrait)"]);
expect(buildEnvContext().orientation).toBe("portrait");
stubMatchMedia([]); // portrait 不匹配 → landscape
expect(buildEnvContext().orientation).toBe("landscape");
});
it("languages 超 100 截断;saveData 布尔转 1/0", () => {
stub(navigator, "languages", ["a".repeat(60), "b".repeat(60)]);
stub(navigator, "connection", { effectiveType: "3g", saveData: false });
const env = buildEnvContext();
expect(env.languages).toHaveLength(100);
expect(env.save_data).toBe("0");
});
it("能力缺失环境:可选键不挂、不崩溃(无 matchMedia/触点/内存/连接)", () => {
stubUA(UA_WIN_CHROME); // 无 MicroMessenger → 无 wechat_version
stub(window, "matchMedia", undefined); // matchMedia 不可用
stub(navigator, "maxTouchPoints", undefined);
stub(navigator, "languages", undefined);
const env = buildEnvContext();
expect("orientation" in env).toBe(false); // screen.orientation 与 matchMedia 双缺
expect("color_scheme" in env).toBe(false);
expect("standalone" in env).toBe(false);
expect("max_touch_points" in env).toBe(false);
expect("languages" in env).toBe(false);
expect("device_memory" in env).toBe(false);
expect("network_downlink" in env).toBe(false);
expect("save_data" in env).toBe(false);
expect("wechat_version" in env).toBe(false);
// 基础键照常(老环境只是缺扩展信号,主采集不受影响)
expect(env.device_type).toBe("PC");
expect(env.os).toBe("Windows");
});
});
describe("collectHighEntropyHints(UA-CH 高熵提示)", () => {
const restore: Array<() => void> = [];
const stubUAD = (impl: (hints: string[]) => Promise<Record<string, unknown>>) => {
Object.defineProperty(navigator, "userAgentData", {
value: { getHighEntropyValues: impl },
configurable: true,
});
restore.push(() => delete (navigator as { userAgentData?: unknown }).userAgentData);
};
afterEach(() => restore.splice(0).forEach((fn) => fn()));
it("getHighEntropyValues → device_model/platform_version/architecture/bitness", async () => {
stubUAD(async () => ({
model: "Pixel 7",
platformVersion: "13.0.0",
architecture: "arm",
bitness: "64",
fullVersionList: [], // 未请求的键忽略
}));
expect(await collectHighEntropyHints()).toEqual({
device_model: "Pixel 7",
platform_version: "13.0.0",
architecture: "arm",
bitness: "64",
});
});
it("型号超长截断(64/32/16/8 各档)", async () => {
stubUAD(async () => ({
model: "M".repeat(80),
platformVersion: "P".repeat(40),
architecture: "A".repeat(20),
bitness: "B".repeat(10),
}));
const hints = await collectHighEntropyHints();
expect(hints.device_model).toHaveLength(64);
expect(hints.platform_version).toHaveLength(32);
expect(hints.architecture).toHaveLength(16);
expect(hints.bitness).toHaveLength(8);
});
it("空值键不挂(model 空串等)", async () => {
stubUAD(async () => ({ model: "", platformVersion: undefined, architecture: null }));
expect(await collectHighEntropyHints()).toEqual({});
});
it("无 userAgentData / getHighEntropyValues 抛异常 → 空对象不崩", async () => {
expect(await collectHighEntropyHints()).toEqual({}); // happy-dom 默认无 userAgentData
stubUAD(() => Promise.reject(new Error("denied")));
expect(await collectHighEntropyHints()).toEqual({});
});
});
describe("pageContext", () => {
it("path 清洗 + utm 提取 + title 读取", async () => {
await (window as any).happyDOM.setURL("http://localhost/p?token=1&utm_source=g");
document.title = "测试页";
const ctx = pageContext();
expect(ctx.path).toBe("/p?utm_source=g"); // token 已删,utm_source 保留
expect(ctx.title).toBe("测试页");
expect(ctx.utm).toEqual({ utm_source: "g" });
expect(ctx.referrer).toBe("");
expect(ctx.referrer_domain).toBe("");
});
it("referrer_domain 提取 hostname,非法/空 referrer 降级空串", () => {
const stub = (v: string) =>
Object.defineProperty(document, "referrer", { value: v, configurable: true });
try {
stub("https://www.google.com/search?q=x");
expect(pageContext().referrer_domain).toBe("www.google.com");
stub("not a url");
expect(pageContext().referrer_domain).toBe("");
stub("");
expect(pageContext().referrer_domain).toBe("");
} finally {
delete (document as { referrer?: string }).referrer;
}
});
});