@chainlesschain/personal-data-hub 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const { getAdapterGuide, ADAPTER_OVERRIDES } = require("../lib/adapter-guide");
6
+
7
+ describe("adapter-guide", () => {
8
+ it("wechat-pc guide reflects the 4.0 one-click reality (no manual PyWxDump as primary)", () => {
9
+ const g = getAdapterGuide("wechat-pc", "device");
10
+ // primary method is the automatic one-click, not manual decryption
11
+ const primary = g.methods[0];
12
+ expect(primary.recommended).toBe(true);
13
+ expect(primary.label).toMatch(/一键|自动/);
14
+ expect(primary.steps.join(" ")).toMatch(/一键采集|自动/);
15
+ // summary mentions the full coverage we now capture
16
+ expect(g.summary).toMatch(/公众号/);
17
+ expect(g.summary).toMatch(/朋友圈/);
18
+ expect(g.summary).toMatch(/收藏/);
19
+ // manual 3.x path is still offered as a fallback
20
+ expect(g.methods.some((m) => /3\.x|PyWxDump|手动/.test(m.label + m.steps.join(" ")))).toBe(true);
21
+ });
22
+
23
+ it("the 6 social platforms all have a tailored one-click ADB guide", () => {
24
+ for (const name of [
25
+ "social-bilibili",
26
+ "social-weibo",
27
+ "social-douyin",
28
+ "social-xiaohongshu",
29
+ "social-toutiao",
30
+ "social-kuaishou",
31
+ ]) {
32
+ expect(ADAPTER_OVERRIDES[name]).toBeTruthy();
33
+ const g = getAdapterGuide(name, "device");
34
+ const primary = g.methods[0];
35
+ expect(primary.recommended).toBe(true);
36
+ // recommended path is root-phone + one-click, not "go log in on the web"
37
+ expect(primary.label + primary.steps.join(" ")).toMatch(/一键|ADB|USB|root/i);
38
+ }
39
+ });
40
+
41
+ it("unknown adapter falls back to a category guide without throwing", () => {
42
+ const g = getAdapterGuide("totally-unknown", "snapshot");
43
+ expect(g.category).toBe("snapshot");
44
+ expect(Array.isArray(g.methods)).toBe(true);
45
+ expect(g.methods.length).toBeGreaterThan(0);
46
+ });
47
+ });
@@ -107,12 +107,17 @@ describe.each([
107
107
  ["DingTalkPcAdapter", DingTalkPcAdapter, "dingtalk"],
108
108
  ["FeishuPcAdapter", FeishuPcAdapter, "feishu"],
109
109
  ])("%s (honest best-effort)", (_label, Cls, platform) => {
110
- it("no-arg construct + DB_NOT_PULLED readiness + legalGate", async () => {
110
+ it("no-arg construct + APP_NOT_INSTALLED when nothing discoverable + legalGate", async () => {
111
111
  const a = new Cls();
112
+ a._deps.discoveryDeps = {
113
+ fs: { existsSync: () => false, readdirSync: () => [], statSync: () => ({ size: 0 }), constants: { R_OK: 4 } },
114
+ home: "/no-home",
115
+ env: {},
116
+ };
112
117
  expect(a.extractMode).toBe("device-pull");
113
118
  expect(a.dataDisclosure.legalGate).toBe(true);
114
119
  const r = await a.authenticate({ readinessOnly: true });
115
- expect(r.reason).toBe("DB_NOT_PULLED");
120
+ expect(r.reason).toBe("APP_NOT_INSTALLED");
116
121
  });
117
122
 
118
123
  it("reads messages → valid events, platform tag, raw preserved", async () => {
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Unit tests for _pc-local-discovery against a synthetic filesystem — no real
5
+ * App needs to be installed. Uses posix path so assertions are host-agnostic.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ const path = require("node:path");
10
+ const { discover, SUPPORTED_APPS } = require("../../lib/adapters/_pc-local-discovery");
11
+
12
+ // Build a fake fs from a map of { dirPath: [entryName...] } + a set of file paths.
13
+ function makeFakeFs({ tree = {}, files = {}, sizes = {} } = {}) {
14
+ const norm = (p) => p.replace(/\\/g, "/");
15
+ const fileSet = new Set(Object.keys(files).map(norm));
16
+ const dirSet = new Set(Object.keys(tree).map(norm));
17
+ return {
18
+ existsSync: (p) => fileSet.has(norm(p)) || dirSet.has(norm(p)),
19
+ readdirSync: (p) => {
20
+ const entries = tree[norm(p)] || [];
21
+ return entries.map((e) => ({
22
+ name: e.name,
23
+ isDirectory: () => e.type === "dir",
24
+ isFile: () => e.type === "file",
25
+ }));
26
+ },
27
+ statSync: (p) => ({ size: sizes[norm(p)] || 1 }),
28
+ constants: { R_OK: 4 },
29
+ };
30
+ }
31
+
32
+ const D = (name) => ({ name, type: "dir" });
33
+ const F = (name) => ({ name, type: "file" });
34
+
35
+ describe("_pc-local-discovery", () => {
36
+ it("supports the expected app keys", () => {
37
+ expect(SUPPORTED_APPS).toEqual(["wechat-pc", "qq-pc", "dingtalk-pc", "feishu-pc"]);
38
+ });
39
+
40
+ it("returns installed:false for an unknown app key (never throws)", () => {
41
+ const r = discover("totally-unknown", { fs: makeFakeFs(), home: "/h", env: {}, path: path.posix });
42
+ expect(r.installed).toBe(false);
43
+ expect(r.note).toMatch(/不支持/);
44
+ });
45
+
46
+ it("returns installed:false when nothing is on disk", () => {
47
+ const r = discover("wechat-pc", { fs: makeFakeFs(), home: "/h", env: {}, path: path.posix });
48
+ expect(r.installed).toBe(false);
49
+ expect(r.primaryDb).toBe(null);
50
+ });
51
+
52
+ it("discovers a WeChat 4.x account and picks message_0.db as primary", () => {
53
+ const base = "/h/Documents/xwechat_files";
54
+ const acc = `${base}/wxid_demo_42`;
55
+ const fs = makeFakeFs({
56
+ tree: {
57
+ [base]: [D("wxid_demo_42"), D("all_users")],
58
+ [`${acc}/db_storage/message`]: [F("message_0.db"), F("biz_message_0.db"), F("message_fts.db")],
59
+ },
60
+ files: {
61
+ [`${acc}/db_storage`]: 1,
62
+ [`${acc}/db_storage/message/message_0.db`]: 1,
63
+ [`${acc}/db_storage/contact/contact.db`]: 1,
64
+ [`${acc}/db_storage/sns/sns.db`]: 1,
65
+ },
66
+ sizes: {
67
+ [`${acc}/db_storage/message/message_0.db`]: 99,
68
+ [`${acc}/db_storage/message/biz_message_0.db`]: 50,
69
+ },
70
+ });
71
+ const r = discover("wechat-pc", { fs, home: "/h", env: {}, path: path.posix });
72
+ expect(r.installed).toBe(true);
73
+ expect(r.layout).toBe("4.x");
74
+ expect(r.encrypted).toBe(true);
75
+ expect(r.accounts).toHaveLength(1);
76
+ expect(r.accounts[0].id).toBe("wxid_demo");
77
+ expect(r.primaryDb).toContain("message_0.db");
78
+ // message_fts.db must NOT be picked up as a message db
79
+ const purposes = r.accounts[0].dbs.map((d) => d.purpose).sort();
80
+ expect(purposes).toContain("message");
81
+ expect(purposes).toContain("contact");
82
+ expect(r.accounts[0].dbs.some((d) => /message_fts/.test(d.path))).toBe(false);
83
+ });
84
+
85
+ it("discovers a WeChat 3.x account (MSG*.db + MicroMsg.db)", () => {
86
+ const base = "/h/Documents/WeChat Files";
87
+ const acc = `${base}/wxid_old`;
88
+ const fs = makeFakeFs({
89
+ tree: {
90
+ [base]: [D("wxid_old"), D("All Users")],
91
+ [`${acc}/Msg/Multi`]: [F("MSG0.db"), F("MSG1.db")],
92
+ },
93
+ files: {
94
+ [`${acc}/Msg/Multi/MSG0.db`]: 1,
95
+ [`${acc}/Msg/Multi/MSG1.db`]: 1,
96
+ [`${acc}/Msg/MicroMsg.db`]: 1,
97
+ },
98
+ });
99
+ const r = discover("wechat-pc", { fs, home: "/h", env: {}, path: path.posix });
100
+ expect(r.installed).toBe(true);
101
+ expect(r.layout).toBe("3.x");
102
+ expect(r.accounts[0].dbs.filter((d) => d.purpose === "message")).toHaveLength(2);
103
+ });
104
+
105
+ it("discovers a QQ NT account by numeric uin dir", () => {
106
+ const base = "/h/Documents/Tencent Files";
107
+ const acc = `${base}/896075341`;
108
+ const fs = makeFakeFs({
109
+ tree: { [base]: [D("896075341"), D("nt_qq")] },
110
+ files: {
111
+ [`${acc}/nt_qq/nt_db/nt_msg.db`]: 1,
112
+ [`${acc}/nt_qq/nt_db/group_info.db`]: 1,
113
+ },
114
+ });
115
+ const r = discover("qq-pc", { fs, home: "/h", env: {}, path: path.posix });
116
+ expect(r.installed).toBe(true);
117
+ expect(r.accounts[0].id).toBe("896075341");
118
+ expect(r.primaryDb).toContain("nt_msg.db");
119
+ expect(r.encrypted).toBe(true);
120
+ });
121
+
122
+ it("dingtalk best-effort scan finds plaintext db (encrypted:false)", () => {
123
+ const root = "/appdata/DingTalk";
124
+ const fs = makeFakeFs({
125
+ tree: {
126
+ [root]: [D("user1")],
127
+ [`${root}/user1`]: [F("im_message.db"), F("cache.db")],
128
+ },
129
+ files: {
130
+ [`${root}/user1/im_message.db`]: 1,
131
+ [`${root}/user1/cache.db`]: 1,
132
+ },
133
+ sizes: { [`${root}/user1/im_message.db`]: 100 },
134
+ });
135
+ const r = discover("dingtalk-pc", { fs, home: "/h", env: { APPDATA: "/appdata" }, path: path.posix });
136
+ expect(r.installed).toBe(true);
137
+ expect(r.encrypted).toBe(false);
138
+ expect(r.bestEffort).toBe(true);
139
+ expect(r.primaryDb).toContain("im_message.db");
140
+ });
141
+ });
@@ -105,12 +105,17 @@ async function collect(iter) {
105
105
  }
106
106
 
107
107
  describe("QQPcAdapter — readiness + construction", () => {
108
- it("no-arg construct + DB_NOT_PULLED readiness", async () => {
108
+ it("no-arg construct + APP_NOT_INSTALLED when nothing discoverable", async () => {
109
109
  const a = new QQPcAdapter();
110
+ a._deps.discoveryDeps = {
111
+ fs: { existsSync: () => false, readdirSync: () => [], statSync: () => ({ size: 0 }), constants: { R_OK: 4 } },
112
+ home: "/no-home",
113
+ env: {},
114
+ };
110
115
  expect(a.name).toBe("qq-pc");
111
116
  expect(a.dataDisclosure.legalGate).toBe(true);
112
117
  const r = await a.authenticate({ readinessOnly: true });
113
- expect(r.reason).toBe("DB_NOT_PULLED");
118
+ expect(r.reason).toBe("APP_NOT_INSTALLED");
114
119
  });
115
120
  });
116
121
 
@@ -184,3 +189,39 @@ describe("QQPcAdapter — edge cases", () => {
184
189
  expect(() => a.normalize({ kind: "x", payload: { kind: "x" } })).toThrow(/unknown kind/);
185
190
  });
186
191
  });
192
+
193
+ describe("QQPcAdapter — QQ NT sidecar path (passphrase)", () => {
194
+ const fakeCollector = (result) => async (_opts) => result;
195
+
196
+ it("opts.passphrase routes through the sidecar collector and yields messages", async () => {
197
+ const a = new QQPcAdapter({
198
+ qqCollector: fakeCollector({
199
+ account: "896075341",
200
+ messageCount: 2,
201
+ c2c: 1,
202
+ group: 1,
203
+ messages: [
204
+ { kind: "group", peer: 88966001, peerUid: "u_x", senderUin: 38181604, senderName: "疯子", type: 0, createTime: 1780941580, text: "保持高贵的沉默。", originalId: "qq-pc:group:88966001:1" },
205
+ { kind: "c2c", peer: 2747277822, peerUid: "u_y", senderUin: 12345, senderName: "张三", type: 0, createTime: 1780900000, text: "在吗", originalId: "qq-pc:c2c:2747277822:2" },
206
+ ],
207
+ }),
208
+ });
209
+ const raws = await collect(a.sync({ passphrase: "5{sww#,6aq=)8=A@" }));
210
+ expect(raws).toHaveLength(2);
211
+ expect(raws[0].payload.text).toBe("保持高贵的沉默。");
212
+ expect(raws[0].payload.isGroup).toBe(true);
213
+ expect(raws[0].payload.senderName).toBe("疯子");
214
+ expect(raws[1].payload.isGroup).toBe(false);
215
+
216
+ const merged = { events: [], persons: [], places: [], items: [], topics: [] };
217
+ for (const r of raws) {
218
+ const n = a.normalize(r);
219
+ for (const k of Object.keys(merged)) if (Array.isArray(n[k])) merged[k].push(...n[k]);
220
+ }
221
+ const { valid } = partitionBatch(merged);
222
+ expect(valid.events.length).toBe(2);
223
+ const texts = valid.events.map((e) => e.content && e.content.text);
224
+ expect(texts).toContain("保持高贵的沉默。");
225
+ expect(valid.events.find((e) => e.content.text === "保持高贵的沉默。").extra.senderName).toBe("疯子");
226
+ });
227
+ });
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 2a (Douyin C 路径) — cover for the douyin.pull-im-db ADB extension's
5
+ * IM-db discovery + classification.
6
+ *
7
+ * Real-device verification (2026-06-08, Xiaomi chopin / MIUI 13, Douyin
8
+ * logged in) found CURRENT Douyin no longer keeps a plaintext social-DM IM
9
+ * db. The databases/ dir instead holds:
10
+ * - encrypted_<uid>_im.db → SQLCipher social DM (header NOT `SQLite format 3`)
11
+ * - im_database_<uid> → Room db, but it is the in-app 豆包/Doubao AI
12
+ * assistant chat, not social DMs
13
+ * The extension must classify these and emit a precise typed error rather
14
+ * than the misleading DOUYIN_NO_IM_DB.
15
+ *
16
+ * Strategy: scripted fake `ctx.adb` returns a canned `ls` body modeled on
17
+ * the real device listing — no ADB / device needed.
18
+ */
19
+
20
+ import { describe, it, expect, vi } from "vitest";
21
+
22
+ const {
23
+ createDouyinDbExtension,
24
+ ENCRYPTED_IM_DB_PATTERN,
25
+ DOUBAO_IM_DB_PATTERN,
26
+ _internals,
27
+ } = require("../../lib/adapters/social-douyin-adb/db-extension");
28
+
29
+ /** Fake ctx: matches the first substring pattern in `responses`. */
30
+ function fakeCtx(responses) {
31
+ const adb = vi.fn(async (args) => {
32
+ const key = args.join(" ");
33
+ for (const [pattern, body] of responses) {
34
+ if (key.includes(pattern)) {
35
+ return typeof body === "function" ? body(args) : body;
36
+ }
37
+ }
38
+ throw new Error(`fake adb: no scripted response for: ${key}`);
39
+ });
40
+ return { adb, pickDevice: vi.fn(async () => "FAKE_SERIAL"), parseContentQueryRows: () => [] };
41
+ }
42
+
43
+ // Real device listing (trimmed to the IM-relevant files).
44
+ const REAL_DEVICE_LS = [
45
+ "aweme_database_92585448288",
46
+ "encrypted_92585448288_im.db",
47
+ "encrypted_92585448288_im_customer_box.db",
48
+ "im_database_",
49
+ "im_database_6951980119394929011",
50
+ "push_message.db",
51
+ ].join("\n");
52
+
53
+ describe("patterns", () => {
54
+ it("ENCRYPTED_IM_DB_PATTERN matches encrypted_<uid>_im.db only", () => {
55
+ expect("encrypted_92585448288_im.db".match(ENCRYPTED_IM_DB_PATTERN)?.[1]).toBe(
56
+ "92585448288",
57
+ );
58
+ // customer_box variant must NOT be mistaken for the DM store
59
+ expect("encrypted_92585448288_im_customer_box.db".match(ENCRYPTED_IM_DB_PATTERN)).toBe(
60
+ null,
61
+ );
62
+ });
63
+
64
+ it("DOUBAO_IM_DB_PATTERN matches im_database_<uid> with a real uid", () => {
65
+ expect("im_database_6951980119394929011".match(DOUBAO_IM_DB_PATTERN)?.[1]).toBe(
66
+ "6951980119394929011",
67
+ );
68
+ // empty-uid `im_database_` must not match (needs ≥6 digits)
69
+ expect("im_database_".match(DOUBAO_IM_DB_PATTERN)).toBe(null);
70
+ });
71
+ });
72
+
73
+ describe("listImDbs classification (real-device listing)", () => {
74
+ it("buckets encrypted + doubao, finds no legacy plaintext", async () => {
75
+ const ctx = fakeCtx([["ls ", REAL_DEVICE_LS]]);
76
+ const r = await _internals.listImDbs(ctx.adb, "FAKE_SERIAL", {});
77
+ expect(r.candidates).toEqual([]); // no legacy `<19digit>_im.db`
78
+ expect(r.encryptedCandidates.map((c) => c.fileName)).toEqual([
79
+ "encrypted_92585448288_im.db",
80
+ ]);
81
+ expect(r.doubaoCandidates.map((c) => c.fileName)).toEqual([
82
+ "im_database_6951980119394929011",
83
+ ]);
84
+ });
85
+ });
86
+
87
+ describe("createDouyinDbExtension — precise typed errors", () => {
88
+ it("throws DOUYIN_IM_DB_ENCRYPTED when only the SQLCipher DM db exists", async () => {
89
+ const ctx = fakeCtx([
90
+ ["id -u", "0"],
91
+ ["ls ", "encrypted_92585448288_im.db\nim_database_6951980119394929011"],
92
+ ]);
93
+ const ext = createDouyinDbExtension();
94
+ await expect(ext({}, ctx)).rejects.toThrow(/DOUYIN_IM_DB_ENCRYPTED/);
95
+ });
96
+
97
+ it("throws DOUYIN_ONLY_DOUBAO_AI_CHAT when only the Doubao Room db exists", async () => {
98
+ const ctx = fakeCtx([
99
+ ["id -u", "0"],
100
+ ["ls ", "im_database_6951980119394929011\npush_message.db"],
101
+ ]);
102
+ const ext = createDouyinDbExtension();
103
+ await expect(ext({}, ctx)).rejects.toThrow(/DOUYIN_ONLY_DOUBAO_AI_CHAT/);
104
+ });
105
+
106
+ it("still throws DOUYIN_NO_IM_DB when nothing relevant exists", async () => {
107
+ const ctx = fakeCtx([
108
+ ["id -u", "0"],
109
+ ["ls ", "push_message.db\naweme.db"],
110
+ ]);
111
+ const ext = createDouyinDbExtension();
112
+ await expect(ext({}, ctx)).rejects.toThrow(/DOUYIN_NO_IM_DB/);
113
+ });
114
+ });
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3a (Weibo C 路径) — cover for the weibo.cookies ADB extension
5
+ * factory, focused on the WebView-profile-dir discovery fix.
6
+ *
7
+ * Real-device verification (2026-06-08, Xiaomi chopin / MIUI 13, Weibo
8
+ * logged in) found current Weibo stores its Chromium cookies under a
9
+ * SUFFIXED profile dir `app_webview_com.sina.weibo/Default/Cookies`, not
10
+ * the legacy `app_webview/Default/Cookies` the collector hardcoded — so the
11
+ * old code threw WEIBO_NOT_INSTALLED on a perfectly logged-in phone. The
12
+ * fix globs `app_webview*` and uses the first match.
13
+ *
14
+ * Strategy mirrors social-bilibili-adb-cookies-extension.test.js: build a
15
+ * real chromium-shape Cookies sqlite, base64 it, and feed it back through a
16
+ * scripted fake `ctx.adb` — no real ADB / device.
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
20
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+ import Database from "better-sqlite3";
24
+
25
+ const {
26
+ createWeiboCookiesExtension,
27
+ WEIBO_COOKIES_REMOTE_GLOB,
28
+ WEIBO_COOKIES_REMOTE_PATH,
29
+ } = require("../../lib/adapters/social-weibo-adb/cookies-extension");
30
+
31
+ let tmpDir;
32
+
33
+ beforeEach(() => {
34
+ tmpDir = mkdtempSync(join(tmpdir(), "cc-weibo-ext-test-"));
35
+ });
36
+
37
+ afterEach(() => {
38
+ try {
39
+ rmSync(tmpDir, { recursive: true, force: true });
40
+ } catch (_e) {
41
+ // ignore
42
+ }
43
+ });
44
+
45
+ /** Build a chromium-shape Cookies sqlite + return its base64. */
46
+ function buildCookiesAsBase64(cookies) {
47
+ const dbPath = join(tmpDir, "fixture-cookies");
48
+ const db = new Database(dbPath);
49
+ db.exec(`
50
+ CREATE TABLE cookies(
51
+ creation_utc INTEGER,
52
+ host_key TEXT,
53
+ name TEXT,
54
+ value TEXT,
55
+ encrypted_value BLOB,
56
+ path TEXT,
57
+ expires_utc INTEGER,
58
+ is_secure INTEGER,
59
+ is_httponly INTEGER,
60
+ is_persistent INTEGER
61
+ );
62
+ `);
63
+ const insert = db.prepare(
64
+ "INSERT INTO cookies(host_key, name, value, path, expires_utc, is_secure, is_httponly, is_persistent) VALUES(?, ?, ?, '/', 0, 0, 1, 1)",
65
+ );
66
+ for (const c of cookies) {
67
+ insert.run(c.hostKey || ".m.weibo.cn", c.name, c.value);
68
+ }
69
+ db.close();
70
+ const buf = readFileSync(dbPath);
71
+ rmSync(dbPath);
72
+ return buf.toString("base64");
73
+ }
74
+
75
+ /**
76
+ * Scripted fake ctx. `responses` is an array of [substringPattern, body];
77
+ * the first pattern that the joined args contain wins.
78
+ */
79
+ function fakeCtx(responses) {
80
+ const adb = vi.fn(async (args) => {
81
+ const key = args.join(" ");
82
+ for (const [pattern, body] of responses) {
83
+ if (key.includes(pattern)) {
84
+ return typeof body === "function" ? body(args) : body;
85
+ }
86
+ }
87
+ throw new Error(`fake adb: no scripted response for: ${key}`);
88
+ });
89
+ return {
90
+ ctx: { adb, pickDevice: vi.fn(async () => "FAKE_SERIAL"), parseContentQueryRows: () => [] },
91
+ adb,
92
+ };
93
+ }
94
+
95
+ const SUFFIXED_PATH =
96
+ "/data/data/com.sina.weibo/app_webview_com.sina.weibo/Default/Cookies";
97
+
98
+ describe("constants", () => {
99
+ it("glob covers both legacy and suffixed app_webview layouts", () => {
100
+ expect(WEIBO_COOKIES_REMOTE_GLOB).toBe(
101
+ "/data/data/com.sina.weibo/app_webview*/Default/Cookies",
102
+ );
103
+ // The legacy constant is kept for reference / back-compat callers.
104
+ expect(WEIBO_COOKIES_REMOTE_PATH).toBe(
105
+ "/data/data/com.sina.weibo/app_webview/Default/Cookies",
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("createWeiboCookiesExtension — WebView profile dir discovery", () => {
111
+ it("resolves the SUFFIXED app_webview_com.sina.weibo dir + pulls from it", async () => {
112
+ const b64 = buildCookiesAsBase64([{ name: "SUB", value: "sessionTokenXYZ" }]);
113
+ const { ctx, adb } = fakeCtx([
114
+ ["ls -d", SUFFIXED_PATH], // glob resolves to suffixed dir
115
+ ["id -u", "0"],
116
+ ["base64", b64],
117
+ ]);
118
+ const ext = createWeiboCookiesExtension();
119
+ const result = await ext({}, ctx);
120
+
121
+ expect(result.cookie).toContain("SUB=sessionTokenXYZ");
122
+ expect(result.diagnostic.hasSub).toBe(true);
123
+
124
+ // The base64 pull MUST target the resolved suffixed path, not the
125
+ // legacy hardcoded one — this is the regression guard for the fix.
126
+ const base64Call = adb.mock.calls.find((c) => c[0].join(" ").includes("base64"));
127
+ expect(base64Call[0].join(" ")).toContain(SUFFIXED_PATH);
128
+ expect(base64Call[0].join(" ")).not.toContain("app_webview/Default");
129
+ });
130
+
131
+ it("still works with the legacy app_webview path (back-compat)", async () => {
132
+ const b64 = buildCookiesAsBase64([{ name: "SUB", value: "legacyTok" }]);
133
+ const { ctx } = fakeCtx([
134
+ ["ls -d", WEIBO_COOKIES_REMOTE_PATH],
135
+ ["id -u", "0"],
136
+ ["base64", b64],
137
+ ]);
138
+ const ext = createWeiboCookiesExtension();
139
+ const result = await ext({}, ctx);
140
+ expect(result.cookie).toContain("SUB=legacyTok");
141
+ });
142
+
143
+ it("throws WEIBO_NOT_INSTALLED when glob matches nothing", async () => {
144
+ const { ctx } = fakeCtx([
145
+ ["ls -d", "NOT_FOUND"],
146
+ ["id -u", "0"],
147
+ ]);
148
+ const ext = createWeiboCookiesExtension();
149
+ await expect(ext({}, ctx)).rejects.toThrow(/WEIBO_NOT_INSTALLED/);
150
+ });
151
+
152
+ it("throws WEIBO_NOT_INSTALLED when shell echoes the unexpanded glob", async () => {
153
+ // Some shells, when the glob matches nothing AND nullglob is off, echo
154
+ // the literal pattern. The `*`-guard must treat that as not-found.
155
+ const { ctx } = fakeCtx([
156
+ ["ls -d", WEIBO_COOKIES_REMOTE_GLOB],
157
+ ["id -u", "0"],
158
+ ]);
159
+ const ext = createWeiboCookiesExtension();
160
+ await expect(ext({}, ctx)).rejects.toThrow(/WEIBO_NOT_INSTALLED/);
161
+ });
162
+
163
+ it("rejects when ctx missing required functions", async () => {
164
+ const ext = createWeiboCookiesExtension();
165
+ await expect(ext({}, {})).rejects.toThrow(/ctx must provide/);
166
+ });
167
+ });