@chainlesschain/personal-data-hub 0.2.3 → 0.3.0

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 (56) 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 +841 -2
  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__/longtail-adapters.test.js +7 -2
  15. package/__tests__/query-parser.test.js +66 -0
  16. package/__tests__/registry.test.js +114 -0
  17. package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
  18. package/__tests__/sidecar-supervisor.test.js +9 -1
  19. package/__tests__/social-kuaishou-snapshot.test.js +55 -2
  20. package/__tests__/social-toutiao-snapshot.test.js +54 -2
  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/vscode/adapter.js +285 -0
  43. package/lib/adapters/vscode/index.js +18 -0
  44. package/lib/adapters/vscode/vscode-reader.js +191 -0
  45. package/lib/adapters/win-recent/adapter.js +150 -0
  46. package/lib/adapters/win-recent/index.js +16 -0
  47. package/lib/adapters/win-recent/win-recent-reader.js +72 -0
  48. package/lib/analysis.js +227 -9
  49. package/lib/categories.js +101 -0
  50. package/lib/index.js +61 -0
  51. package/lib/migrations.js +146 -0
  52. package/lib/query-parser.js +74 -0
  53. package/lib/registry.js +162 -0
  54. package/lib/vault.js +363 -2
  55. package/package.json +2 -1
  56. package/scripts/run-native-tests-sandbox.sh +53 -0
@@ -48,7 +48,9 @@ describe("SystemDataAndroidAdapter — contract", () => {
48
48
  expect(adapter.name).toBe(SYSTEM_DATA_ANDROID_NAME);
49
49
  expect(adapter.version).toBe(SYSTEM_DATA_ANDROID_VERSION);
50
50
  expect(adapter.extractMode).toBe("device-pull");
51
- expect(adapter.dataDisclosure.sensitivity).toBe("medium");
51
+ // v0.2 bumped sensitivity medium → high when sms/call_log were added
52
+ // (real address book + SMS body are firmly PII-grade).
53
+ expect(adapter.dataDisclosure.sensitivity).toBe("high");
52
54
  expect(adapter.dataDisclosure.legalGate).toBe(false);
53
55
  });
54
56
  });
