@chainlesschain/personal-data-hub 0.2.4 → 0.3.1

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.
Files changed (57) hide show
  1. package/__tests__/adapters/browser-history-chrome.test.js +377 -0
  2. package/__tests__/adapters/browser-history-edge.test.js +159 -0
  3. package/__tests__/adapters/git-activity.test.js +216 -0
  4. package/__tests__/adapters/local-files.test.js +264 -0
  5. package/__tests__/adapters/shell-history.test.js +180 -0
  6. package/__tests__/adapters/system-data-android.test.js +104 -3
  7. package/__tests__/adapters/vscode.test.js +299 -0
  8. package/__tests__/adapters/win-recent.test.js +192 -0
  9. package/__tests__/analysis.test.js +840 -1
  10. package/__tests__/categories.test.js +92 -0
  11. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
  12. package/__tests__/entity-resolver-vault.test.js +5 -2
  13. package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
  14. package/__tests__/query-parser.test.js +66 -0
  15. package/__tests__/registry.test.js +114 -0
  16. package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
  17. package/__tests__/sidecar-supervisor.test.js +9 -1
  18. package/__tests__/social-kuaishou-snapshot.test.js +55 -2
  19. package/__tests__/social-toutiao-snapshot.test.js +54 -2
  20. package/__tests__/travel-adapters.test.js +97 -5
  21. package/__tests__/vault-search-helpers.test.js +104 -0
  22. package/__tests__/vault-search.test.js +423 -0
  23. package/__tests__/vault.test.js +77 -3
  24. package/lib/adapters/browser-history-chrome/adapter.js +247 -0
  25. package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
  26. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
  27. package/lib/adapters/browser-history-chrome/index.js +23 -0
  28. package/lib/adapters/browser-history-edge/adapter.js +34 -0
  29. package/lib/adapters/browser-history-edge/index.js +13 -0
  30. package/lib/adapters/git-activity/adapter.js +155 -0
  31. package/lib/adapters/git-activity/git-reader.js +125 -0
  32. package/lib/adapters/git-activity/index.js +17 -0
  33. package/lib/adapters/local-files/adapter.js +149 -0
  34. package/lib/adapters/local-files/file-walker.js +125 -0
  35. package/lib/adapters/local-files/index.js +18 -0
  36. package/lib/adapters/shell-history/adapter.js +137 -0
  37. package/lib/adapters/shell-history/index.js +17 -0
  38. package/lib/adapters/shell-history/shell-reader.js +100 -0
  39. package/lib/adapters/social-kuaishou/index.js +57 -1
  40. package/lib/adapters/social-toutiao/index.js +59 -1
  41. package/lib/adapters/system-data-android/adapter.js +220 -3
  42. package/lib/adapters/travel-12306/index.js +215 -29
  43. package/lib/adapters/vscode/adapter.js +285 -0
  44. package/lib/adapters/vscode/index.js +18 -0
  45. package/lib/adapters/vscode/vscode-reader.js +191 -0
  46. package/lib/adapters/win-recent/adapter.js +150 -0
  47. package/lib/adapters/win-recent/index.js +16 -0
  48. package/lib/adapters/win-recent/win-recent-reader.js +72 -0
  49. package/lib/analysis.js +227 -9
  50. package/lib/categories.js +101 -0
  51. package/lib/index.js +61 -0
  52. package/lib/migrations.js +146 -0
  53. package/lib/query-parser.js +74 -0
  54. package/lib/registry.js +162 -0
  55. package/lib/vault.js +363 -2
  56. package/package.json +2 -1
  57. package/scripts/run-native-tests-sandbox.sh +53 -0
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const {
6
+ CATEGORIES,
7
+ CATEGORY_LABELS,
8
+ getCategory,
9
+ groupByCategory,
10
+ } = require("../lib/categories");
11
+
12
+ describe("PDH categories taxonomy", () => {
13
+ it("CATEGORIES covers the 8 known buckets and is frozen", () => {
14
+ expect(CATEGORIES).toEqual([
15
+ "chat", "social", "email", "shopping", "travel", "system", "ai-chat", "other",
16
+ ]);
17
+ expect(Object.isFrozen(CATEGORIES)).toBe(true);
18
+ });
19
+
20
+ it("CATEGORY_LABELS has Chinese label for every category", () => {
21
+ for (const c of CATEGORIES) {
22
+ expect(typeof CATEGORY_LABELS[c]).toBe("string");
23
+ expect(CATEGORY_LABELS[c].length).toBeGreaterThan(0);
24
+ }
25
+ });
26
+
27
+ it.each([
28
+ ["wechat", "chat"],
29
+ ["messaging-qq", "chat"],
30
+ ["messaging-telegram", "chat"],
31
+ ["messaging-whatsapp", "chat"],
32
+ ["social-bilibili", "social"],
33
+ ["social-weibo", "social"],
34
+ ["social-douyin", "social"],
35
+ ["social-xiaohongshu", "social"],
36
+ ["social-toutiao", "social"],
37
+ ["social-kuaishou", "social"],
38
+ ["email-imap", "email"],
39
+ ["email-imap-qq", "email"],
40
+ ["email-imap-gmail", "email"],
41
+ ["alipay-bill", "shopping"],
42
+ ["shopping-taobao", "shopping"],
43
+ ["shopping-jd", "shopping"],
44
+ ["shopping-meituan", "shopping"],
45
+ ["shopping-pinduoduo", "shopping"],
46
+ ["travel-12306", "travel"],
47
+ ["travel-ctrip", "travel"],
48
+ ["travel-amap", "travel"],
49
+ ["travel-baidu-map", "travel"],
50
+ ["travel-tencent-map", "travel"],
51
+ ["system-data", "system"],
52
+ ["system-data-android", "system"],
53
+ ["browser-history-chrome", "system"],
54
+ ["ai-chat-history", "ai-chat"],
55
+ ["ai-chat-deepseek", "ai-chat"],
56
+ ])("getCategory(%s) → %s", (adapter, cat) => {
57
+ expect(getCategory(adapter)).toBe(cat);
58
+ });
59
+
60
+ it.each([
61
+ ["unknown-adapter", "other"],
62
+ ["", "other"],
63
+ [null, "other"],
64
+ [undefined, "other"],
65
+ [123, "other"],
66
+ ])("getCategory(%p) falls back to other", (adapter, expected) => {
67
+ expect(getCategory(adapter)).toBe(expected);
68
+ });
69
+
70
+ it("groupByCategory groups multiple adapters and omits empty buckets", () => {
71
+ const groups = groupByCategory([
72
+ "wechat",
73
+ "messaging-qq",
74
+ "social-bilibili",
75
+ "email-imap-qq",
76
+ "unknown-x",
77
+ ]);
78
+ expect(groups).toEqual({
79
+ chat: ["wechat", "messaging-qq"],
80
+ social: ["social-bilibili"],
81
+ email: ["email-imap-qq"],
82
+ other: ["unknown-x"],
83
+ });
84
+ expect(groups.travel).toBeUndefined();
85
+ });
86
+
87
+ it("groupByCategory tolerates empty / nullish input", () => {
88
+ expect(groupByCategory(null)).toEqual({});
89
+ expect(groupByCategory(undefined)).toEqual({});
90
+ expect(groupByCategory([])).toEqual({});
91
+ });
92
+ });
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * E2E — spawn the cc CLI and exercise the real `hub list-adapters` +
5
+ * `hub sync-adapter` commands against a sandboxed APPDATA dir. Validates
6
+ * the entire path PDH wiring → registry → CLI gateway → JSON stdout for
7
+ * the 4 new Phase 17 adapters.
8
+ *
9
+ * Same bs3mc-on-Win caveat as the integration test: skip when LocalVault
10
+ * cannot open. CI Linux runs the real chain.
11
+ *
12
+ * Strategy: redirect APPDATA / XDG_CONFIG_HOME / HOME to a tmpdir so the
13
+ * CLI's getElectronUserDataDir() resolves to that tmpdir and we don't
14
+ * touch the user's real chainlesschain-desktop-vue/.chainlesschain dir.
15
+ */
16
+
17
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
18
+
19
+ const fs = require("node:fs");
20
+ const path = require("node:path");
21
+ const os = require("node:os");
22
+ const { spawnSync } = require("node:child_process");
23
+
24
+ // Probe bs3mc once — same gate as the integration test.
25
+ let bs3mcAvailable = true;
26
+ let bs3mcSkipReason = "";
27
+ try {
28
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "bs3mc-e2e-probe-"));
29
+ const { LocalVault: PV, generateKeyHex: PK } = require("../../lib");
30
+ const v = new PV({ path: path.join(probeDir, "p.db"), key: PK() });
31
+ v.open();
32
+ v.close();
33
+ fs.rmSync(probeDir, { recursive: true, force: true });
34
+ } catch (e) {
35
+ bs3mcAvailable = false;
36
+ bs3mcSkipReason = e && e.message ? e.message : String(e);
37
+ }
38
+ // Resolve the CLI entry — we run it through node directly (avoids cc
39
+ // PATH lookup hassles + the workspace symlink resolves to current source).
40
+ // In the FTS5 sandbox runner (lib/ + __tests__/ copied to $TMPDIR) the
41
+ // relative ../../../cli path resolves outside the repo and is missing;
42
+ // gate the tests so they skip cleanly when the CLI binary is absent.
43
+ const CLI_BIN = path.resolve(__dirname, "..", "..", "..", "cli", "bin", "chainlesschain.js");
44
+ const cliBinAvailable = fs.existsSync(CLI_BIN);
45
+
46
+ const itOrSkip = bs3mcAvailable && cliBinAvailable ? it : it.skip;
47
+
48
+ let sandboxAppData;
49
+
50
+ function runCli(args, { timeoutMs = 60_000 } = {}) {
51
+ const env = {
52
+ ...process.env,
53
+ // Override the platform user-data dir lookup so initHub() builds its
54
+ // vault inside our sandbox instead of the real user profile.
55
+ APPDATA: sandboxAppData,
56
+ XDG_CONFIG_HOME: sandboxAppData,
57
+ HOME: sandboxAppData,
58
+ USERPROFILE: sandboxAppData,
59
+ // Prevent CC from launching the auto-update probe / telemetry pings
60
+ // mid-test (those can hang the spawn).
61
+ CC_DISABLE_TELEMETRY: "1",
62
+ CC_DISABLE_AUTOUPDATE: "1",
63
+ NO_COLOR: "1",
64
+ };
65
+ const res = spawnSync(process.execPath, [CLI_BIN, ...args], {
66
+ env,
67
+ encoding: "utf-8",
68
+ timeout: timeoutMs,
69
+ windowsHide: true,
70
+ });
71
+ return {
72
+ code: res.status,
73
+ stdout: res.stdout || "",
74
+ stderr: res.stderr || "",
75
+ signal: res.signal,
76
+ error: res.error,
77
+ };
78
+ }
79
+
80
+ beforeAll(() => {
81
+ if (!bs3mcAvailable) return;
82
+ sandboxAppData = fs.mkdtempSync(path.join(os.tmpdir(), "pdh-e2e-appdata-"));
83
+ });
84
+
85
+ afterAll(() => {
86
+ if (sandboxAppData) {
87
+ try {
88
+ fs.rmSync(sandboxAppData, { recursive: true, force: true });
89
+ } catch (_e) {
90
+ /* noop */
91
+ }
92
+ }
93
+ });
94
+
95
+ describe("cc hub list-adapters — 4 Phase 17 adapters registered", () => {
96
+ itOrSkip("lists browser-history-chrome / -edge / vscode / win-recent", () => {
97
+ const r = runCli(["hub", "list-adapters", "--json"]);
98
+ // Stderr may carry deprecation noise; we only assert on the JSON stdout.
99
+ expect(r.code).toBe(0);
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(r.stdout);
103
+ } catch (_e) {
104
+ throw new Error(
105
+ `list-adapters did not emit JSON (code=${r.code}, signal=${r.signal})\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}`,
106
+ );
107
+ }
108
+ const names = parsed.map((a) => a.name);
109
+ for (const expected of [
110
+ "browser-history-chrome",
111
+ "browser-history-edge",
112
+ "vscode",
113
+ "win-recent",
114
+ ]) {
115
+ expect(names).toContain(expected);
116
+ }
117
+ });
118
+ });
119
+
120
+ describe("cc hub sync-adapter — drives one adapter end-to-end", () => {
121
+ itOrSkip("win-recent sync against an empty Recent dir returns ok with 0 events", () => {
122
+ // win-recent will fall through authenticate() PLATFORM_UNSUPPORTED on
123
+ // Linux CI. To make this assertion stable across both Win and Linux
124
+ // CI runners, we touch a sandbox Recent dir and point the adapter at
125
+ // it via env var — but the adapter doesn't read env, only opts. So
126
+ // this test simply validates the CLI gateway can invoke and surface
127
+ // either an "ok empty" report (Win) or an "unhealthy" report (Linux
128
+ // where the default dir doesn't exist). Both prove the gateway works.
129
+ const r = runCli(["hub", "sync-adapter", "win-recent", "--json"]);
130
+ // The CLI exits 0 for both ok and adapter-reported-error reports; it
131
+ // exits non-zero only for hard exceptions. We accept either.
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(r.stdout);
135
+ } catch (_e) {
136
+ throw new Error(
137
+ `sync-adapter did not emit JSON (code=${r.code})\nstdout: ${r.stdout.slice(0, 400)}\nstderr: ${r.stderr.slice(0, 400)}`,
138
+ );
139
+ }
140
+ expect(parsed).toBeDefined();
141
+ // status is one of: ok / auth_expired / unhealthy / error
142
+ expect(["ok", "auth_expired", "unhealthy", "error"]).toContain(parsed.status);
143
+ expect(typeof parsed.rawCount).toBe("number");
144
+ expect(parsed.entityCounts).toBeDefined();
145
+ });
146
+ });
@@ -7,6 +7,7 @@ const fs = require("node:fs");
7
7
  const os = require("node:os");
