@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.13

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 (54) hide show
  1. package/dist/active-skills.js +67 -0
  2. package/dist/active-skills.test.js +29 -0
  3. package/dist/config-sync.js +439 -0
  4. package/dist/config-sync.test.js +145 -0
  5. package/dist/hooks.js +337 -0
  6. package/dist/hooks.test.js +123 -0
  7. package/dist/http.js +54 -0
  8. package/dist/identity.js +56 -0
  9. package/dist/index.js +240 -78
  10. package/dist/index.test.js +39 -0
  11. package/dist/integration.test.js +102 -0
  12. package/dist/matcher.js +362 -0
  13. package/dist/matcher.test.js +139 -0
  14. package/dist/paths.js +62 -0
  15. package/dist/paths.test.js +49 -0
  16. package/dist/reporter.js +267 -0
  17. package/dist/reporter.test.js +128 -0
  18. package/dist/semver.js +64 -0
  19. package/dist/semver.test.js +21 -0
  20. package/dist/skill-version.js +23 -0
  21. package/dist/types.js +9 -0
  22. package/dist/updater.js +352 -0
  23. package/dist/updater.test.js +212 -0
  24. package/dist/ws-client.js +484 -0
  25. package/openclaw.plugin.json +50 -50
  26. package/package.json +37 -37
  27. package/src/active-skills.test.ts +32 -32
  28. package/src/active-skills.ts +77 -77
  29. package/src/config-sync.test.ts +165 -165
  30. package/src/config-sync.ts +544 -544
  31. package/src/hooks.test.ts +251 -251
  32. package/src/hooks.ts +517 -517
  33. package/src/http.ts +61 -61
  34. package/src/identity.ts +64 -64
  35. package/src/index.test.ts +53 -53
  36. package/src/index.ts +226 -226
  37. package/src/integration.test.ts +119 -119
  38. package/src/matcher.test.ts +170 -170
  39. package/src/matcher.ts +393 -393
  40. package/src/paths.test.ts +57 -57
  41. package/src/paths.ts +84 -84
  42. package/src/reporter.test.ts +139 -139
  43. package/src/reporter.ts +298 -298
  44. package/src/sample-config.json +72 -72
  45. package/src/semver.test.ts +23 -23
  46. package/src/semver.ts +60 -60
  47. package/src/skill-version.ts +53 -53
  48. package/src/types.ts +198 -198
  49. package/src/updater.test.ts +325 -237
  50. package/src/updater.ts +549 -433
  51. package/src/ws-client.test.ts +48 -37
  52. package/src/ws-client.ts +717 -642
  53. package/test-ws.ts +17 -17
  54. package/tsconfig.json +14 -14
