@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.
- package/__tests__/adapters/browser-history-chrome.test.js +377 -0
- package/__tests__/adapters/browser-history-edge.test.js +159 -0
- package/__tests__/adapters/git-activity.test.js +216 -0
- package/__tests__/adapters/local-files.test.js +264 -0
- package/__tests__/adapters/shell-history.test.js +180 -0
- package/__tests__/adapters/system-data-android.test.js +104 -3
- package/__tests__/adapters/vscode.test.js +299 -0
- package/__tests__/adapters/win-recent.test.js +192 -0
- package/__tests__/analysis.test.js +840 -1
- package/__tests__/categories.test.js +92 -0
- package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
- package/__tests__/entity-resolver-vault.test.js +5 -2
- package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
- package/__tests__/query-parser.test.js +66 -0
- package/__tests__/registry.test.js +114 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
- package/__tests__/sidecar-supervisor.test.js +9 -1
- package/__tests__/social-kuaishou-snapshot.test.js +55 -2
- package/__tests__/social-toutiao-snapshot.test.js +54 -2
- package/__tests__/travel-adapters.test.js +97 -5
- package/__tests__/vault-search-helpers.test.js +104 -0
- package/__tests__/vault-search.test.js +423 -0
- package/__tests__/vault.test.js +77 -3
- package/lib/adapters/browser-history-chrome/adapter.js +247 -0
- package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
- package/lib/adapters/browser-history-chrome/index.js +23 -0
- package/lib/adapters/browser-history-edge/adapter.js +34 -0
- package/lib/adapters/browser-history-edge/index.js +13 -0
- package/lib/adapters/git-activity/adapter.js +155 -0
- package/lib/adapters/git-activity/git-reader.js +125 -0
- package/lib/adapters/git-activity/index.js +17 -0
- package/lib/adapters/local-files/adapter.js +149 -0
- package/lib/adapters/local-files/file-walker.js +125 -0
- package/lib/adapters/local-files/index.js +18 -0
- package/lib/adapters/shell-history/adapter.js +137 -0
- package/lib/adapters/shell-history/index.js +17 -0
- package/lib/adapters/shell-history/shell-reader.js +100 -0
- package/lib/adapters/social-kuaishou/index.js +57 -1
- package/lib/adapters/social-toutiao/index.js +59 -1
- package/lib/adapters/system-data-android/adapter.js +220 -3
- package/lib/adapters/travel-12306/index.js +215 -29
- package/lib/adapters/vscode/adapter.js +285 -0
- package/lib/adapters/vscode/index.js +18 -0
- package/lib/adapters/vscode/vscode-reader.js +191 -0
- package/lib/adapters/win-recent/adapter.js +150 -0
- package/lib/adapters/win-recent/index.js +16 -0
- package/lib/adapters/win-recent/win-recent-reader.js +72 -0
- package/lib/analysis.js +227 -9
- package/lib/categories.js +101 -0
- package/lib/index.js +61 -0
- package/lib/migrations.js +146 -0
- package/lib/query-parser.js +74 -0
- package/lib/registry.js +162 -0
- package/lib/vault.js +363 -2
- package/package.json +2 -1
- package/scripts/run-native-tests-sandbox.sh +53 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
GitActivityAdapter,
|
|
11
|
+
GIT_ACTIVITY_NAME,
|
|
12
|
+
GIT_ACTIVITY_VERSION,
|
|
13
|
+
} = require("../../lib/adapters/git-activity");
|
|
14
|
+
const { assertAdapter } = require("../../lib/adapter-spec");
|
|
15
|
+
const { EVENT_SUBTYPES } = require("../../lib/constants");
|
|
16
|
+
const { validateEvent } = require("../../lib/schemas");
|
|
17
|
+
|
|
18
|
+
let tmpDir;
|
|
19
|
+
let codeRoot;
|
|
20
|
+
|
|
21
|
+
// Anchor fixtures to "now - 1h" so the default --since=180.days filter
|
|
22
|
+
// never drops them. Bumping the literal in one place avoids drift if the
|
|
23
|
+
// adapter's default window changes.
|
|
24
|
+
const FIXTURE_NOW = Date.now();
|
|
25
|
+
function ts(offsetSec = 0) {
|
|
26
|
+
return FIXTURE_NOW - 3_600_000 + offsetSec * 1000;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build a single throwaway repo with N commits — each committed with
|
|
30
|
+
// GIT_AUTHOR_DATE so the timestamps are deterministic.
|
|
31
|
+
function makeRepo(name, commits) {
|
|
32
|
+
const dir = join(codeRoot, name);
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
const G = (args, env = {}) =>
|
|
35
|
+
execFileSync("git", ["-C", dir, ...args], {
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
40
|
+
...env,
|
|
41
|
+
},
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
});
|
|
44
|
+
G(["init", "-q", "-b", "main"]);
|
|
45
|
+
G(["config", "user.email", "test@example.com"]);
|
|
46
|
+
G(["config", "user.name", "Test"]);
|
|
47
|
+
G(["config", "commit.gpgsign", "false"]);
|
|
48
|
+
let i = 0;
|
|
49
|
+
for (const c of commits) {
|
|
50
|
+
const file = join(dir, `f${i}.txt`);
|
|
51
|
+
writeFileSync(file, `content ${i}\n`, "utf-8");
|
|
52
|
+
G(["add", "."]);
|
|
53
|
+
const dt = new Date(c.tsMs).toISOString();
|
|
54
|
+
G(["commit", "-m", c.subject, "--author", `${c.author || "Test User"} <test@example.com>`], {
|
|
55
|
+
GIT_AUTHOR_DATE: dt,
|
|
56
|
+
GIT_COMMITTER_DATE: dt,
|
|
57
|
+
});
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
tmpDir = mkdtempSync(join(tmpdir(), "git-act-test-"));
|
|
65
|
+
codeRoot = join(tmpDir, "code");
|
|
66
|
+
mkdirSync(codeRoot, { recursive: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("GitActivityAdapter — contract + identity", () => {
|
|
74
|
+
it("conforms to PersonalDataAdapter contract", () => {
|
|
75
|
+
expect(assertAdapter(new GitActivityAdapter())).toEqual({ ok: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("identifies as git-activity with sync:git-log-local capability", () => {
|
|
79
|
+
const a = new GitActivityAdapter();
|
|
80
|
+
expect(a.name).toBe(GIT_ACTIVITY_NAME);
|
|
81
|
+
expect(a.name).toBe("git-activity");
|
|
82
|
+
expect(a.version).toBe(GIT_ACTIVITY_VERSION);
|
|
83
|
+
expect(a.capabilities).toContain("sync:git-log-local");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("GitActivityAdapter.authenticate", () => {
|
|
88
|
+
it("NO_GIT_REPOS when codeRoot empty", async () => {
|
|
89
|
+
const a = new GitActivityAdapter({ codeRoots: [codeRoot] });
|
|
90
|
+
const r = await a.authenticate({});
|
|
91
|
+
expect(r.ok).toBe(false);
|
|
92
|
+
expect(r.reason).toBe("NO_GIT_REPOS");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("ok when at least one repo exists", async () => {
|
|
96
|
+
makeRepo("a", [{ subject: "init", tsMs: ts(0) }]);
|
|
97
|
+
const a = new GitActivityAdapter({ codeRoots: [codeRoot] });
|
|
98
|
+
const r = await a.authenticate({});
|
|
99
|
+
expect(r.ok).toBe(true);
|
|
100
|
+
expect(r.repoCount).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("GitActivityAdapter.sync", () => {
|
|
105
|
+
it("yields commits with author+subject+ts, originalId stable across syncs", async () => {
|
|
106
|
+
makeRepo("a", [
|
|
107
|
+
{ subject: "first commit", tsMs: ts(1), author: "Alice" },
|
|
108
|
+
{ subject: "second commit", tsMs: ts(2), author: "Alice" },
|
|
109
|
+
]);
|
|
110
|
+
const a = new GitActivityAdapter({ codeRoots: [codeRoot] });
|
|
111
|
+
const raws1 = [];
|
|
112
|
+
for await (const r of a.sync()) raws1.push(r);
|
|
113
|
+
expect(raws1).toHaveLength(2);
|
|
114
|
+
expect(raws1[0].payload.subject).toBeTruthy();
|
|
115
|
+
expect(raws1[0].payload.authorName).toBe("Alice");
|
|
116
|
+
expect(raws1[0].payload.repoName).toBe("a");
|
|
117
|
+
expect(raws1[0].originalId).toMatch(/^git-commit:/);
|
|
118
|
+
// Re-sync — originalIds should match (idempotent)
|
|
119
|
+
const raws2 = [];
|
|
120
|
+
for await (const r of a.sync()) raws2.push(r);
|
|
121
|
+
const ids1 = raws1.map((r) => r.originalId).sort();
|
|
122
|
+
const ids2 = raws2.map((r) => r.originalId).sort();
|
|
123
|
+
expect(ids2).toEqual(ids1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("multi-repo: enumerates every .git dir under the root", async () => {
|
|
127
|
+
makeRepo("repo-a", [{ subject: "a1", tsMs: ts(1) }]);
|
|
128
|
+
makeRepo("repo-b", [{ subject: "b1", tsMs: ts(2) }]);
|
|
129
|
+
makeRepo("repo-c", [{ subject: "c1", tsMs: ts(3) }]);
|
|
130
|
+
const a = new GitActivityAdapter({ codeRoots: [codeRoot] });
|
|
131
|
+
const raws = [];
|
|
132
|
+
for await (const r of a.sync()) raws.push(r);
|
|
133
|
+
const repoNames = new Set(raws.map((r) => r.payload.repoName));
|
|
134
|
+
expect(repoNames).toEqual(new Set(["repo-a", "repo-b", "repo-c"]));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("respects limit", async () => {
|
|
138
|
+
makeRepo("a", [
|
|
139
|
+
{ subject: "1", tsMs: ts(1) },
|
|
140
|
+
{ subject: "2", tsMs: ts(2) },
|
|
141
|
+
{ subject: "3", tsMs: ts(3) },
|
|
142
|
+
]);
|
|
143
|
+
const a = new GitActivityAdapter({ codeRoots: [codeRoot] });
|
|
144
|
+
const raws = [];
|
|
145
|
+
for await (const r of a.sync({ limit: 2 })) raws.push(r);
|
|
146
|
+
expect(raws).toHaveLength(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("skips non-.git directories silently", async () => {
|
|
150
|
+
makeRepo("real-repo", [{ subject: "x", tsMs: ts(1) }]);
|
|
151
|
+
mkdirSync(join(codeRoot, "not-a-repo"), { recursive: true });
|
|
152
|
+
writeFileSync(join(codeRoot, "not-a-repo", "README.md"), "no .git here", "utf-8");
|
|
153
|
+
const a = new GitActivityAdapter({ codeRoots: [codeRoot] });
|
|
154
|
+
const raws = [];
|
|
155
|
+
for await (const r of a.sync()) raws.push(r);
|
|
156
|
+
expect(raws).toHaveLength(1);
|
|
157
|
+
expect(raws[0].payload.repoName).toBe("real-repo");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("GitActivityAdapter.normalize", () => {
|
|
162
|
+
it("maps commit → schema-valid Event(OTHER) with author as actor", () => {
|
|
163
|
+
const a = new GitActivityAdapter();
|
|
164
|
+
const { events } = a.normalize({
|
|
165
|
+
kind: "commit",
|
|
166
|
+
originalId: "git-commit:/code/foo:abc",
|
|
167
|
+
capturedAt: 1_700_000_005_000,
|
|
168
|
+
payload: {
|
|
169
|
+
sha: "abcdef0123456789",
|
|
170
|
+
shortSha: "abcdef01",
|
|
171
|
+
authoredAtMs: 1_700_000_001_000,
|
|
172
|
+
authorName: "Alice",
|
|
173
|
+
authorEmail: "alice@example.com",
|
|
174
|
+
subject: "Fix the bug",
|
|
175
|
+
repoDir: "/code/foo",
|
|
176
|
+
repoName: "foo",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(events).toHaveLength(1);
|
|
180
|
+
const e = events[0];
|
|
181
|
+
expect(e.subtype).toBe(EVENT_SUBTYPES.OTHER);
|
|
182
|
+
expect(e.actor).toBe("Alice");
|
|
183
|
+
expect(e.content.title).toBe("Fix the bug");
|
|
184
|
+
expect(e.occurredAt).toBe(1_700_000_001_000);
|
|
185
|
+
expect(e.extra.repoName).toBe("foo");
|
|
186
|
+
expect(e.extra.sha).toBe("abcdef0123456789");
|
|
187
|
+
expect(validateEvent(e).valid).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("truncates long commit subjects to 100 chars in title", () => {
|
|
191
|
+
const a = new GitActivityAdapter();
|
|
192
|
+
const longSubj = "x".repeat(200);
|
|
193
|
+
const { events } = a.normalize({
|
|
194
|
+
kind: "commit",
|
|
195
|
+
capturedAt: 1_700_000_000_000,
|
|
196
|
+
originalId: "git-commit:foo:long",
|
|
197
|
+
payload: {
|
|
198
|
+
sha: "deadbeef",
|
|
199
|
+
shortSha: "deadbeef",
|
|
200
|
+
authoredAtMs: 1_700_000_000_000,
|
|
201
|
+
subject: longSubj,
|
|
202
|
+
authorName: "X",
|
|
203
|
+
repoName: "r",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
expect(events[0].content.title.length).toBeLessThanOrEqual(101);
|
|
207
|
+
expect(events[0].content.title.endsWith("…")).toBe(true);
|
|
208
|
+
expect(events[0].content.text).toBe(longSubj);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("throws on unknown raw.kind", () => {
|
|
212
|
+
expect(() => new GitActivityAdapter().normalize({ kind: "bogus" })).toThrow(
|
|
213
|
+
/unknown raw\.kind=bogus/,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
LocalFilesAdapter,
|
|
10
|
+
LOCAL_FILES_NAME,
|
|
11
|
+
LOCAL_FILES_VERSION,
|
|
12
|
+
} = require("../../lib/adapters/local-files");
|
|
13
|
+
const { assertAdapter } = require("../../lib/adapter-spec");
|
|
14
|
+
const { EVENT_SUBTYPES } = require("../../lib/constants");
|
|
15
|
+
const { validateEvent } = require("../../lib/schemas");
|
|
16
|
+
|
|
17
|
+
let tmpDir;
|
|
18
|
+
|
|
19
|
+
function makeFile(rel, content, mtimeMs) {
|
|
20
|
+
const p = join(tmpDir, rel);
|
|
21
|
+
mkdirSync(join(tmpDir, rel, ".."), { recursive: true });
|
|
22
|
+
writeFileSync(p, content, "utf-8");
|
|
23
|
+
if (mtimeMs) utimesSync(p, mtimeMs / 1000, mtimeMs / 1000);
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpDir = mkdtempSync(join(tmpdir(), "local-files-test-"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("LocalFilesAdapter — contract + identity", () => {
|
|
36
|
+
it("conforms to PersonalDataAdapter contract", () => {
|
|
37
|
+
expect(assertAdapter(new LocalFilesAdapter())).toEqual({ ok: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("identifies as local-files with sync:local-file-walk capability", () => {
|
|
41
|
+
const a = new LocalFilesAdapter();
|
|
42
|
+
expect(a.name).toBe(LOCAL_FILES_NAME);
|
|
43
|
+
expect(a.name).toBe("local-files");
|
|
44
|
+
expect(a.version).toBe(LOCAL_FILES_VERSION);
|
|
45
|
+
expect(a.capabilities).toContain("sync:local-file-walk");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("LocalFilesAdapter.sync", () => {
|
|
50
|
+
it("yields one row per file across multiple roots", async () => {
|
|
51
|
+
const r1 = join(tmpDir, "root1");
|
|
52
|
+
const r2 = join(tmpDir, "root2");
|
|
53
|
+
mkdirSync(r1);
|
|
54
|
+
mkdirSync(r2);
|
|
55
|
+
makeFile("root1/a.txt", "a", 1_700_000_001_000);
|
|
56
|
+
makeFile("root1/b.md", "b", 1_700_000_002_000);
|
|
57
|
+
makeFile("root2/c.pdf", "c", 1_700_000_003_000);
|
|
58
|
+
const a = new LocalFilesAdapter({ roots: [r1, r2] });
|
|
59
|
+
const raws = [];
|
|
60
|
+
for await (const r of a.sync()) raws.push(r);
|
|
61
|
+
expect(raws).toHaveLength(3);
|
|
62
|
+
expect(raws.every((r) => r.kind === "local-file")).toBe(true);
|
|
63
|
+
const names = raws.map((r) => r.payload.name).sort();
|
|
64
|
+
expect(names).toEqual(["a.txt", "b.md", "c.pdf"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("walks subdirectories within maxDepth", async () => {
|
|
68
|
+
const r = join(tmpDir, "r");
|
|
69
|
+
mkdirSync(r);
|
|
70
|
+
makeFile("r/sub/nested/deep.txt", "x", 1_700_000_001_000);
|
|
71
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
72
|
+
const raws = [];
|
|
73
|
+
for await (const x of a.sync()) raws.push(x);
|
|
74
|
+
expect(raws).toHaveLength(1);
|
|
75
|
+
expect(raws[0].payload.name).toBe("deep.txt");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("excludes xwechat_files / WXWork / node_modules / .git by default", async () => {
|
|
79
|
+
const r = join(tmpDir, "r");
|
|
80
|
+
mkdirSync(r);
|
|
81
|
+
makeFile("r/normal.txt", "ok", 1_700_000_001_000);
|
|
82
|
+
makeFile("r/xwechat_files/x.txt", "skip", 1_700_000_001_000);
|
|
83
|
+
makeFile("r/WXWork/y.txt", "skip", 1_700_000_001_000);
|
|
84
|
+
makeFile("r/node_modules/lib/index.js", "skip", 1_700_000_001_000);
|
|
85
|
+
makeFile("r/.git/config", "skip", 1_700_000_001_000);
|
|
86
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
87
|
+
const raws = [];
|
|
88
|
+
for await (const x of a.sync()) raws.push(x);
|
|
89
|
+
expect(raws.map((r) => r.payload.name)).toEqual(["normal.txt"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("custom excludes override defaults", async () => {
|
|
93
|
+
const r = join(tmpDir, "r");
|
|
94
|
+
mkdirSync(r);
|
|
95
|
+
makeFile("r/.git/config", "kept-now", 1_700_000_001_000);
|
|
96
|
+
makeFile("r/build/out.txt", "skipped-now", 1_700_000_001_000);
|
|
97
|
+
const a = new LocalFilesAdapter({ roots: [r], excludes: ["build"] });
|
|
98
|
+
const raws = [];
|
|
99
|
+
for await (const x of a.sync()) raws.push(x);
|
|
100
|
+
// .git is hidden (leading '.') so still skipped even without default rule
|
|
101
|
+
expect(raws.map((r) => r.payload.name)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("skips hidden files / dirs (leading '.')", async () => {
|
|
105
|
+
const r = join(tmpDir, "r");
|
|
106
|
+
mkdirSync(r);
|
|
107
|
+
makeFile("r/.hidden", "skip", 1_700_000_001_000);
|
|
108
|
+
makeFile("r/.cache/sub.txt", "skip", 1_700_000_001_000);
|
|
109
|
+
makeFile("r/visible.txt", "ok", 1_700_000_001_000);
|
|
110
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
111
|
+
const raws = [];
|
|
112
|
+
for await (const x of a.sync()) raws.push(x);
|
|
113
|
+
expect(raws.map((r) => r.payload.name)).toEqual(["visible.txt"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("respects since filter (file mtime granularity)", async () => {
|
|
117
|
+
const r = join(tmpDir, "r");
|
|
118
|
+
mkdirSync(r);
|
|
119
|
+
makeFile("r/old.txt", "x", 1_700_000_001_000);
|
|
120
|
+
makeFile("r/new.txt", "x", 1_700_000_005_000);
|
|
121
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
122
|
+
const raws = [];
|
|
123
|
+
for await (const x of a.sync({ since: 1_700_000_003_000 })) raws.push(x);
|
|
124
|
+
expect(raws.map((r) => r.payload.name)).toEqual(["new.txt"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("respects limit", async () => {
|
|
128
|
+
const r = join(tmpDir, "r");
|
|
129
|
+
mkdirSync(r);
|
|
130
|
+
for (let i = 0; i < 5; i++) makeFile(`r/f${i}.txt`, "x", 1_700_000_001_000);
|
|
131
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
132
|
+
const raws = [];
|
|
133
|
+
for await (const x of a.sync({ limit: 2 })) raws.push(x);
|
|
134
|
+
expect(raws).toHaveLength(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("missing roots silently produce nothing", async () => {
|
|
138
|
+
const a = new LocalFilesAdapter({ roots: [join(tmpDir, "nonexistent")] });
|
|
139
|
+
const raws = [];
|
|
140
|
+
for await (const x of a.sync()) raws.push(x);
|
|
141
|
+
expect(raws).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("originalId encodes path + mtime so re-saved files dedupe per mtime", async () => {
|
|
145
|
+
const r = join(tmpDir, "r");
|
|
146
|
+
mkdirSync(r);
|
|
147
|
+
const p = makeFile("r/a.txt", "x", 1_700_000_001_000);
|
|
148
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
149
|
+
const first = [];
|
|
150
|
+
for await (const x of a.sync()) first.push(x);
|
|
151
|
+
expect(first[0].originalId).toBe(`local-file:${p}:1700000001000`);
|
|
152
|
+
// change mtime -> new originalId so adapter does not collapse history
|
|
153
|
+
utimesSync(p, 1_700_000_009 / 1000, 1_700_000_009);
|
|
154
|
+
const second = [];
|
|
155
|
+
for await (const x of a.sync()) second.push(x);
|
|
156
|
+
expect(second[0].originalId).not.toBe(first[0].originalId);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("LocalFilesAdapter.authenticate + healthCheck", () => {
|
|
161
|
+
it("authenticate returns ok=true with resolved roots when defaults exist", async () => {
|
|
162
|
+
const r = join(tmpDir, "r");
|
|
163
|
+
mkdirSync(r);
|
|
164
|
+
const a = new LocalFilesAdapter({ roots: [r] });
|
|
165
|
+
const result = await a.authenticate();
|
|
166
|
+
expect(result.ok).toBe(true);
|
|
167
|
+
expect(result.mode).toBe("file-import");
|
|
168
|
+
expect(result.roots).toEqual([r]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("authenticate returns NO_DATA_ROOTS when roots is empty", async () => {
|
|
172
|
+
const a = new LocalFilesAdapter({ roots: [] });
|
|
173
|
+
// Constructor coerces empty array to override=null, so defaults kick in.
|
|
174
|
+
// Force empty roots via context arg path is not exposed; verify the
|
|
175
|
+
// adapter at least returns ok when defaults present. This test verifies
|
|
176
|
+
// the no-roots branch by stubbing defaultRoots to return [].
|
|
177
|
+
a._deps.defaultRoots = () => [];
|
|
178
|
+
const result = await a.authenticate();
|
|
179
|
+
expect(result.ok).toBe(false);
|
|
180
|
+
expect(result.reason).toBe("NO_DATA_ROOTS");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("healthCheck always returns ok=true (file-import adapter has no live dep)", async () => {
|
|
184
|
+
const a = new LocalFilesAdapter();
|
|
185
|
+
const r = await a.healthCheck();
|
|
186
|
+
expect(r.ok).toBe(true);
|
|
187
|
+
expect(typeof r.lastChecked).toBe("number");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("LocalFilesAdapter.normalize", () => {
|
|
192
|
+
it("maps local-file → Event(OTHER) with [file] title prefix", () => {
|
|
193
|
+
const a = new LocalFilesAdapter();
|
|
194
|
+
const { events } = a.normalize({
|
|
195
|
+
kind: "local-file",
|
|
196
|
+
originalId: "local-file:/home/u/Documents/report.pdf:1700000005000",
|
|
197
|
+
capturedAt: 1_700_000_010_000,
|
|
198
|
+
payload: {
|
|
199
|
+
path: "/home/u/Documents/report.pdf",
|
|
200
|
+
name: "report.pdf",
|
|
201
|
+
ext: "pdf",
|
|
202
|
+
size: 4096,
|
|
203
|
+
mtimeMs: 1_700_000_005_000,
|
|
204
|
+
root: "/home/u/Documents",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(events).toHaveLength(1);
|
|
208
|
+
const e = events[0];
|
|
209
|
+
expect(e.subtype).toBe(EVENT_SUBTYPES.OTHER);
|
|
210
|
+
expect(e.actor).toBe("self");
|
|
211
|
+
expect(e.content.title).toBe("[file] report.pdf");
|
|
212
|
+
expect(e.content.text).toBe("/home/u/Documents/report.pdf");
|
|
213
|
+
expect(e.occurredAt).toBe(1_700_000_005_000);
|
|
214
|
+
expect(e.extra.kind).toBe("local-file");
|
|
215
|
+
expect(e.extra.ext).toBe("pdf");
|
|
216
|
+
expect(e.extra.size).toBe(4096);
|
|
217
|
+
expect(validateEvent(e).valid).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("truncates long filenames in title (keeps full path in text)", () => {
|
|
221
|
+
const a = new LocalFilesAdapter();
|
|
222
|
+
const longName = "x".repeat(300) + ".txt";
|
|
223
|
+
const { events } = a.normalize({
|
|
224
|
+
kind: "local-file",
|
|
225
|
+
originalId: "local-file:/r/big.txt:1700000000000",
|
|
226
|
+
capturedAt: 1_700_000_000_000,
|
|
227
|
+
payload: {
|
|
228
|
+
path: "/r/" + longName,
|
|
229
|
+
name: longName,
|
|
230
|
+
ext: "txt",
|
|
231
|
+
size: 0,
|
|
232
|
+
mtimeMs: 1_700_000_000_000,
|
|
233
|
+
root: "/r",
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
expect(events[0].content.title.length).toBeLessThanOrEqual(101);
|
|
237
|
+
expect(events[0].content.title.endsWith("…")).toBe(true);
|
|
238
|
+
expect(events[0].content.text).toContain(longName);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("uses '(无名)' for empty filename", () => {
|
|
242
|
+
const a = new LocalFilesAdapter();
|
|
243
|
+
const { events } = a.normalize({
|
|
244
|
+
kind: "local-file",
|
|
245
|
+
originalId: "local-file::1700000000000",
|
|
246
|
+
capturedAt: 1_700_000_000_000,
|
|
247
|
+
payload: {
|
|
248
|
+
path: "",
|
|
249
|
+
name: "",
|
|
250
|
+
ext: "",
|
|
251
|
+
size: 0,
|
|
252
|
+
mtimeMs: 1_700_000_000_000,
|
|
253
|
+
root: "",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
expect(events[0].content.title).toBe("[file] (无名)");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("throws on unknown raw.kind", () => {
|
|
260
|
+
expect(() => new LocalFilesAdapter().normalize({ kind: "bogus" })).toThrow(
|
|
261
|
+
/unknown raw\.kind=bogus/,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
ShellHistoryAdapter,
|
|
10
|
+
SHELL_HISTORY_NAME,
|
|
11
|
+
SHELL_HISTORY_VERSION,
|
|
12
|
+
} = require("../../lib/adapters/shell-history");
|
|
13
|
+
const { assertAdapter } = require("../../lib/adapter-spec");
|
|
14
|
+
const { EVENT_SUBTYPES } = require("../../lib/constants");
|
|
15
|
+
const { validateEvent } = require("../../lib/schemas");
|
|
16
|
+
|
|
17
|
+
let tmpDir;
|
|
18
|
+
|
|
19
|
+
function makeHistFile(name, lines, mtimeMs) {
|
|
20
|
+
const p = join(tmpDir, name);
|
|
21
|
+
writeFileSync(p, lines.join("\n") + "\n", "utf-8");
|
|
22
|
+
if (mtimeMs) utimesSync(p, mtimeMs / 1000, mtimeMs / 1000);
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpDir = mkdtempSync(join(tmpdir(), "shell-hist-test-"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("ShellHistoryAdapter — contract + identity", () => {
|
|
35
|
+
it("conforms to PersonalDataAdapter contract", () => {
|
|
36
|
+
expect(assertAdapter(new ShellHistoryAdapter())).toEqual({ ok: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("identifies as shell-history with sync:shell-history-files capability", () => {
|
|
40
|
+
const a = new ShellHistoryAdapter();
|
|
41
|
+
expect(a.name).toBe(SHELL_HISTORY_NAME);
|
|
42
|
+
expect(a.name).toBe("shell-history");
|
|
43
|
+
expect(a.version).toBe(SHELL_HISTORY_VERSION);
|
|
44
|
+
expect(a.capabilities).toContain("sync:shell-history-files");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("ShellHistoryAdapter.sync", () => {
|
|
49
|
+
it("yields one row per non-blank line per configured shell", async () => {
|
|
50
|
+
const pwshFile = makeHistFile("pwsh.txt", ["ls", "git status", "", "npm test"], 1_700_000_010_000);
|
|
51
|
+
const bashFile = makeHistFile("bash.txt", ["cd /tmp", "make"], 1_700_000_020_000);
|
|
52
|
+
const a = new ShellHistoryAdapter({
|
|
53
|
+
sources: [
|
|
54
|
+
{ shell: "pwsh", file: pwshFile },
|
|
55
|
+
{ shell: "bash", file: bashFile },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
const raws = [];
|
|
59
|
+
for await (const r of a.sync()) raws.push(r);
|
|
60
|
+
expect(raws).toHaveLength(5); // 3 pwsh (blank skipped) + 2 bash
|
|
61
|
+
expect(raws[0].payload.shell).toBe("pwsh");
|
|
62
|
+
expect(raws[0].payload.value).toBe("ls");
|
|
63
|
+
expect(raws[0].payload.snapshotTs).toBe(1_700_000_010_000);
|
|
64
|
+
expect(raws[3].payload.shell).toBe("bash");
|
|
65
|
+
expect(raws[3].payload.value).toBe("cd /tmp");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("strips zsh extended history prefix", async () => {
|
|
69
|
+
const zshFile = makeHistFile(
|
|
70
|
+
"zsh.txt",
|
|
71
|
+
[
|
|
72
|
+
": 1700000001:0;ls -la",
|
|
73
|
+
": 1700000002:5;npm install",
|
|
74
|
+
"plain-line-without-prefix",
|
|
75
|
+
],
|
|
76
|
+
1_700_000_030_000,
|
|
77
|
+
);
|
|
78
|
+
const a = new ShellHistoryAdapter({ sources: [{ shell: "zsh", file: zshFile }] });
|
|
79
|
+
const raws = [];
|
|
80
|
+
for await (const r of a.sync()) raws.push(r);
|
|
81
|
+
expect(raws.map((r) => r.payload.value)).toEqual([
|
|
82
|
+
"ls -la",
|
|
83
|
+
"npm install",
|
|
84
|
+
"plain-line-without-prefix",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("originalId disambiguates same command at different sourceIndex", async () => {
|
|
89
|
+
const f = makeHistFile("h.txt", ["ls", "ls", "ls"], 1_700_000_001_000);
|
|
90
|
+
const a = new ShellHistoryAdapter({ sources: [{ shell: "bash", file: f }] });
|
|
91
|
+
const raws = [];
|
|
92
|
+
for await (const r of a.sync()) raws.push(r);
|
|
93
|
+
const ids = raws.map((r) => r.originalId);
|
|
94
|
+
expect(new Set(ids).size).toBe(3);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("respects since filter (file mtime granularity)", async () => {
|
|
98
|
+
const oldF = makeHistFile("old.txt", ["a"], 1_700_000_001_000);
|
|
99
|
+
const newF = makeHistFile("new.txt", ["b"], 1_700_000_005_000);
|
|
100
|
+
const a = new ShellHistoryAdapter({
|
|
101
|
+
sources: [
|
|
102
|
+
{ shell: "pwsh", file: oldF },
|
|
103
|
+
{ shell: "bash", file: newF },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
const raws = [];
|
|
107
|
+
for await (const r of a.sync({ since: 1_700_000_003_000 })) raws.push(r);
|
|
108
|
+
expect(raws.map((r) => r.payload.value)).toEqual(["b"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("respects limit", async () => {
|
|
112
|
+
const f = makeHistFile("h.txt", ["1", "2", "3", "4", "5"], 1_700_000_001_000);
|
|
113
|
+
const a = new ShellHistoryAdapter({ sources: [{ shell: "pwsh", file: f }] });
|
|
114
|
+
const raws = [];
|
|
115
|
+
for await (const r of a.sync({ limit: 2 })) raws.push(r);
|
|
116
|
+
expect(raws).toHaveLength(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("missing files silently produce nothing", async () => {
|
|
120
|
+
const a = new ShellHistoryAdapter({
|
|
121
|
+
sources: [{ shell: "pwsh", file: join(tmpDir, "nonexistent.txt") }],
|
|
122
|
+
});
|
|
123
|
+
const raws = [];
|
|
124
|
+
for await (const r of a.sync()) raws.push(r);
|
|
125
|
+
expect(raws).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("ShellHistoryAdapter.normalize", () => {
|
|
130
|
+
it("maps shell-command → Event(OTHER) with [shell] title prefix", () => {
|
|
131
|
+
const a = new ShellHistoryAdapter();
|
|
132
|
+
const { events } = a.normalize({
|
|
133
|
+
kind: "shell-command",
|
|
134
|
+
originalId: "shell-cmd:bash:0:abc",
|
|
135
|
+
capturedAt: 1_700_000_005_000,
|
|
136
|
+
payload: {
|
|
137
|
+
shell: "bash",
|
|
138
|
+
file: "/home/u/.bash_history",
|
|
139
|
+
value: "git status",
|
|
140
|
+
sourceIndex: 0,
|
|
141
|
+
snapshotTs: 1_700_000_001_000,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
expect(events).toHaveLength(1);
|
|
145
|
+
const e = events[0];
|
|
146
|
+
expect(e.subtype).toBe(EVENT_SUBTYPES.OTHER);
|
|
147
|
+
expect(e.actor).toBe("self");
|
|
148
|
+
expect(e.content.title).toBe("[bash] git status");
|
|
149
|
+
expect(e.content.text).toBe("git status");
|
|
150
|
+
expect(e.occurredAt).toBe(1_700_000_001_000);
|
|
151
|
+
expect(e.extra.kind).toBe("shell-command");
|
|
152
|
+
expect(e.extra.shell).toBe("bash");
|
|
153
|
+
expect(validateEvent(e).valid).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("truncates long commands in title (keeps full text)", () => {
|
|
157
|
+
const a = new ShellHistoryAdapter();
|
|
158
|
+
const longCmd = "echo " + "x".repeat(300);
|
|
159
|
+
const { events } = a.normalize({
|
|
160
|
+
kind: "shell-command",
|
|
161
|
+
capturedAt: 1_700_000_000_000,
|
|
162
|
+
originalId: "shell-cmd:pwsh:0:long",
|
|
163
|
+
payload: {
|
|
164
|
+
shell: "pwsh",
|
|
165
|
+
value: longCmd,
|
|
166
|
+
sourceIndex: 0,
|
|
167
|
+
snapshotTs: 1_700_000_000_000,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
expect(events[0].content.title.length).toBeLessThanOrEqual(101);
|
|
171
|
+
expect(events[0].content.title.endsWith("…")).toBe(true);
|
|
172
|
+
expect(events[0].content.text).toBe(longCmd);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws on unknown raw.kind", () => {
|
|
176
|
+
expect(() => new ShellHistoryAdapter().normalize({ kind: "bogus" })).toThrow(
|
|
177
|
+
/unknown raw\.kind=bogus/,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|