8
8
  const { LocalVault } = require("../lib/vault");
9
9
  const { generateKeyHex } = require("../lib/key-providers");
10
+ const { TARGET_VERSION } = require("../lib/migrations");
10
11
 
11
12
  // Helper to spin up a fresh vault each test
12
13
  function makeVault() {
@@ -30,8 +31,10 @@ describe("Phase 8 migration v2 — EntityResolver tables", () => {
30
31
  beforeEach(() => { ({ vault, dir } = makeVault()); });
31
32
  afterEach(() => cleanup(vault, dir));
32
33
 
33
- it("schemaVersion is 2 after open()", () => {
34
- expect(vault.schemaVersion()).toBe(2);
34
+ it("schemaVersion is current after open()", () => {
35
+ expect(vault.schemaVersion()).toBe(TARGET_VERSION);
36
+ // Phase 8 ER tables landed in v2; subsequent migrations must not regress.
37
+ expect(TARGET_VERSION).toBeGreaterThanOrEqual(2);
35
38
  });
36
39
 
37
40
  it("all 5 new tables exist + are queryable", () => {
@@ -0,0 +1,373 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Integration test — Phase 17 local-data adapters end-to-end pipeline.
5
+ *
6
+ * Exercises the full chain WITHOUT real Chrome / Edge / VSCode / Windows
7
+ * installations: every adapter is pointed at a synthetic on-disk fixture
8
+ * via opts.profilePath / opts.vscodeRoot / opts.recentDir. Confirms that
9
+ * each adapter, when registered with a real LocalVault + AdapterRegistry,
10
+ * produces the expected entityCounts and that ingested rows survive a
11
+ * vault re-open round-trip.
12
+ *
13
+ * Covered adapters (4):
14
+ * browser-history-chrome
15
+ * browser-history-edge (Chromium subclass — proves inheritance wiring)
16
+ * vscode
17
+ * win-recent
18
+ *
19
+ * Win note: bs3mc has the known NODE_MODULE_VERSION mismatch on this dev
20
+ * box (built for Electron's ABI 140; Node 22 wants 127, Node 24 wants 137).
21
+ * Tests pass on CI Linux (matched prebuild). To run locally without
22
+ * Electron running, stop the dev app and `npm rebuild better-sqlite3-
23
+ * multiple-ciphers`. Same caveat as the bilibili integration test header.
24
+ */
25
+
26
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
27
+
28
+ const fs = require("node:fs");
29
+ const path = require("node:path");
30
+ const os = require("node:os");
31
+ const Database = require("better-sqlite3");
32
+
33
+ // Probe bs3mc once at import time. When it throws ABI mismatch (common
34
+ // on Win dev boxes since the prebuild targets Electron's ABI 140), we
35
+ // flip `bs3mcAvailable` off and every `it` in this file becomes a skip.
36
+ // CI Linux has matching prebuilds so the gate stays open there.
37
+ let bs3mcAvailable = true;
38
+ let bs3mcSkipReason = "";
39
+ try {
40
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "bs3mc-probe-"));
41
+ const probePath = path.join(probeDir, "p.db");
42
+ const { LocalVault: ProbeVault, generateKeyHex: probeKey } = require("../../lib");
43
+ const v = new ProbeVault({ path: probePath, key: probeKey() });
44
+ v.open();
45
+ v.close();
46
+ fs.rmSync(probeDir, { recursive: true, force: true });
47
+ } catch (e) {
48
+ bs3mcAvailable = false;
49
+ bs3mcSkipReason = e && e.message ? e.message : String(e);
50
+ }
51
+ const itOrSkip = bs3mcAvailable ? it : it.skip;
52
+
53
+ const {
54
+ LocalVault,
55
+ generateKeyHex,
56
+ AdapterRegistry,
57
+ } = require("../../lib");
58
+ const {
59
+ BrowserHistoryChromeAdapter,
60
+ epochMsToWebkitUs,
61
+ } = require("../../lib/adapters/browser-history-chrome");
62
+ const {
63
+ BrowserHistoryEdgeAdapter,
64
+ } = require("../../lib/adapters/browser-history-edge");
65
+ const { VSCodeAdapter } = require("../../lib/adapters/vscode");
66
+ const { WinRecentAdapter } = require("../../lib/adapters/win-recent");
67
+
68
+ let rig;
69
+
70
+ function makeRig() {
71
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdh-int-"));
72
+ const vault = new LocalVault({
73
+ path: path.join(dir, "vault.db"),
74
+ key: generateKeyHex(),
75
+ });
76
+ vault.open();
77
+ // Capture sync events for assertion + debugging.
78
+ const events = [];
79
+ const registry = new AdapterRegistry({
80
+ vault,
81
+ onSyncEvent: (m) => events.push(m),
82
+ });
83
+ return { vault, registry, dir, events };
84
+ }
85
+
86
+ function cleanup(r) {
87
+ if (!r) return;
88
+ try {
89
+ r.vault.close();
90
+ } catch (_e) {
91
+ /* noop */
92
+ }
93
+ try {
94
+ fs.rmSync(r.dir, { recursive: true, force: true });
95
+ } catch (_e) {
96
+ /* noop */
97
+ }
98
+ }
99
+
100
+ // ─── Fixtures ──────────────────────────────────────────────────────────
101
+
102
+ function buildChromeFixture(profileDir, { visits = [], bookmarks = null } = {}) {
103
+ fs.mkdirSync(profileDir, { recursive: true });
104
+ const db = new Database(path.join(profileDir, "History"));
105
+ db.exec(`
106
+ CREATE TABLE urls(
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
108
+ url LONGVARCHAR, title LONGVARCHAR,
109
+ visit_count INTEGER DEFAULT 0 NOT NULL,
110
+ typed_count INTEGER DEFAULT 0 NOT NULL,
111
+ last_visit_time INTEGER NOT NULL,
112
+ hidden INTEGER DEFAULT 0 NOT NULL
113
+ );
114
+ CREATE TABLE visits(
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ url INTEGER NOT NULL,
117
+ visit_time INTEGER NOT NULL,
118
+ from_visit INTEGER,
119
+ transition INTEGER DEFAULT 0 NOT NULL,
120
+ visit_duration INTEGER DEFAULT 0 NOT NULL
121
+ );
122
+ `);
123
+ const ins = db.prepare(
124
+ "INSERT INTO urls(url, title, last_visit_time) VALUES(?, ?, ?)",
125
+ );
126
+ const insv = db.prepare(
127
+ "INSERT INTO visits(url, visit_time, transition) VALUES(?, ?, 1)",
128
+ );
129
+ for (const v of visits) {
130
+ const wk = epochMsToWebkitUs(v.visitTimeMs).toString();
131
+ const r = ins.run(v.url, v.title || "", wk);
132
+ insv.run(r.lastInsertRowid, wk);
133
+ }
134
+ db.close();
135
+ if (bookmarks) {
136
+ fs.writeFileSync(path.join(profileDir, "Bookmarks"), JSON.stringify(bookmarks), "utf-8");
137
+ }
138
+ }
139
+
140
+ function buildVscodeFixture(vscodeRoot, { workspaces = [], commands = [], dirs = [] } = {}) {
141
+ const wsRoot = path.join(vscodeRoot, "User", "workspaceStorage");
142
+ fs.mkdirSync(wsRoot, { recursive: true });
143
+ for (const w of workspaces) {
144
+ const d = path.join(wsRoot, w.hash);
145
+ fs.mkdirSync(d, { recursive: true });
146
+ const wsFile = path.join(d, "workspace.json");
147
+ fs.writeFileSync(wsFile, JSON.stringify({ folder: w.folderUri }), "utf-8");
148
+ if (w.mtimeMs) fs.utimesSync(wsFile, w.mtimeMs / 1000, w.mtimeMs / 1000);
149
+ }
150
+ const stateDir = path.join(vscodeRoot, "User", "globalStorage");
151
+ fs.mkdirSync(stateDir, { recursive: true });
152
+ const db = new Database(path.join(stateDir, "state.vscdb"));
153
+ db.exec("CREATE TABLE ItemTable(key TEXT PRIMARY KEY, value BLOB)");
154
+ const put = db.prepare("INSERT INTO ItemTable(key, value) VALUES(?, ?)");
155
+ put.run(
156
+ "terminal.history.entries.commands",
157
+ JSON.stringify({ entries: commands.map((c) => ({ key: c, value: { shellType: "pwsh" } })) }),
158
+ );
159
+ put.run(
160
+ "terminal.history.entries.dirs",
161
+ JSON.stringify({ entries: dirs.map((d) => ({ key: d, value: { shellType: "pwsh" } })) }),
162
+ );
163
+ put.run("terminal.history.timestamp.commands", String(1_700_000_010_000));
164
+ put.run("terminal.history.timestamp.dirs", String(1_700_000_020_000));
165
+ db.close();
166
+ }
167
+
168
+ function buildRecentFixture(recentDir, lnks = []) {
169
+ fs.mkdirSync(recentDir, { recursive: true });
170
+ for (const l of lnks) {
171
+ const p = path.join(recentDir, l.name);
172
+ fs.writeFileSync(p, "lnk-blob", "utf-8");
173
+ if (l.mtimeMs) fs.utimesSync(p, l.mtimeMs / 1000, l.mtimeMs / 1000);
174
+ }
175
+ }
176
+
177
+ beforeEach(() => {
178
+ // Skip rig creation when bs3mc isn't loadable — the it.skip won't reach
179
+ // these tests, but beforeEach still runs and would throw.
180
+ if (!bs3mcAvailable) return;
181
+ rig = makeRig();
182
+ });
183
+
184
+ afterEach(() => {
185
+ cleanup(rig);
186
+ rig = null;
187
+ });
188
+
189
+ // ─── Per-adapter pipeline tests ────────────────────────────────────────
190
+
191
+ describe("browser-history-chrome pipeline", () => {
192
+ itOrSkip("syncs visits + bookmarks into vault, status=ok, entityCounts match", async () => {
193
+ const profileDir = path.join(rig.dir, "ChromeProfile");
194
+ buildChromeFixture(profileDir, {
195
+ visits: [
196
+ { url: "https://anthropic.com", title: "Anthropic", visitTimeMs: 1_700_000_001_000 },
197
+ { url: "https://example.com", title: "Example", visitTimeMs: 1_700_000_002_000 },
198
+ ],
199
+ bookmarks: {
200
+ version: 1,
201
+ roots: {
202
+ bookmark_bar: {
203
+ type: "folder",
204
+ name: "bar",
205
+ children: [
206
+ {
207
+ type: "url",
208
+ id: "1",
209
+ guid: "g1",
210
+ url: "https://saved.test",
211
+ name: "Saved",
212
+ date_added: "13300000000000000",
213
+ },
214
+ ],
215
+ },
216
+ },
217
+ },
218
+ });
219
+ const adapter = new BrowserHistoryChromeAdapter({ profilePath: profileDir });
220
+ rig.registry.register(adapter);
221
+ const report = await rig.registry.syncAdapter("browser-history-chrome");
222
+ expect(report.status).toBe("ok");
223
+ expect(report.rawCount).toBe(3); // 2 visits + 1 bookmark
224
+ expect(report.entityCounts.events).toBe(2);
225
+ expect(report.entityCounts.items).toBe(1);
226
+ expect(report.invalidCount).toBe(0);
227
+
228
+ // Vault row count crosscheck (proves entities really persisted)
229
+ const eventsN = rig.vault.db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
230
+ const itemsN = rig.vault.db.prepare("SELECT COUNT(*) AS n FROM items").get().n;
231
+ expect(eventsN).toBe(2);
232
+ expect(itemsN).toBe(1);
233
+ });
234
+
235
+ itOrSkip("re-sync is idempotent — same originalId, no duplicate rows", async () => {
236
+ const profileDir = path.join(rig.dir, "ChromeProfile");
237
+ buildChromeFixture(profileDir, {
238
+ visits: [{ url: "https://idem.test", title: "Idem", visitTimeMs: 1_700_000_000_000 }],
239
+ });
240
+ const adapter = new BrowserHistoryChromeAdapter({ profilePath: profileDir });
241
+ rig.registry.register(adapter);
242
+ await rig.registry.syncAdapter("browser-history-chrome");
243
+ await rig.registry.syncAdapter("browser-history-chrome");
244
+ const n = rig.vault.db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
245
+ expect(n).toBe(1); // dedup by source.originalId UNIQUE constraint
246
+ });
247
+ });
248
+
249
+ describe("browser-history-edge pipeline (Chromium subclass)", () => {
250
+ itOrSkip("syncs visits with edge-tagged source.adapter, not chrome", async () => {
251
+ const profileDir = path.join(rig.dir, "EdgeProfile");
252
+ buildChromeFixture(profileDir, {
253
+ visits: [{ url: "https://bing.com", title: "Bing", visitTimeMs: 1_700_000_001_000 }],
254
+ });
255
+ const adapter = new BrowserHistoryEdgeAdapter({ profilePath: profileDir });
256
+ rig.registry.register(adapter);
257
+ const report = await rig.registry.syncAdapter("browser-history-edge");
258
+ expect(report.status).toBe("ok");
259
+ expect(report.entityCounts.events).toBe(1);
260
+
261
+ // Drill into the row to confirm the subclass set source.adapter correctly
262
+ const row = rig.vault.db.prepare("SELECT source FROM events LIMIT 1").get();
263
+ const source = JSON.parse(row.source);
264
+ expect(source.adapter).toBe("browser-history-edge");
265
+ expect(source.originalId).toMatch(/^edge-visit:/);
266
+ });
267
+ });
268
+
269
+ describe("vscode pipeline", () => {
270
+ itOrSkip("syncs workspaces (items) + terminal commands+dirs (events)", async () => {
271
+ const vscodeRoot = path.join(rig.dir, "VSCode");
272
+ buildVscodeFixture(vscodeRoot, {
273
+ workspaces: [
274
+ { hash: "h1", folderUri: "file:///c%3A/code/foo", mtimeMs: 1_700_000_001_000 },
275
+ { hash: "h2", folderUri: "file:///c%3A/code/bar", mtimeMs: 1_700_000_002_000 },
276
+ ],
277
+ commands: ["ls", "git status"],
278
+ dirs: ["/c/code/foo"],
279
+ });
280
+ const adapter = new VSCodeAdapter({ vscodeRoot });
281
+ rig.registry.register(adapter);
282
+ const report = await rig.registry.syncAdapter("vscode");
283
+ expect(report.status).toBe("ok");
284
+ expect(report.entityCounts.items).toBe(2); // 2 workspaces
285
+ expect(report.entityCounts.events).toBe(3); // 2 commands + 1 dir
286
+ expect(report.invalidCount).toBe(0);
287
+
288
+ const items = rig.vault.db
289
+ .prepare("SELECT name, category FROM items ORDER BY name")
290
+ .all();
291
+ expect(items.map((i) => i.category)).toEqual(["code-project", "code-project"]);
292
+ });
293
+ });
294
+
295
+ describe("win-recent pipeline", () => {
296
+ itOrSkip("syncs recent shortcuts to Event(OTHER) with '打开了 X' title", async () => {
297
+ const recentDir = path.join(rig.dir, "Recent");
298
+ buildRecentFixture(recentDir, [
299
+ { name: "report.docx.lnk", mtimeMs: 1_700_000_001_000 },
300
+ { name: "todo.txt.lnk", mtimeMs: 1_700_000_002_000 },
301
+ ]);
302
+ const adapter = new WinRecentAdapter({ recentDir });
303
+ rig.registry.register(adapter);
304
+ const report = await rig.registry.syncAdapter("win-recent");
305
+ expect(report.status).toBe("ok");
306
+ expect(report.entityCounts.events).toBe(2);
307
+
308
+ const titles = rig.vault.db
309
+ .prepare("SELECT content FROM events ORDER BY occurred_at")
310
+ .all()
311
+ .map((r) => JSON.parse(r.content).title);
312
+ expect(titles).toEqual(["打开了 report.docx", "打开了 todo.txt"]);
313
+ });
314
+ });
315
+
316
+ // ─── Cross-adapter / mixed registry ────────────────────────────────────
317
+
318
+ describe("all 4 adapters registered together", () => {
319
+ itOrSkip("each adapter writes its own source-tagged rows; counts add up", async () => {
320
+ const chromeDir = path.join(rig.dir, "ChromeProfile");
321
+ const edgeDir = path.join(rig.dir, "EdgeProfile");
322
+ const vscodeRoot = path.join(rig.dir, "VSCode");
323
+ const recentDir = path.join(rig.dir, "Recent");
324
+
325
+ buildChromeFixture(chromeDir, {
326
+ visits: [{ url: "https://a.test", title: "A", visitTimeMs: 1_700_000_001_000 }],
327
+ });
328
+ buildChromeFixture(edgeDir, {
329
+ visits: [{ url: "https://b.test", title: "B", visitTimeMs: 1_700_000_002_000 }],
330
+ });
331
+ buildVscodeFixture(vscodeRoot, {
332
+ workspaces: [{ hash: "hX", folderUri: "file:///c%3A/x", mtimeMs: 1_700_000_001_000 }],
333
+ commands: ["ls"],
334
+ });
335
+ buildRecentFixture(recentDir, [{ name: "x.txt.lnk", mtimeMs: 1_700_000_001_000 }]);
336
+
337
+ rig.registry.register(new BrowserHistoryChromeAdapter({ profilePath: chromeDir }));
338
+ rig.registry.register(new BrowserHistoryEdgeAdapter({ profilePath: edgeDir }));
339
+ rig.registry.register(new VSCodeAdapter({ vscodeRoot }));
340
+ rig.registry.register(new WinRecentAdapter({ recentDir }));
341
+
342
+ const r1 = await rig.registry.syncAdapter("browser-history-chrome");
343
+ const r2 = await rig.registry.syncAdapter("browser-history-edge");
344
+ const r3 = await rig.registry.syncAdapter("vscode");
345
+ const r4 = await rig.registry.syncAdapter("win-recent");
346
+
347
+ expect([r1, r2, r3, r4].every((r) => r.status === "ok")).toBe(true);
348
+
349
+ // Totals across vault
350
+ const eventsTotal = rig.vault.db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
351
+ const itemsTotal = rig.vault.db.prepare("SELECT COUNT(*) AS n FROM items").get().n;
352
+ // Chrome 1 visit + Edge 1 visit + VSCode 1 command + Win 1 recent = 4 events
353
+ // VSCode 1 workspace = 1 item
354
+ expect(eventsTotal).toBe(4);
355
+ expect(itemsTotal).toBe(1);
356
+
357
+ // Source-tagged distribution
358
+ const sourceCounts = rig.vault.db
359
+ .prepare("SELECT source FROM events")
360
+ .all()
361
+ .map((r) => JSON.parse(r.source).adapter)
362
+ .reduce((acc, a) => {
363
+ acc[a] = (acc[a] || 0) + 1;
364
+ return acc;
365
+ }, {});
366
+ expect(sourceCounts).toEqual({
367
+ "browser-history-chrome": 1,
368
+ "browser-history-edge": 1,
369
+ vscode: 1,
370
+ "win-recent": 1,
371
+ });
372
+ });
373
+ });