package/src/paths.test.ts CHANGED
@@ -1,57 +1,57 @@
1
- import { describe, it, beforeEach, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- import os from "node:os";
6
- import { resolveAgentSkillDirs } from "./paths.ts";
7
-
8
- let home: string;
9
-
10
- beforeEach(async () => {
11
- home = path.join(os.tmpdir(), `slp-paths-${Date.now()}-${Math.random().toString(36).slice(2)}`);
12
- await fs.mkdir(home, { recursive: true });
13
- });
14
-
15
- afterEach(async () => {
16
- await fs.rm(home, { recursive: true, force: true });
17
- });
18
-
19
- describe("resolveAgentSkillDirs", () => {
20
- it("从 openclaw.json 收集各 agent workspace 的 skills 目录(含默认与顶层)", async () => {
21
- const cfg = {
22
- agents: {
23
- defaults: { workspace: path.join(home, "workspace") },
24
- list: [
25
- { id: "main" }, // 无 workspace → 落到默认
26
- { id: "coder", workspace: path.join(home, "workspace-coder") },
27
- { id: "proj", workspace: path.join(home, "agency-agents", "proj") },
28
- ],
29
- },
30
- };
31
- const configPath = path.join(home, "openclaw.json");
32
- await fs.writeFile(configPath, JSON.stringify(cfg));
33
-
34
- const dirs = resolveAgentSkillDirs(home, configPath);
35
-
36
- assert.ok(dirs.includes(path.join(home, "skills")), "应含顶层全局 skills");
37
- assert.ok(dirs.includes(path.join(home, "workspace", "skills")), "应含默认 workspace skills");
38
- assert.ok(dirs.includes(path.join(home, "workspace-coder", "skills")), "应含 coder workspace skills");
39
- assert.ok(dirs.includes(path.join(home, "agency-agents", "proj", "skills")), "应含 proj workspace skills");
40
- // 去重:main 落到默认 workspace,不应产生重复项
41
- assert.equal(new Set(dirs).size, dirs.length);
42
- });
43
-
44
- it("openclaw.json 缺失时回退到顶层 skills + 默认 workspace skills,且不抛", async () => {
45
- const dirs = resolveAgentSkillDirs(home, path.join(home, "does-not-exist.json"));
46
- assert.ok(dirs.includes(path.join(home, "skills")));
47
- assert.ok(dirs.includes(path.join(home, "workspace", "skills")));
48
- });
49
-
50
- it("非法 JSON 不抛,回退到默认目录", async () => {
51
- const configPath = path.join(home, "openclaw.json");
52
- await fs.writeFile(configPath, "{ not valid json ");
53
- const dirs = resolveAgentSkillDirs(home, configPath);
54
- assert.ok(dirs.includes(path.join(home, "skills")));
55
- assert.ok(dirs.includes(path.join(home, "workspace", "skills")));
56
- });
57
- });
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { resolveAgentSkillDirs } from "./paths.ts";
7
+
8
+ let home: string;
9
+
10
+ beforeEach(async () => {
11
+ home = path.join(os.tmpdir(), `slp-paths-${Date.now()}-${Math.random().toString(36).slice(2)}`);
12
+ await fs.mkdir(home, { recursive: true });
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await fs.rm(home, { recursive: true, force: true });
17
+ });
18
+
19
+ describe("resolveAgentSkillDirs", () => {
20
+ it("从 openclaw.json 收集各 agent workspace 的 skills 目录(含默认与顶层)", async () => {
21
+ const cfg = {
22
+ agents: {
23
+ defaults: { workspace: path.join(home, "workspace") },
24
+ list: [
25
+ { id: "main" }, // 无 workspace → 落到默认
26
+ { id: "coder", workspace: path.join(home, "workspace-coder") },
27
+ { id: "proj", workspace: path.join(home, "agency-agents", "proj") },
28
+ ],
29
+ },
30
+ };
31
+ const configPath = path.join(home, "openclaw.json");
32
+ await fs.writeFile(configPath, JSON.stringify(cfg));
33
+
34
+ const dirs = resolveAgentSkillDirs(home, configPath);
35
+
36
+ assert.ok(dirs.includes(path.join(home, "skills")), "应含顶层全局 skills");
37
+ assert.ok(dirs.includes(path.join(home, "workspace", "skills")), "应含默认 workspace skills");
38
+ assert.ok(dirs.includes(path.join(home, "workspace-coder", "skills")), "应含 coder workspace skills");
39
+ assert.ok(dirs.includes(path.join(home, "agency-agents", "proj", "skills")), "应含 proj workspace skills");
40
+ // 去重:main 落到默认 workspace,不应产生重复项
41
+ assert.equal(new Set(dirs).size, dirs.length);
42
+ });
43
+
44
+ it("openclaw.json 缺失时回退到顶层 skills + 默认 workspace skills,且不抛", async () => {
45
+ const dirs = resolveAgentSkillDirs(home, path.join(home, "does-not-exist.json"));
46
+ assert.ok(dirs.includes(path.join(home, "skills")));
47
+ assert.ok(dirs.includes(path.join(home, "workspace", "skills")));
48
+ });
49
+
50
+ it("非法 JSON 不抛,回退到默认目录", async () => {
51
+ const configPath = path.join(home, "openclaw.json");
52
+ await fs.writeFile(configPath, "{ not valid json ");
53
+ const dirs = resolveAgentSkillDirs(home, configPath);
54
+ assert.ok(dirs.includes(path.join(home, "skills")));
55
+ assert.ok(dirs.includes(path.join(home, "workspace", "skills")));
56
+ });
57
+ });
package/src/paths.ts CHANGED
@@ -1,84 +1,84 @@
1
- /**
2
- * 集中管理插件用到的所有文件系统路径。
3
- *
4
- * 为什么集中:① 单测可注入临时目录;② openclaw 安全策略下插件不读环境变量,
5
- * 路径在此固定,运行时不依赖外部配置。
6
- */
7
- import fs from "node:fs";
8
- import path from "node:path";
9
- import os from "node:os";
10
-
11
- /** 所有可注入路径的集合;不传时回落到 `~/.openclaw` 下的默认位置。 */
12
- export type PluginPaths = {
13
- /** 事件日志队列:待上报事件追加写入;成功上报后清理已确认行。 */
14
- eventsLogPath: string;
15
- /** 配置同步状态:已装 skill 签名 + 缓存的标准配置。 */
16
- syncStatePath: string;
17
- /** 更新冷却期状态:skill@version → 上次尝试时间戳。 */
18
- cooldownStatePath: string;
19
- /** openclaw 扩展安装目录,用于扫描已装 skill。 */
20
- extensionsDir: string;
21
- /** openclaw 主配置文件路径(含 agents 列表),用于动态解析各 agent workspace 的 skills 目录。 */
22
- openclawConfigPath: string;
23
- };
24
-
25
- /** 默认根目录 `~/.openclaw`。 */
26
- export function openclawHome(): string {
27
- return path.join(os.homedir(), ".openclaw");
28
- }
29
-
30
- /**
31
- * 解析所有 agent workspace 下的 skill 扫描目录。
32
- *
33
- * 来源:
34
- * - 顶层全局 skills:`<home>/skills`
35
- * - 默认 workspace:`<agents.defaults.workspace>/skills`(缺省为 `<home>/workspace`)
36
- * - 各 agent:`<agents.list[].workspace>/skills`(未显式配置 workspace 的 agent 落到默认)
37
- *
38
- * openclaw.json 不存在 / 解析失败 / 字段缺失时,回退到「顶层 skills + 默认 workspace skills」,
39
- * 至少不弱于历史行为,且整段被 try/catch 包裹,绝不抛。
40
- */
41
- export function resolveAgentSkillDirs(home: string, configPath: string): string[] {
42
- const dirs = new Set<string>();
43
- // 永远纳入的兜底目录(即便 openclaw.json 缺失也能工作)。
44
- dirs.add(path.join(home, "skills"));
45
- const defaultWorkspaceFallback = path.join(home, "workspace");
46
- dirs.add(path.join(defaultWorkspaceFallback, "skills"));
47
-
48
- try {
49
- const raw = fs.readFileSync(configPath, "utf-8");
50
- const cfg = JSON.parse(raw) as {
51
- agents?: { defaults?: { workspace?: unknown }; list?: Array<{ workspace?: unknown }> };
52
- };
53
- const agents = cfg?.agents;
54
- const defaultWs =
55
- typeof agents?.defaults?.workspace === "string" ? agents.defaults.workspace : defaultWorkspaceFallback;
56
- dirs.add(path.join(defaultWs, "skills"));
57
-
58
- const list = Array.isArray(agents?.list) ? agents!.list! : [];
59
- for (const a of list) {
60
- const ws = typeof a?.workspace === "string" ? a.workspace : defaultWs;
61
- dirs.add(path.join(ws, "skills"));
62
- }
63
- } catch {
64
- // openclaw.json 不存在 / 非法 JSON / 权限不足 → 用上面的兜底目录,绝不影响插件启动。
65
- }
66
-
67
- return [...dirs];
68
- }
69
-
70
- /**
71
- * 生成一组路径。`overrides` 仅供测试注入(如指向临时目录)。
72
- */
73
- export function resolvePaths(overrides?: Partial<PluginPaths>): PluginPaths {
74
- const home = openclawHome();
75
- const logsDir = path.join(home, "logs");
76
- return {
77
- eventsLogPath: path.join(logsDir, "skill-logger-plugin.jsonl"),
78
- syncStatePath: path.join(logsDir, "skill-logger-plugin.sync.json"),
79
- cooldownStatePath: path.join(logsDir, "skill-logger-plugin.cooldown.json"),
80
- extensionsDir: path.join(home, "extensions"),
81
- openclawConfigPath: path.join(home, "openclaw.json"),
82
- ...overrides,
83
- };
84
- }
1
+ /**
2
+ * 集中管理插件用到的所有文件系统路径。
3
+ *
4
+ * 为什么集中:① 单测可注入临时目录;② openclaw 安全策略下插件不读环境变量,
5
+ * 路径在此固定,运行时不依赖外部配置。
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ /** 所有可注入路径的集合;不传时回落到 `~/.openclaw` 下的默认位置。 */
12
+ export type PluginPaths = {
13
+ /** 事件日志队列:待上报事件追加写入;成功上报后清理已确认行。 */
14
+ eventsLogPath: string;
15
+ /** 配置同步状态:已装 skill 签名 + 缓存的标准配置。 */
16
+ syncStatePath: string;
17
+ /** 更新冷却期状态:skill@version → 上次尝试时间戳。 */
18
+ cooldownStatePath: string;
19
+ /** openclaw 扩展安装目录,用于扫描已装 skill。 */
20
+ extensionsDir: string;
21
+ /** openclaw 主配置文件路径(含 agents 列表),用于动态解析各 agent workspace 的 skills 目录。 */
22
+ openclawConfigPath: string;
23
+ };
24
+
25
+ /** 默认根目录 `~/.openclaw`。 */
26
+ export function openclawHome(): string {
27
+ return path.join(os.homedir(), ".openclaw");
28
+ }
29
+
30
+ /**
31
+ * 解析所有 agent workspace 下的 skill 扫描目录。
32
+ *
33
+ * 来源:
34
+ * - 顶层全局 skills:`<home>/skills`
35
+ * - 默认 workspace:`<agents.defaults.workspace>/skills`(缺省为 `<home>/workspace`)
36
+ * - 各 agent:`<agents.list[].workspace>/skills`(未显式配置 workspace 的 agent 落到默认)
37
+ *
38
+ * openclaw.json 不存在 / 解析失败 / 字段缺失时,回退到「顶层 skills + 默认 workspace skills」,
39
+ * 至少不弱于历史行为,且整段被 try/catch 包裹,绝不抛。
40
+ */
41
+ export function resolveAgentSkillDirs(home: string, configPath: string): string[] {
42
+ const dirs = new Set<string>();
43
+ // 永远纳入的兜底目录(即便 openclaw.json 缺失也能工作)。
44
+ dirs.add(path.join(home, "skills"));
45
+ const defaultWorkspaceFallback = path.join(home, "workspace");
46
+ dirs.add(path.join(defaultWorkspaceFallback, "skills"));
47
+
48
+ try {
49
+ const raw = fs.readFileSync(configPath, "utf-8");
50
+ const cfg = JSON.parse(raw) as {
51
+ agents?: { defaults?: { workspace?: unknown }; list?: Array<{ workspace?: unknown }> };
52
+ };
53
+ const agents = cfg?.agents;
54
+ const defaultWs =
55
+ typeof agents?.defaults?.workspace === "string" ? agents.defaults.workspace : defaultWorkspaceFallback;
56
+ dirs.add(path.join(defaultWs, "skills"));
57
+
58
+ const list = Array.isArray(agents?.list) ? agents!.list! : [];
59
+ for (const a of list) {
60
+ const ws = typeof a?.workspace === "string" ? a.workspace : defaultWs;
61
+ dirs.add(path.join(ws, "skills"));
62
+ }
63
+ } catch {
64
+ // openclaw.json 不存在 / 非法 JSON / 权限不足 → 用上面的兜底目录,绝不影响插件启动。
65
+ }
66
+
67
+ return [...dirs];
68
+ }
69
+
70
+ /**
71
+ * 生成一组路径。`overrides` 仅供测试注入(如指向临时目录)。
72
+ */
73
+ export function resolvePaths(overrides?: Partial<PluginPaths>): PluginPaths {
74
+ const home = openclawHome();
75
+ const logsDir = path.join(home, "logs");
76
+ return {
77
+ eventsLogPath: path.join(logsDir, "skill-logger-plugin.jsonl"),
78
+ syncStatePath: path.join(logsDir, "skill-logger-plugin.sync.json"),
79
+ cooldownStatePath: path.join(logsDir, "skill-logger-plugin.cooldown.json"),
80
+ extensionsDir: path.join(home, "extensions"),
81
+ openclawConfigPath: path.join(home, "openclaw.json"),
82
+ ...overrides,
83
+ };
84
+ }
@@ -1,139 +1,139 @@
1
- import { describe, it, beforeEach, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- import os from "node:os";
6
- import { Reporter } from "./reporter.ts";
7
- import type { PluginConfig, SkillEvent } from "./types.ts";
8
-
9
- const identityProvider = {
10
- getIdentity: async () => ({ user_id: "", git_name: "n", git_email: "e", machine_id: "m" }),
11
- };
12
-
13
- function makeEvent(i: number): SkillEvent {
14
- return { event_id: `id${i}`, event_type: "function_call", skill_name: "s", called_at: new Date().toISOString() };
15
- }
16
-
17
- let dir: string;
18
- let paths: { eventsLogPath: string };
19
-
20
- beforeEach(async () => {
21
- dir = path.join(os.tmpdir(), `slp-reporter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
22
- await fs.mkdir(dir, { recursive: true });
23
- paths = {
24
- eventsLogPath: path.join(dir, "events.jsonl"),
25
- };
26
- });
27
-
28
- afterEach(async () => {
29
- await fs.rm(dir, { recursive: true, force: true });
30
- });
31
-
32
- /** 缺文件视为空,匹配轮转模型下「上报成功即删除」的形态。 */
33
- async function readEventsOrEmpty(p: string): Promise<string> {
34
- try {
35
- return await fs.readFile(p, "utf-8");
36
- } catch {
37
- return "";
38
- }
39
- }
40
-
41
- /** 列出已轮转、待上报的日志文件(排除活跃的 events.jsonl)。 */
42
- async function listRotated(d: string): Promise<string[]> {
43
- try {
44
- const entries = await fs.readdir(d);
45
- return entries.filter((f) => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl");
46
- } catch {
47
- return [];
48
- }
49
- }
50
-
51
- describe("Reporter.appendEvent", () => {
52
- it("目录自动创建并追加写", async () => {
53
- const r = new Reporter({ paths, getConfig: () => ({}), identityProvider });
54
- await r.appendEvent(makeEvent(1));
55
- await r.appendEvent(makeEvent(2));
56
- const lines = (await fs.readFile(paths.eventsLogPath, "utf-8")).trim().split("\n");
57
- assert.equal(lines.length, 2);
58
- assert.equal(JSON.parse(lines[1]).event_id, "id2");
59
- });
60
- });
61
-
62
- describe("Reporter.flush", () => {
63
- it("未配置 reportBaseUrl 时不上报", async () => {
64
- let called = 0;
65
- const r = new Reporter({
66
- paths,
67
- getConfig: () => ({}),
68
- identityProvider,
69
- fetchImpl: async () => {
70
- called++;
71
- return { ok: true, status: 200 };
72
- },
73
- });
74
- await r.appendEvent(makeEvent(1));
75
- await r.flush();
76
- assert.equal(called, 0);
77
- });
78
-
79
- it("成功上报后清理本地日志,再次 flush 不重复发", async () => {
80
- const sent: number[] = [];
81
- const config: PluginConfig = { reportBaseUrl: "https://x" };
82
- const r = new Reporter({
83
- paths,
84
- getConfig: () => config,
85
- identityProvider,
86
- fetchImpl: async (_url, init) => {
87
- const body = JSON.parse(String(init.body));
88
- sent.push(body.events.length);
89
- return { ok: true, status: 200 };
90
- },
91
- });
92
- await r.appendEvent(makeEvent(1));
93
- await r.appendEvent(makeEvent(2));
94
- await r.flush();
95
- await r.flush(); // 无新事件
96
- assert.deepEqual(sent, [2]);
97
- // 轮转文件已全部上报并删除,活跃日志清空
98
- assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
99
- assert.equal((await listRotated(dir)).length, 0);
100
- });
101
-
102
- it("保留 flush 期间新追加的事件(轮转隔离读写)", async () => {
103
- let appendedDuringFlush = false;
104
- const r = new Reporter({
105
- paths,
106
- getConfig: () => ({ reportBaseUrl: "https://x" }),
107
- identityProvider,
108
- fetchImpl: async () => {
109
- if (!appendedDuringFlush) {
110
- appendedDuringFlush = true;
111
- await fs.appendFile(paths.eventsLogPath, JSON.stringify(makeEvent(2)) + "\n");
112
- }
113
- return { ok: true, status: 200 };
114
- },
115
- });
116
- await r.appendEvent(makeEvent(1));
117
- await r.flush();
118
- const lines = (await readEventsOrEmpty(paths.eventsLogPath)).trim().split("\n").filter(Boolean);
119
- assert.equal(lines.length, 1);
120
- assert.equal(JSON.parse(lines[0]).event_id, "id2");
121
- });
122
-
123
- it("上报失败保留轮转文件,下轮重试成功后清空", async () => {
124
- let ok = false;
125
- const r = new Reporter({
126
- paths,
127
- getConfig: () => ({ reportBaseUrl: "https://x" }),
128
- identityProvider,
129
- fetchImpl: async () => ({ ok, status: ok ? 200 : 500 }),
130
- });
131
- await r.appendEvent(makeEvent(1));
132
- await r.flush(); // 失败:轮转文件保留待重试
133
- assert.equal((await listRotated(dir)).length, 1);
134
- ok = true;
135
- await r.flush(); // 成功:清空
136
- assert.equal((await listRotated(dir)).length, 0);
137
- assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
138
- });
139
- });
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { Reporter } from "./reporter.ts";
7
+ import type { PluginConfig, SkillEvent } from "./types.ts";
8
+
9
+ const identityProvider = {
10
+ getIdentity: async () => ({ user_id: "", git_name: "n", git_email: "e", machine_id: "m" }),
11
+ };
12
+
13
+ function makeEvent(i: number): SkillEvent {
14
+ return { event_id: `id${i}`, event_type: "function_call", skill_name: "s", called_at: new Date().toISOString() };
15
+ }
16
+
17
+ let dir: string;
18
+ let paths: { eventsLogPath: string };
19
+
20
+ beforeEach(async () => {
21
+ dir = path.join(os.tmpdir(), `slp-reporter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
22
+ await fs.mkdir(dir, { recursive: true });
23
+ paths = {
24
+ eventsLogPath: path.join(dir, "events.jsonl"),
25
+ };
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await fs.rm(dir, { recursive: true, force: true });
30
+ });
31
+
32
+ /** 缺文件视为空,匹配轮转模型下「上报成功即删除」的形态。 */
33
+ async function readEventsOrEmpty(p: string): Promise<string> {
34
+ try {
35
+ return await fs.readFile(p, "utf-8");
36
+ } catch {
37
+ return "";
38
+ }
39
+ }
40
+
41
+ /** 列出已轮转、待上报的日志文件(排除活跃的 events.jsonl)。 */
42
+ async function listRotated(d: string): Promise<string[]> {
43
+ try {
44
+ const entries = await fs.readdir(d);
45
+ return entries.filter((f) => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl");
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ describe("Reporter.appendEvent", () => {
52
+ it("目录自动创建并追加写", async () => {
53
+ const r = new Reporter({ paths, getConfig: () => ({}), identityProvider });
54
+ await r.appendEvent(makeEvent(1));
55
+ await r.appendEvent(makeEvent(2));
56
+ const lines = (await fs.readFile(paths.eventsLogPath, "utf-8")).trim().split("\n");
57
+ assert.equal(lines.length, 2);
58
+ assert.equal(JSON.parse(lines[1]).event_id, "id2");
59
+ });
60
+ });
61
+
62
+ describe("Reporter.flush", () => {
63
+ it("未配置 reportBaseUrl 时不上报", async () => {
64
+ let called = 0;
65
+ const r = new Reporter({
66
+ paths,
67
+ getConfig: () => ({}),
68
+ identityProvider,
69
+ fetchImpl: async () => {
70
+ called++;
71
+ return { ok: true, status: 200 };
72
+ },
73
+ });
74
+ await r.appendEvent(makeEvent(1));
75
+ await r.flush();
76
+ assert.equal(called, 0);
77
+ });
78
+
79
+ it("成功上报后清理本地日志,再次 flush 不重复发", async () => {
80
+ const sent: number[] = [];
81
+ const config: PluginConfig = { reportBaseUrl: "https://x" };
82
+ const r = new Reporter({
83
+ paths,
84
+ getConfig: () => config,
85
+ identityProvider,
86
+ fetchImpl: async (_url, init) => {
87
+ const body = JSON.parse(String(init.body));
88
+ sent.push(body.events.length);
89
+ return { ok: true, status: 200 };
90
+ },
91
+ });
92
+ await r.appendEvent(makeEvent(1));
93
+ await r.appendEvent(makeEvent(2));
94
+ await r.flush();
95
+ await r.flush(); // 无新事件
96
+ assert.deepEqual(sent, [2]);
97
+ // 轮转文件已全部上报并删除,活跃日志清空
98
+ assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
99
+ assert.equal((await listRotated(dir)).length, 0);
100
+ });
101
+
102
+ it("保留 flush 期间新追加的事件(轮转隔离读写)", async () => {
103
+ let appendedDuringFlush = false;
104
+ const r = new Reporter({
105
+ paths,
106
+ getConfig: () => ({ reportBaseUrl: "https://x" }),
107
+ identityProvider,
108
+ fetchImpl: async () => {
109
+ if (!appendedDuringFlush) {
110
+ appendedDuringFlush = true;
111
+ await fs.appendFile(paths.eventsLogPath, JSON.stringify(makeEvent(2)) + "\n");
112
+ }
113
+ return { ok: true, status: 200 };
114
+ },
115
+ });
116
+ await r.appendEvent(makeEvent(1));
117
+ await r.flush();
118
+ const lines = (await readEventsOrEmpty(paths.eventsLogPath)).trim().split("\n").filter(Boolean);
119
+ assert.equal(lines.length, 1);
120
+ assert.equal(JSON.parse(lines[0]).event_id, "id2");
121
+ });
122
+
123
+ it("上报失败保留轮转文件,下轮重试成功后清空", async () => {
124
+ let ok = false;
125
+ const r = new Reporter({
126
+ paths,
127
+ getConfig: () => ({ reportBaseUrl: "https://x" }),
128
+ identityProvider,
129
+ fetchImpl: async () => ({ ok, status: ok ? 200 : 500 }),
130
+ });
131
+ await r.appendEvent(makeEvent(1));
132
+ await r.flush(); // 失败:轮转文件保留待重试
133
+ assert.equal((await listRotated(dir)).length, 1);
134
+ ok = true;
135
+ await r.flush(); // 成功:清空
136
+ assert.equal((await listRotated(dir)).length, 0);
137
+ assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
138
+ });
139
+ });