@@ -272,13 +274,29 @@ describe("SystemDataAndroidAdapter — degenerate snapshots", () => {
272
274
  // the new path without a real device.
273
275
 
274
276
  describe("SystemDataAndroidAdapter — bridge-direct sync", () => {
275
- function makeBridge({ contacts = [], apps = [], throws = null } = {}) {
277
+ // Helper accepts every kind the adapter may invoke today (v0.3.0 added
278
+ // sms / call / media). Unspecified kinds return empty so existing tests
279
+ // only need to opt-in to the kinds they assert against.
280
+ function makeBridge({
281
+ contacts = [],
282
+ apps = [],
283
+ sms = [],
284
+ calls = [],
285
+ media = {},
286
+ throws = null,
287
+ } = {}) {
276
288
  return {
277
289
  caps: () => ({ available: true, reason: "test" }),
278
- invoke: async (method) => {
290
+ invoke: async (method, params) => {
279
291
  if (throws) throw throws;
280
292
  if (method === "contacts.query") return { contacts };
281
293
  if (method === "app.list") return { apps };
294
+ if (method === "sms.query") return { sms };
295
+ if (method === "call.query") return { calls };
296
+ if (method === "media.list") {
297
+ const cat = params && params.category;
298
+ return { files: media[cat] || [] };
299
+ }
282
300
  throw new Error("unexpected method " + method);
283
301
  },
284
302
  };
@@ -384,4 +402,87 @@ describe("SystemDataAndroidAdapter — bridge-direct sync", () => {
384
402
  expect(out).toHaveLength(1);
385
403
  expect(out[0].payload.displayName).toBe("From Snapshot");
386
404
  });
405
+
406
+ // v0.2 — sms/call bridge methods. Validate yields + originalId stability.
407
+ it("v0.2 bridge yields sms + call rows when present", async () => {
408
+ const adapter = new SystemDataAndroidAdapter();
409
+ adapter._deps.bridgeProvider = () =>
410
+ makeBridge({
411
+ sms: [{ id: 7, address: "10086", body: "余额: ¥10", date: 1_700_000_000_000, type: 1 }],
412
+ calls: [{ id: 9, number: "13900000000", duration: 12, date: 1_700_000_001_000, type: 2 }],
413
+ });
414
+ const out = [];
415
+ for await (const r of adapter.sync({ useBridge: true })) out.push(r);
416
+ expect(out.map((r) => r.kind)).toEqual(["sms", "call"]);
417
+ expect(out[0].originalId).toBe("android-sms:7");
418
+ expect(out[1].originalId).toBe("android-call:9");
419
+ });
420
+
421
+ it("v0.2 include.sms=false / include.calls=false honoured per-kind", async () => {
422
+ const adapter = new SystemDataAndroidAdapter();
423
+ adapter._deps.bridgeProvider = () =>
424
+ makeBridge({
425
+ sms: [{ id: 1, address: "x", body: "y", date: 1, type: 1 }],
426
+ calls: [{ id: 2, number: "x", duration: 0, date: 1, type: 1 }],
427
+ });
428
+ const noSms = [];
429
+ for await (const r of adapter.sync({ useBridge: true, include: { sms: false } })) noSms.push(r);
430
+ expect(noSms.map((r) => r.kind)).toEqual(["call"]);
431
+ const noCalls = [];
432
+ for await (const r of adapter.sync({ useBridge: true, include: { calls: false } })) noCalls.push(r);
433
+ expect(noCalls.map((r) => r.kind)).toEqual(["sms"]);
434
+ });
435
+
436
+ // v0.3 — media files across 5 /sdcard categories.
437
+ it("v0.3 bridge yields media-file per category with stable originalId", async () => {
438
+ const adapter = new SystemDataAndroidAdapter();
439
+ adapter._deps.bridgeProvider = () =>
440
+ makeBridge({
441
+ media: {
442
+ photos: [{ path: "/sdcard/DCIM/Camera/img.jpg", size: 1234, mtimeMs: 1, ext: "jpg", category: "photos" }],
443
+ videos: [{ path: "/sdcard/Movies/m.mp4", size: 999, mtimeMs: 2, ext: "mp4", category: "videos" }],
444
+ },
445
+ });
446
+ const out = [];
447
+ for await (const r of adapter.sync({ useBridge: true })) out.push(r);
448
+ expect(out.map((r) => r.kind)).toEqual(["media-file", "media-file"]);
449
+ expect(out[0].originalId).toBe("android-media:/sdcard/DCIM/Camera/img.jpg");
450
+ expect(out[1].originalId).toBe("android-media:/sdcard/Movies/m.mp4");
451
+ });
452
+
453
+ it("v0.3 include.media=false disables ALL media categories", async () => {
454
+ const adapter = new SystemDataAndroidAdapter();
455
+ adapter._deps.bridgeProvider = () =>
456
+ makeBridge({
457
+ media: {
458
+ photos: [{ path: "/p.jpg", category: "photos" }],
459
+ videos: [{ path: "/v.mp4", category: "videos" }],
460
+ },
461
+ });
462
+ const out = [];
463
+ for await (const r of adapter.sync({ useBridge: true, include: { media: false } })) {
464
+ out.push(r);
465
+ }
466
+ expect(out).toHaveLength(0);
467
+ });
468
+
469
+ it("v0.3 include.media.photos=false skips just photos, keeps videos", async () => {
470
+ const adapter = new SystemDataAndroidAdapter();
471
+ adapter._deps.bridgeProvider = () =>
472
+ makeBridge({
473
+ media: {
474
+ photos: [{ path: "/p.jpg", category: "photos" }],
475
+ videos: [{ path: "/v.mp4", category: "videos" }],
476
+ },
477
+ });
478
+ const out = [];
479
+ for await (const r of adapter.sync({
480
+ useBridge: true,
481
+ include: { media: { photos: false } },
482
+ })) {
483
+ out.push(r);
484
+ }
485
+ expect(out).toHaveLength(1);
486
+ expect(out[0].payload.category).toBe("videos");
487
+ });
387
488
  });
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, utimesSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import Database from "better-sqlite3";
8
+
9
+ const {
10
+ VSCodeAdapter,
11
+ VSCODE_NAME,
12
+ VSCODE_VERSION,
13
+ decodeFileUri,
14
+ } = require("../../lib/adapters/vscode");
15
+ const { assertAdapter } = require("../../lib/adapter-spec");
16
+ const {
17
+ ENTITY_TYPES,
18
+ EVENT_SUBTYPES,
19
+ ITEM_SUBTYPES,
20
+ } = require("../../lib/constants");
21
+ const { validateEvent, validateItem } = require("../../lib/schemas");
22
+
23
+ let tmpDir;
24
+ let vscodeRoot;
25
+ let wsRoot;
26
+ let stateDbPath;
27
+
28
+ function makeWorkspace(hash, folderUri, opts = {}) {
29
+ const d = join(wsRoot, hash);
30
+ mkdirSync(d, { recursive: true });
31
+ const wsFile = join(d, "workspace.json");
32
+ writeFileSync(wsFile, JSON.stringify({ folder: folderUri }), "utf-8");
33
+ if (opts.mtimeMs) {
34
+ const ms = opts.mtimeMs;
35
+ utimesSync(wsFile, ms / 1000, ms / 1000);
36
+ }
37
+ }
38
+
39
+ function makeTerminalHistoryDb({ commands = [], dirs = [], cmdTs = null, dirTs = null }) {
40
+ mkdirSync(join(vscodeRoot, "User", "globalStorage"), { recursive: true });
41
+ const db = new Database(stateDbPath);
42
+ db.exec("CREATE TABLE ItemTable(key TEXT PRIMARY KEY, value BLOB)");
43
+ const put = db.prepare("INSERT OR REPLACE INTO ItemTable(key, value) VALUES(?, ?)");
44
+ put.run(
45
+ "terminal.history.entries.commands",
46
+ JSON.stringify({
47
+ entries: commands.map((c) => ({ key: c, value: { shellType: "pwsh" } })),
48
+ }),
49
+ );
50
+ put.run(
51
+ "terminal.history.entries.dirs",
52
+ JSON.stringify({
53
+ entries: dirs.map((d) => ({ key: d, value: { shellType: "pwsh" } })),
54
+ }),
55
+ );
56
+ if (cmdTs != null) put.run("terminal.history.timestamp.commands", String(cmdTs));
57
+ if (dirTs != null) put.run("terminal.history.timestamp.dirs", String(dirTs));
58
+ db.close();
59
+ }
60
+
61
+ beforeEach(() => {
62
+ tmpDir = mkdtempSync(join(tmpdir(), "vscode-adapter-test-"));
63
+ vscodeRoot = join(tmpDir, "Code");
64
+ wsRoot = join(vscodeRoot, "User", "workspaceStorage");
65
+ stateDbPath = join(vscodeRoot, "User", "globalStorage", "state.vscdb");
66
+ mkdirSync(wsRoot, { recursive: true });
67
+ });
68
+
69
+ afterEach(() => {
70
+ rmSync(tmpDir, { recursive: true, force: true });
71
+ });
72
+
73
+ describe("VSCodeAdapter — contract + identity", () => {
74
+ it("conforms to PersonalDataAdapter contract", () => {
75
+ expect(assertAdapter(new VSCodeAdapter())).toEqual({ ok: true });
76
+ });
77
+
78
+ it("name/version/capabilities are stable", () => {
79
+ const a = new VSCodeAdapter();
80
+ expect(a.name).toBe(VSCODE_NAME);
81
+ expect(a.name).toBe("vscode");
82
+ expect(a.version).toBe(VSCODE_VERSION);
83
+ expect(a.extractMode).toBe("file-import");
84
+ expect(a.capabilities).toContain("sync:vscode-workspace-storage");
85
+ expect(a.capabilities).toContain("sync:vscode-globalstorage-sqlite");
86
+ });
87
+ });
88
+
89
+ describe("VSCodeAdapter.authenticate", () => {
90
+ it("VSCODE_NOT_FOUND when neither workspaceStorage nor state.vscdb exist", async () => {
91
+ rmSync(wsRoot, { recursive: true, force: true });
92
+ const a = new VSCodeAdapter({ vscodeRoot });
93
+ const r = await a.authenticate({});
94
+ expect(r.ok).toBe(false);
95
+ expect(r.reason).toBe("VSCODE_NOT_FOUND");
96
+ });
97
+
98
+ it("succeeds when only workspaceStorage exists", async () => {
99
+ const a = new VSCodeAdapter({ vscodeRoot });
100
+ const r = await a.authenticate({});
101
+ expect(r.ok).toBe(true);
102
+ expect(r.hasWorkspaces).toBe(true);
103
+ expect(r.hasTerminalHistory).toBe(false);
104
+ });
105
+
106
+ it("succeeds when only state.vscdb exists", async () => {
107
+ rmSync(wsRoot, { recursive: true, force: true });
108
+ makeTerminalHistoryDb({ commands: ["ls"], cmdTs: 1_700_000_000_000 });
109
+ const a = new VSCodeAdapter({ vscodeRoot });
110
+ const r = await a.authenticate({});
111
+ expect(r.ok).toBe(true);
112
+ expect(r.hasWorkspaces).toBe(false);
113
+ expect(r.hasTerminalHistory).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("VSCodeAdapter.sync — workspaces", () => {
118
+ it("yields one raw per workspace.json with folderPath decoded", async () => {
119
+ makeWorkspace("hash-a", "file:///c%3A/code/foo", { mtimeMs: 1_700_000_001_000 });
120
+ makeWorkspace("hash-b", "file:///c%3A/code/bar", { mtimeMs: 1_700_000_002_000 });
121
+ const a = new VSCodeAdapter({ vscodeRoot });
122
+ const raws = [];
123
+ for await (const r of a.sync()) raws.push(r);
124
+ const wss = raws.filter((r) => r.kind === "workspace");
125
+ expect(wss).toHaveLength(2);
126
+ const byHash = Object.fromEntries(wss.map((r) => [r.payload.hash, r.payload]));
127
+ expect(byHash["hash-a"].folderUri).toBe("file:///c%3A/code/foo");
128
+ expect(byHash["hash-a"].folderPath).toMatch(/foo$/);
129
+ expect(byHash["hash-a"].lastOpenedMs).toBe(1_700_000_001_000);
130
+ });
131
+
132
+ it("filters workspaces by since (uses workspace.json mtime)", async () => {
133
+ makeWorkspace("old", "file:///c%3A/old", { mtimeMs: 1_700_000_000_000 });
134
+ makeWorkspace("new", "file:///c%3A/new", { mtimeMs: 1_700_000_005_000 });
135
+ const a = new VSCodeAdapter({ vscodeRoot });
136
+ const raws = [];
137
+ for await (const r of a.sync({ since: 1_700_000_003_000 })) raws.push(r);
138
+ const hashes = raws.filter((r) => r.kind === "workspace").map((r) => r.payload.hash);
139
+ expect(hashes).toEqual(["new"]);
140
+ });
141
+
142
+ it("skips workspaces with no folder/workspace key", async () => {
143
+ const d = join(wsRoot, "empty");
144
+ mkdirSync(d, { recursive: true });
145
+ writeFileSync(join(d, "workspace.json"), JSON.stringify({}), "utf-8");
146
+ const a = new VSCodeAdapter({ vscodeRoot });
147
+ const raws = [];
148
+ for await (const r of a.sync()) raws.push(r);
149
+ expect(raws.filter((r) => r.kind === "workspace")).toHaveLength(0);
150
+ });
151
+ });
152
+
153
+ describe("VSCodeAdapter.sync — terminal history", () => {
154
+ it("yields one raw per command + per dir, anchored to snapshot ts", async () => {
155
+ makeTerminalHistoryDb({
156
+ commands: ["ls", "git status", "npm test"],
157
+ dirs: ["/c/code/foo", "/c/code/bar"],
158
+ cmdTs: 1_700_000_010_000,
159
+ dirTs: 1_700_000_020_000,
160
+ });
161
+ const a = new VSCodeAdapter({ vscodeRoot });
162
+ const raws = [];
163
+ for await (const r of a.sync()) raws.push(r);
164
+ const cmds = raws.filter((r) => r.kind === "terminal-command");
165
+ const dirs = raws.filter((r) => r.kind === "terminal-dir");
166
+ expect(cmds).toHaveLength(3);
167
+ expect(dirs).toHaveLength(2);
168
+ expect(cmds[0].payload.value).toBe("ls");
169
+ expect(cmds[0].payload.snapshotTs).toBe(1_700_000_010_000);
170
+ expect(dirs[0].payload.snapshotTs).toBe(1_700_000_020_000);
171
+ });
172
+
173
+ it("originalId disambiguates duplicate commands by sourceIndex", async () => {
174
+ makeTerminalHistoryDb({
175
+ commands: ["ls", "ls"], // duplicate value
176
+ cmdTs: 1_700_000_010_000,
177
+ });
178
+ const a = new VSCodeAdapter({ vscodeRoot });
179
+ const raws = [];
180
+ for await (const r of a.sync()) raws.push(r);
181
+ const ids = raws.filter((r) => r.kind === "terminal-command").map((r) => r.originalId);
182
+ expect(new Set(ids).size).toBe(2);
183
+ });
184
+
185
+ it("respects include.terminal=false", async () => {
186
+ makeWorkspace("h", "file:///c%3A/p", { mtimeMs: 1_700_000_001_000 });
187
+ makeTerminalHistoryDb({ commands: ["ls"], cmdTs: 1_700_000_010_000 });
188
+ const a = new VSCodeAdapter({ vscodeRoot });
189
+ const raws = [];
190
+ for await (const r of a.sync({ include: { terminal: false } })) raws.push(r);
191
+ expect(raws.every((r) => r.kind === "workspace")).toBe(true);
192
+ expect(raws.length).toBe(1);
193
+ });
194
+
195
+ it("respects include.workspaces=false", async () => {
196
+ makeWorkspace("h", "file:///c%3A/p", { mtimeMs: 1_700_000_001_000 });
197
+ makeTerminalHistoryDb({ commands: ["ls"], cmdTs: 1_700_000_010_000 });
198
+ const a = new VSCodeAdapter({ vscodeRoot });
199
+ const raws = [];
200
+ for await (const r of a.sync({ include: { workspaces: false } })) raws.push(r);
201
+ expect(raws.every((r) => r.kind !== "workspace")).toBe(true);
202
+ });
203
+ });
204
+
205
+ describe("VSCodeAdapter.normalize", () => {
206
+ it("maps a workspace to Item(LINK) with code-project category", () => {
207
+ const a = new VSCodeAdapter();
208
+ const { items } = a.normalize({
209
+ kind: "workspace",
210
+ capturedAt: 1_700_000_000_000,
211
+ payload: {
212
+ hash: "h1",
213
+ folderUri: "file:///c%3A/code/chainlesschain",
214
+ folderPath: "c:\\code\\chainlesschain",
215
+ lastOpenedMs: 1_700_000_001_000,
216
+ },
217
+ });
218
+ expect(items).toHaveLength(1);
219
+ expect(items[0].subtype).toBe(ITEM_SUBTYPES.LINK);
220
+ expect(items[0].category).toBe("code-project");
221
+ expect(items[0].name).toBe("chainlesschain");
222
+ expect(items[0].extra.editor).toBe("vscode");
223
+ expect(items[0].extra.folderUri).toBe("file:///c%3A/code/chainlesschain");
224
+ expect(validateItem(items[0]).valid).toBe(true);
225
+ });
226
+
227
+ it("maps terminal-command to Event(OTHER) with cmd in content", () => {
228
+ const a = new VSCodeAdapter();
229
+ const { events } = a.normalize({
230
+ kind: "terminal-command",
231
+ capturedAt: 1_700_000_000_000,
232
+ originalId: "vscode-terminal-cmd:0:abc",
233
+ payload: {
234
+ value: "git status",
235
+ shellType: "pwsh",
236
+ sourceIndex: 0,
237
+ snapshotTs: 1_700_000_010_000,
238
+ },
239
+ });
240
+ expect(events).toHaveLength(1);
241
+ expect(events[0].subtype).toBe(EVENT_SUBTYPES.OTHER);
242
+ expect(events[0].content.title).toBe("git status");
243
+ expect(events[0].content.text).toBe("git status");
244
+ expect(events[0].extra.kind).toBe("terminal-command");
245
+ expect(events[0].extra.shellType).toBe("pwsh");
246
+ expect(events[0].occurredAt).toBe(1_700_000_010_000);
247
+ expect(validateEvent(events[0]).valid).toBe(true);
248
+ });
249
+
250
+ it("maps terminal-dir to Event(OTHER) with 'cd <dir>' title", () => {
251
+ const a = new VSCodeAdapter();
252
+ const { events } = a.normalize({
253
+ kind: "terminal-dir",
254
+ capturedAt: 1_700_000_000_000,
255
+ originalId: "vscode-terminal-dir:0:xyz",
256
+ payload: {
257
+ value: "/c/code/foo",
258
+ sourceIndex: 0,
259
+ snapshotTs: 1_700_000_020_000,
260
+ },
261
+ });
262
+ expect(events[0].content.title).toBe("cd /c/code/foo");
263
+ expect(events[0].extra.kind).toBe("terminal-dir");
264
+ expect(validateEvent(events[0]).valid).toBe(true);
265
+ });
266
+
267
+ it("truncates >80 char command titles to ellipsis", () => {
268
+ const a = new VSCodeAdapter();
269
+ const longCmd = "echo " + "a".repeat(200);
270
+ const { events } = a.normalize({
271
+ kind: "terminal-command",
272
+ capturedAt: 1_700_000_000_000,
273
+ originalId: "vscode-terminal-cmd:0:long",
274
+ payload: { value: longCmd, sourceIndex: 0, snapshotTs: 1_700_000_010_000 },
275
+ });
276
+ expect(events[0].content.title.length).toBeLessThanOrEqual(81);
277
+ expect(events[0].content.title.endsWith("…")).toBe(true);
278
+ expect(events[0].content.text).toBe(longCmd);
279
+ });
280
+
281
+ it("throws on unknown raw.kind", () => {
282
+ expect(() => new VSCodeAdapter().normalize({ kind: "bogus", payload: {} })).toThrow(
283
+ /unknown raw\.kind=bogus/,
284
+ );
285
+ });
286
+ });
287
+
288
+ describe("decodeFileUri helper", () => {
289
+ it("decodes Windows file URI to backslash path", () => {
290
+ if (process.platform !== "win32") return;
291
+ expect(decodeFileUri("file:///c%3A/code/foo")).toBe("c:\\code\\foo");
292
+ });
293
+
294
+ it("returns null for non-file:// schemes", () => {
295
+ expect(decodeFileUri("vscode-remote://ssh-remote+host/foo")).toBe(null);
296
+ expect(decodeFileUri(null)).toBe(null);
297
+ expect(decodeFileUri(undefined)).toBe(null);
298
+ });
299
+ });
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, utimesSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ const {
9
+ WinRecentAdapter,
10
+ WIN_RECENT_NAME,
11
+ WIN_RECENT_VERSION,
12
+ } = require("../../lib/adapters/win-recent");
13
+ const { assertAdapter } = require("../../lib/adapter-spec");
14
+ const {
15
+ EVENT_SUBTYPES,
16
+ } = require("../../lib/constants");
17
+ const { validateEvent } = require("../../lib/schemas");
18
+
19
+ let tmpDir;
20
+ let recentDir;
21
+
22
+ function makeLnk(name, mtimeMs, body = "lnk-blob") {
23
+ const p = join(recentDir, name);
24
+ writeFileSync(p, body, "utf-8");
25
+ if (mtimeMs != null) {
26
+ utimesSync(p, mtimeMs / 1000, mtimeMs / 1000);
27
+ }
28
+ }
29
+
30
+ beforeEach(() => {
31
+ tmpDir = mkdtempSync(join(tmpdir(), "win-recent-test-"));
32
+ recentDir = join(tmpDir, "Recent");
33
+ mkdirSync(recentDir, { recursive: true });
34
+ });
35
+
36
+ afterEach(() => {
37
+ rmSync(tmpDir, { recursive: true, force: true });
38
+ });
39
+
40
+ describe("WinRecentAdapter — contract + identity", () => {
41
+ it("conforms to PersonalDataAdapter contract", () => {
42
+ expect(assertAdapter(new WinRecentAdapter())).toEqual({ ok: true });
43
+ });
44
+
45
+ it("name + version + capabilities stable", () => {
46
+ const a = new WinRecentAdapter();
47
+ expect(a.name).toBe(WIN_RECENT_NAME);
48
+ expect(a.name).toBe("win-recent");
49
+ expect(a.version).toBe(WIN_RECENT_VERSION);
50
+ expect(a.extractMode).toBe("file-import");
51
+ expect(a.capabilities).toContain("sync:win-recent-shortcuts");
52
+ });
53
+ });
54
+
55
+ describe("WinRecentAdapter.authenticate", () => {
56
+ it("PLATFORM_UNSUPPORTED when no recentDir resolved (override null)", async () => {
57
+ const a = new WinRecentAdapter();
58
+ a._deps.defaultDir = () => null;
59
+ const r = await a.authenticate({});
60
+ expect(r.ok).toBe(false);
61
+ expect(r.reason).toBe("PLATFORM_UNSUPPORTED");
62
+ });
63
+
64
+ it("RECENT_DIR_NOT_FOUND when dir doesn't exist", async () => {
65
+ const a = new WinRecentAdapter({ recentDir: join(tmpDir, "bogus") });
66
+ const r = await a.authenticate({});
67
+ expect(r.ok).toBe(false);
68
+ expect(r.reason).toBe("RECENT_DIR_NOT_FOUND");
69
+ });
70
+
71
+ it("succeeds when dir exists", async () => {
72
+ const a = new WinRecentAdapter({ recentDir });
73
+ const r = await a.authenticate({});
74
+ expect(r.ok).toBe(true);
75
+ expect(r.recentDir).toBe(recentDir);
76
+ });
77
+ });
78
+
79
+ describe("WinRecentAdapter.sync", () => {
80
+ it("yields one raw per .lnk, sorted mtime ascending", async () => {
81
+ makeLnk("zebra.lnk", 1_700_000_003_000);
82
+ makeLnk("apple.lnk", 1_700_000_001_000);
83
+ makeLnk("mango.lnk", 1_700_000_002_000);
84
+ const a = new WinRecentAdapter({ recentDir });
85
+ const raws = [];
86
+ for await (const r of a.sync()) raws.push(r);
87
+ expect(raws).toHaveLength(3);
88
+ expect(raws[0].payload.name).toBe("apple");
89
+ expect(raws[1].payload.name).toBe("mango");
90
+ expect(raws[2].payload.name).toBe("zebra");
91
+ expect(raws[0].payload.mtimeMs).toBe(1_700_000_001_000);
92
+ });
93
+
94
+ it("skips non-.lnk files and AutomaticDestinations / CustomDestinations subdirs", async () => {
95
+ makeLnk("a.lnk", 1_700_000_001_000);
96
+ makeLnk("readme.txt", 1_700_000_002_000); // non-.lnk
97
+ mkdirSync(join(recentDir, "AutomaticDestinations"), { recursive: true });
98
+ writeFileSync(
99
+ join(recentDir, "AutomaticDestinations", "deep.lnk"),
100
+ "should-not-be-found",
101
+ );
102
+ mkdirSync(join(recentDir, "CustomDestinations"), { recursive: true });
103
+ const a = new WinRecentAdapter({ recentDir });
104
+ const raws = [];
105
+ for await (const r of a.sync()) raws.push(r);
106
+ expect(raws).toHaveLength(1);
107
+ expect(raws[0].payload.name).toBe("a");
108
+ });
109
+
110
+ it("respects since filter (epoch ms)", async () => {
111
+ makeLnk("old.lnk", 1_700_000_001_000);
112
+ makeLnk("new.lnk", 1_700_000_005_000);
113
+ const a = new WinRecentAdapter({ recentDir });
114
+ const raws = [];
115
+ for await (const r of a.sync({ since: 1_700_000_003_000 })) raws.push(r);
116
+ expect(raws.map((r) => r.payload.name)).toEqual(["new"]);
117
+ });
118
+
119
+ it("respects limit", async () => {
120
+ for (let i = 0; i < 10; i++) makeLnk(`f${i}.lnk`, 1_700_000_000_000 + i * 1000);
121
+ const a = new WinRecentAdapter({ recentDir });
122
+ const raws = [];
123
+ for await (const r of a.sync({ limit: 4 })) raws.push(r);
124
+ expect(raws).toHaveLength(4);
125
+ });
126
+
127
+ it("originalId folds in mtime so same file at new mtime gets a new event", async () => {
128
+ makeLnk("foo.lnk", 1_700_000_001_000);
129
+ const a = new WinRecentAdapter({ recentDir });
130
+ const raws1 = [];
131
+ for await (const r of a.sync()) raws1.push(r);
132
+ const id1 = raws1[0].originalId;
133
+ // Re-touch with a newer mtime
134
+ makeLnk("foo.lnk", 1_700_000_009_000);
135
+ const raws2 = [];
136
+ for await (const r of a.sync()) raws2.push(r);
137
+ const id2 = raws2[0].originalId;
138
+ expect(id1).not.toBe(id2);
139
+ expect(id2).toContain("1700000009000");
140
+ });
141
+ });
142
+
143
+ describe("WinRecentAdapter.normalize", () => {
144
+ it("maps recent-file to Event(OTHER) with '打开了 X' title", () => {
145
+ const a = new WinRecentAdapter();
146
+ const { events } = a.normalize({
147
+ kind: "recent-file",
148
+ originalId: "win-recent:C:\\Users\\u\\Recent\\foo.lnk:1700000001000",
149
+ capturedAt: 1_700_000_005_000,
150
+ payload: {
151
+ name: "foo",
152
+ mtimeMs: 1_700_000_001_000,
153
+ size: 1024,
154
+ lnkPath: "C:\\Users\\u\\Recent\\foo.lnk",
155
+ },
156
+ });
157
+ expect(events).toHaveLength(1);
158
+ const e = events[0];
159
+ expect(e.subtype).toBe(EVENT_SUBTYPES.OTHER);
160
+ expect(e.content.title).toBe("打开了 foo");
161
+ expect(e.content.text).toBe("foo");
162
+ expect(e.actor).toBe("self");
163
+ expect(e.occurredAt).toBe(1_700_000_001_000);
164
+ expect(e.extra.kind).toBe("recent-file");
165
+ expect(e.extra.targetName).toBe("foo");
166
+ expect(e.extra.source).toBe("win-recent");
167
+ expect(validateEvent(e).valid).toBe(true);
168
+ });
169
+
170
+ it("truncates long target names in title", () => {
171
+ const a = new WinRecentAdapter();
172
+ const longName = "x".repeat(120);
173
+ const { events } = a.normalize({
174
+ kind: "recent-file",
175
+ originalId: "win-recent:long",
176
+ capturedAt: 1_700_000_000_000,
177
+ payload: {
178
+ name: longName,
179
+ mtimeMs: 1_700_000_000_000,
180
+ },
181
+ });
182
+ // Title is "打开了 " (4 chars) + name; name truncated to 70 + "…"
183
+ expect(events[0].content.title.endsWith("…")).toBe(true);
184
+ expect(events[0].content.text).toBe(longName); // full name preserved in text
185
+ });
186
+
187
+ it("throws on unknown raw.kind", () => {
188
+ expect(() => new WinRecentAdapter().normalize({ kind: "bogus", payload: {} })).toThrow(
189
+ /unknown raw\.kind=bogus/,
190
+ );
191
+ });
192
+ });