22d89a3f42
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>
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
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;
|
||
}
|
||
});
|
||
});
|