@chainlesschain/personal-data-hub 0.4.0 → 0.4.2

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.
package/README.md CHANGED
@@ -4,25 +4,38 @@ Personal Data Hub — UnifiedSchema, validators, batch helpers, SQLCipher
4
4
  LocalVault, and AdapterRegistry for the "data back to the individual"
5
5
  middleware.
6
6
 
7
- > **Phase 0 + Phase 1 + Phase 2 + Phase 3 + Phase 3.5 landed** of the 13-phase plan in
8
- > [`docs/design/Personal_Data_Hub_Architecture.md`](../../docs/design/Personal_Data_Hub_Architecture.md).
9
- > Phase 0 covers schema + validation + ID generation.
10
- > Phase 1 adds SQLCipher LocalVault + pluggable key providers + migrations.
11
- > Phase 2 adds AdapterRegistry + KG/RAG derivation + MockAdapter (1000
12
- > events ingest in ~600ms 50× under the 30s target).
13
- > Phase 3 adds the natural-language AnalysisEngine: query parser → vault
14
- > facts prompt builder LLM citation validation, with a privacy gate
15
- > that refuses non-local LLMs unless caller opts in. **MockLLMClient**
16
- > for tests, **OllamaClient** for standalone use.
17
- > Phase 3.5 wires production bridges: **CcLLMAdapter** wraps the existing
18
- > cc llm-manager (Ollama / Volcengine / Anthropic / Gemini / DeepSeek)
19
- > via dependency injection; **CcKgSink** translates hub triples into the
20
- > existing knowledge-graph addEntity + addRelation; **CcRagSink** feeds
21
- > hub RagDocs into BM25 (Qdrant vector store wiring left as future work).
22
- > Hub package stays decoupled bridges take cc functions as constructor
23
- > args rather than importing cc modules directly.
24
- > Sync engine UI, real KG/RAG wiring, and the actual adapters (Email,
25
- > Alipay, AI Chat × 8, WeChat, ...) come in later phases.
7
+ > **v0.4.0 (ships with ChainlessChain v5.0.3.99, 2026-06-08).** Phase 0–13
8
+ > of the 13-phase plan in
9
+ > [`docs/design/Personal_Data_Hub_Architecture.md`](../../docs/design/Personal_Data_Hub_Architecture.md)
10
+ > have landed, plus the multi-platform collection layer. The foundation is
11
+ > unchanged: schema + validation + UUID v7 (Phase 0); SQLCipher LocalVault +
12
+ > pluggable key providers + migrations (Phase 1); AdapterRegistry + KG/RAG
13
+ > derivation (Phase 2); the natural-language AnalysisEngine with a hard
14
+ > privacy gate that refuses non-local LLMs unless the caller opts in
15
+ > (Phase 3); and production bridges **CcLLMAdapter** (wraps cc llm-manager:
16
+ > Ollama / Volcengine / Anthropic / Gemini / DeepSeek), **CcKgSink**, **CcRagSink**
17
+ > injected at the desktop/CLI entry so this package stays decoupled (Phase 3.5).
18
+ >
19
+ > **51 adapters are now live** (no longer "later phases"): Email IMAP,
20
+ > Alipay bill, 9 AI-chat vendors, WeChat / QQ / Weibo / Bilibili / Douyin /
21
+ > Xiaohongshu / Toutiao / Kuaishou social, Telegram / WhatsApp messaging,
22
+ > Taobao / JD / Meituan / Pinduoduo shopping, Amap / Baidu-map / Tencent-map /
23
+ > Ctrip / 12306 travel, system-data (contacts / calls / sms / location),
24
+ > and the developer-activity set (git / shell / vscode / browser-history /
25
+ > local-files / win-recent).
26
+ >
27
+ > **New in v0.4.0 (v5.0.3.99):** adapter **readiness** — split out from the
28
+ > loose `healthCheck` sync gate into a real ready/needs_setup/unavailable
29
+ > judgment (`registry.readiness()`) with a one-line reason, so "config looks
30
+ > fine but nothing collects" is no longer silent; an `adapter-guide.js`
31
+ > single-source of import steps reused across web-shell / desktop / CLI /
32
+ > Android; new local-direct-read sources (Douyin, WeChat PC, QQ-NT, DingTalk,
33
+ > Feishu, WeRead, Apple Health, NetEase Music); email-bill LLM gap-fill
34
+ > (Phase 5.5); and iOS encrypted-backup decryption (Phase 7.5b).
35
+ >
36
+ > Editing `lib/**` requires bumping the package version + `npm publish` +
37
+ > the Android `USR_VERSION` sentinel, or real devices keep running stale code
38
+ > (see hidden-risk-traps #27/#28).
26
39
 
27
40
  ## What's in here
28
41
 
@@ -40,6 +53,14 @@ lib/
40
53
  │ typed put/get, queryEvents, watermarks, audit, key
41
54
  │ rotation (WAL-safe), destroy
42
55
  ├── adapter-spec.js PersonalDataAdapter contract + assertAdapter check
56
+ ├── adapter-readiness.js readiness() — ready/needs_setup/unavailable + reason,
57
+ │ split out from the loose healthCheck sync gate
58
+ ├── adapter-guide.js category-driven import guides (single source of import
59
+ │ steps reused across web-shell / desktop / CLI / Android)
60
+ ├── adapters/ 51 live adapters (email-imap, alipay-bill, ai-chat-history,
61
+ │ wechat / wechat-pc, qq-pc, dingtalk-pc, feishu-pc, weread,
62
+ │ apple-health, netease-music, social-*, shopping-*,
63
+ │ travel-*, system-data, git-activity, vscode, ...)
43
64
  ├── kg-derive.js UnifiedSchema → KG triples (rdf:type / by / involves /
44
65
  │ happened-at / etc.) — engine-agnostic
45
66
  ├── rag-derive.js UnifiedSchema → RAG (text, metadata) docs for indexing
@@ -217,7 +238,7 @@ cd packages/personal-data-hub
217
238
  npm test
218
239
  ```
219
240
 
220
- **268 tests** across 17 files covering ID generation, all 5 entity validators,
241
+ **2040 tests** across 121 files covering ID generation, all 5 entity validators,
221
242
  batch helpers, key providers, vault open/migrations, entity round-trips,
222
243
  transactional putBatch with rollback, raw_events archive, queryEvents
223
244
  filters + pagination, sync watermarks, audit log, key rotation (WAL-safe),
@@ -230,11 +251,10 @@ tolerance), and the 1k events <30s ingest perf gate.
230
251
 
231
252
  | Concern | Lives in |
232
253
  |-----------------------|---------------------------------------------------|
233
- | Platform KeyProviders (DPAPI/Keychain/Keystore) | Phase 1.5 — desktop-app-vue main process bridge |
234
- | AdapterRegistry | Phase 2 same package or sibling |
235
- | KG ingestor / RAG | Phase 2/4 wired into existing KG / RAG engines |
236
- | Email/Alipay/AI/WeChat adapters | Phase 5-12separate sub-packages |
237
- | AI analysis skills | Phase 11 — `skills/personal-analysis-*/` |
254
+ | Platform KeyProviders (DPAPI/Keychain/Keystore) | desktop-app-vue main-process bridge (the package ships the contract + InMemory/File providers) |
255
+ | Qdrant vector retrieval | wired into the existing RAG engine at the cc entry (BM25 derivation ships here) |
256
+ | AI analysis skills | `skills/personal-analysis-*/` (the 5 built-in analysis skills) |
257
+ | Native SQLCipher build | `better-sqlite3-multiple-ciphers`host/Electron ABI dual-load handled at the cc entry |
238
258
 
239
259
  ## License
240
260
 
@@ -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
 
@@ -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
+ });