autocrew 0.1.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 (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,596 @@
1
+ /**
2
+ * End-to-end integration tests — simulates a real user flow through the full plugin.
3
+ */
4
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ import { createContext } from "./runtime/context.js";
9
+ import { ToolRunner } from "./runtime/tool-runner.js";
10
+ import { EventBus } from "./runtime/events.js";
11
+ import { registerAllTools } from "./tools/registry.js";
12
+ import { recordDiff } from "./modules/learnings/diff-tracker.js";
13
+ import { distillRules } from "./modules/learnings/rule-distiller.js";
14
+ import { addWritingRule, loadProfile, saveProfile } from "./modules/profile/creator-profile.js";
15
+
16
+ const TEST_DIR = path.join(os.tmpdir(), `autocrew-e2e-${Date.now()}`);
17
+
18
+ let runner: ToolRunner;
19
+ let eventBus: EventBus;
20
+
21
+ beforeAll(async () => {
22
+ const ctx = createContext({ data_dir: TEST_DIR });
23
+ eventBus = new EventBus();
24
+ runner = new ToolRunner({ ctx, eventBus });
25
+ registerAllTools(runner);
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await fs.rm(TEST_DIR, { recursive: true, force: true }).catch(() => {});
30
+ });
31
+
32
+ describe("E2E: Init + Onboarding", () => {
33
+ it("init creates directory structure and returns next_step", async () => {
34
+ const result = await runner.execute("autocrew_init", {});
35
+ expect(result.ok).toBe(true);
36
+ expect(result.dataDir).toBe(TEST_DIR);
37
+ expect(result.next_step).toBeDefined();
38
+ expect((result.next_step as any).action).toBe("onboarding");
39
+
40
+ const dirs = ["topics", "contents", "covers/templates", "sensitive-words"];
41
+ for (const dir of dirs) {
42
+ const stat = await fs.stat(path.join(TEST_DIR, dir));
43
+ expect(stat.isDirectory()).toBe(true);
44
+ }
45
+
46
+ const style = await fs.readFile(path.join(TEST_DIR, "STYLE.md"), "utf-8");
47
+ expect(style).toContain("尚未校准");
48
+ });
49
+
50
+ it("init is idempotent", async () => {
51
+ const result = await runner.execute("autocrew_init", {});
52
+ expect(result.ok).toBe(true);
53
+ expect(result.alreadyExisted).toBe(true);
54
+ });
55
+
56
+ it("status is exempt from onboarding gate", async () => {
57
+ const result = await runner.execute("autocrew_status", {});
58
+ expect(result.ok).toBe(true);
59
+ });
60
+
61
+ it("content tool blocked before profile setup", async () => {
62
+ const result = await runner.execute("autocrew_content", { action: "list" });
63
+ expect(result.ok).toBe(false);
64
+ expect(result.error).toMatch(/onboarding|profile/i);
65
+ });
66
+ });
67
+
68
+ describe("E2E: Profile Setup", () => {
69
+ it("set up complete profile to unblock tools", async () => {
70
+ await fs.writeFile(path.join(TEST_DIR, "creator-profile.json"), JSON.stringify({
71
+ industry: "美食探店",
72
+ platforms: ["xiaohongshu", "douyin"],
73
+ audiencePersona: {
74
+ name: "美食爱好者", age: "20-35", job: "白领",
75
+ painPoints: ["不知道吃什么"], scrollStopTriggers: ["高颜值菜品"],
76
+ },
77
+ writingRules: [],
78
+ styleBoundaries: { never: [], always: [] },
79
+ competitorAccounts: [],
80
+ performanceHistory: [],
81
+ styleCalibrated: true,
82
+ createdAt: new Date().toISOString(),
83
+ updatedAt: new Date().toISOString(),
84
+ }));
85
+
86
+ const result = await runner.execute("autocrew_content", { action: "list" });
87
+ expect(result.ok).toBe(true);
88
+ });
89
+
90
+ it("pro_status shows profile completeness", async () => {
91
+ const result = await runner.execute("autocrew_pro_status", {});
92
+ expect(result.ok).toBe(true);
93
+ expect(result.profileExists).toBe(true);
94
+ expect(result.styleCalibrated).toBe(true);
95
+ expect((result.missingInfo as string[]).length).toBe(0);
96
+ });
97
+ });
98
+
99
+ describe("E2E: Content Creation Flow", () => {
100
+ let contentId: string;
101
+
102
+ it("save a content draft", async () => {
103
+ const result = await runner.execute("autocrew_content", {
104
+ action: "save",
105
+ title: "北京胡同里的宝藏面馆|人均30吃到撑",
106
+ body: "今天给大家分享一家隐藏在北京胡同里的面馆。这家面馆已经开了20年了。招牌炸酱面,面条劲道,酱料浓郁。人均消费只要30元。#北京美食 #胡同美食",
107
+ platform: "xiaohongshu",
108
+ });
109
+ expect(result.ok).toBe(true);
110
+ // content-save returns { ok, content } where content.id is the ID
111
+ const content = result.content as any;
112
+ expect(content).toBeDefined();
113
+ expect(content.id).toBeDefined();
114
+ contentId = content.id;
115
+ });
116
+
117
+ it("list contents shows the saved draft", async () => {
118
+ const result = await runner.execute("autocrew_content", { action: "list" });
119
+ expect(result.ok).toBe(true);
120
+ const contents = result.contents as any[];
121
+ expect(contents).toBeDefined();
122
+ expect(contents.length).toBeGreaterThanOrEqual(1);
123
+ expect(contents.some((c: any) => c.title?.includes("宝藏面馆"))).toBe(true);
124
+ });
125
+
126
+ it("get content by id", async () => {
127
+ const result = await runner.execute("autocrew_content", {
128
+ action: "get",
129
+ id: contentId,
130
+ });
131
+ expect(result.ok).toBe(true);
132
+ });
133
+
134
+ it("humanize text directly", async () => {
135
+ const result = await runner.execute("autocrew_humanize", {
136
+ action: "humanize_zh",
137
+ text: "本文将深入探讨北京胡同美食的赋能效应。值得一提的是,这家面馆打通了传统与现代的闭环。",
138
+ });
139
+ expect(result.ok).not.toBe(false);
140
+ expect(result.humanizedText).toBeDefined();
141
+ expect(result.changeCount).toBeDefined();
142
+ });
143
+
144
+ it("review content directly", async () => {
145
+ const result = await runner.execute("autocrew_review", {
146
+ action: "full_review",
147
+ text: "今天给大家分享一家面馆。招牌炸酱面很好吃。人均30元。#北京美食",
148
+ platform: "xiaohongshu",
149
+ });
150
+ expect(result.ok).toBe(true);
151
+ });
152
+
153
+ it("pre-publish checklist", async () => {
154
+ const result = await runner.execute("autocrew_pre_publish", {
155
+ action: "check",
156
+ content_id: contentId,
157
+ });
158
+ expect(result.ok).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe("E2E: Topic Management", () => {
163
+ it("create a topic", async () => {
164
+ const result = await runner.execute("autocrew_topic", {
165
+ action: "create",
166
+ title: "2026年北京新开的宝藏面馆盘点",
167
+ description: "盘点北京今年新开的特色面馆",
168
+ tags: ["美食", "北京", "面馆"],
169
+ });
170
+ expect(result.ok).toBe(true);
171
+ // topic-create returns { ok, topic } where topic.id is the ID
172
+ const topic = result.topic as any;
173
+ expect(topic).toBeDefined();
174
+ expect(topic.id).toBeDefined();
175
+ });
176
+
177
+ it("list topics", async () => {
178
+ const result = await runner.execute("autocrew_topic", { action: "list" });
179
+ expect(result.ok).toBe(true);
180
+ expect((result.topics as any[]).length).toBeGreaterThanOrEqual(1);
181
+ });
182
+ });
183
+
184
+ describe("E2E: Pipeline & Workflow", () => {
185
+ let workflowId: string;
186
+
187
+ it("list templates", async () => {
188
+ const result = await runner.execute("autocrew_pipeline", { action: "templates" });
189
+ expect(result.ok).toBe(true);
190
+ const templates = result.templates as any[];
191
+ expect(templates.length).toBeGreaterThanOrEqual(2);
192
+ expect(templates.some((t: any) => t.id === "xiaohongshu_full")).toBe(true);
193
+ expect(templates.some((t: any) => t.id === "quick_publish")).toBe(true);
194
+ });
195
+
196
+ it("create a workflow from template", async () => {
197
+ const result = await runner.execute("autocrew_pipeline", {
198
+ action: "create",
199
+ template: "quick_publish",
200
+ });
201
+ expect(result.ok).toBe(true);
202
+ const workflow = result.workflow as any;
203
+ expect(workflow).toBeDefined();
204
+ expect(workflow.id).toBeDefined();
205
+ workflowId = workflow.id;
206
+ });
207
+
208
+ it("get workflow status", async () => {
209
+ const result = await runner.execute("autocrew_pipeline", {
210
+ action: "status",
211
+ id: workflowId,
212
+ });
213
+ expect(result.ok).toBe(true);
214
+ });
215
+
216
+ it("list workflows shows created one", async () => {
217
+ const result = await runner.execute("autocrew_pipeline", { action: "list" });
218
+ expect(result.ok).toBe(true);
219
+ expect((result.workflows as any[]).length).toBeGreaterThanOrEqual(1);
220
+ });
221
+ });
222
+
223
+ describe("E2E: Publish Tools (error handling)", () => {
224
+ it("xiaohongshu_publish fails gracefully without cookie", async () => {
225
+ const result = await runner.execute("autocrew_publish", {
226
+ action: "xiaohongshu_publish",
227
+ title: "test",
228
+ description: "test",
229
+ images: ["/tmp/nonexistent.png"],
230
+ });
231
+ expect(result.ok).toBe(false);
232
+ expect(result.error).toBeDefined();
233
+ });
234
+
235
+ it("douyin_publish returns not-yet-implemented", async () => {
236
+ const result = await runner.execute("autocrew_publish", {
237
+ action: "douyin_publish",
238
+ title: "test",
239
+ });
240
+ expect(result.ok).toBe(false);
241
+ expect(result.error).toBeDefined();
242
+ });
243
+
244
+ it("relay_publish returns info message", async () => {
245
+ const result = await runner.execute("autocrew_publish", {
246
+ action: "relay_publish",
247
+ });
248
+ expect(result.ok).toBe(false);
249
+ expect(result.error).toBeDefined();
250
+ });
251
+ });
252
+
253
+ describe("E2E: Memory", () => {
254
+ it("capture feedback", async () => {
255
+ const result = await runner.execute("autocrew_memory", {
256
+ action: "capture_feedback",
257
+ signal: "general",
258
+ feedback: "写的内容太正式了,需要更口语化",
259
+ });
260
+ expect(result.ok).toBe(true);
261
+ });
262
+
263
+ it("get memory after feedback", async () => {
264
+ const result = await runner.execute("autocrew_memory", {
265
+ action: "get_memory",
266
+ });
267
+ expect(result.ok).toBe(true);
268
+ // memory tool returns { ok, memoryPath, content }
269
+ expect(result.content).toBeDefined();
270
+ });
271
+ });
272
+
273
+ // ============================================================
274
+ // 补充测试:用户修改偏好记录、风格校准、批量、封面、资产、状态流转
275
+ // ============================================================
276
+
277
+ describe("E2E: User Edit Tracking + Rule Distillation", () => {
278
+ it("recordDiff captures a user edit", async () => {
279
+ const diff = await recordDiff(
280
+ "test-content-001",
281
+ "body",
282
+ "本文将深入探讨美食的赋能效应。值得一提的是,这家面馆打通了传统闭环。",
283
+ "今天聊聊这家面馆怎么把传统做法玩出了新花样。",
284
+ TEST_DIR,
285
+ );
286
+ expect(diff.id).toBeDefined();
287
+ expect(diff.contentId).toBe("test-content-001");
288
+ expect(diff.field).toBe("body");
289
+ expect(diff.before).toContain("赋能");
290
+ expect(diff.after).toContain("新花样");
291
+ expect(diff.patterns.length).toBeGreaterThanOrEqual(0);
292
+ });
293
+
294
+ it("record multiple edits to accumulate patterns", async () => {
295
+ // Simulate 5 edits removing similar AI patterns
296
+ for (let i = 0; i < 5; i++) {
297
+ await recordDiff(
298
+ `test-content-${100 + i}`,
299
+ "body",
300
+ `这篇文章全面赋能了读者的认知。第${i}次`,
301
+ `这篇文章帮读者搞明白了。第${i}次`,
302
+ TEST_DIR,
303
+ );
304
+ }
305
+ // Check if distillation is ready
306
+ const ready = await distillRules(TEST_DIR);
307
+ expect(ready).toBeDefined();
308
+ expect(typeof ready.newRulesCount).toBe("number");
309
+ expect(typeof ready.summary).toBe("string");
310
+ });
311
+
312
+ it("addWritingRule persists to creator profile", async () => {
313
+ const profile = await addWritingRule(
314
+ { rule: "不要用'赋能',改用'帮助'或'支持'", source: "auto_distilled", confidence: 0.85 },
315
+ TEST_DIR,
316
+ );
317
+ expect(profile.writingRules.length).toBeGreaterThanOrEqual(1);
318
+ expect(profile.writingRules.some((r) => r.rule.includes("赋能"))).toBe(true);
319
+
320
+ // Verify persistence
321
+ const loaded = await loadProfile(TEST_DIR);
322
+ expect(loaded).not.toBeNull();
323
+ expect(loaded!.writingRules.length).toBeGreaterThanOrEqual(1);
324
+ });
325
+ });
326
+
327
+ describe("E2E: Style Calibration Gate", () => {
328
+ it("uncalibrated profile blocks tools with style_not_calibrated", async () => {
329
+ const profile = await loadProfile(TEST_DIR);
330
+ expect(profile).not.toBeNull();
331
+ profile!.styleCalibrated = false;
332
+ await saveProfile(profile!, TEST_DIR);
333
+
334
+ const result = await runner.execute("autocrew_content", { action: "list" });
335
+ expect(result.ok).toBe(false);
336
+ expect(result.error).toMatch(/style|calibrat|profile_incomplete/i);
337
+
338
+ // Restore
339
+ profile!.styleCalibrated = true;
340
+ await saveProfile(profile!, TEST_DIR);
341
+ });
342
+
343
+ it("init returns style_calibration next_step when only style is missing", async () => {
344
+ const profile = await loadProfile(TEST_DIR);
345
+ expect(profile).not.toBeNull();
346
+ profile!.styleCalibrated = false;
347
+ await saveProfile(profile!, TEST_DIR);
348
+
349
+ const result = await runner.execute("autocrew_init", {});
350
+ expect(result.ok).toBe(true);
351
+ expect(result.next_step).toBeDefined();
352
+ const nextStep = result.next_step as any;
353
+ expect(nextStep.action).toBe("style_calibration");
354
+ expect(nextStep.message).toContain("风格校准");
355
+
356
+ // Restore
357
+ profile!.styleCalibrated = true;
358
+ await saveProfile(profile!, TEST_DIR);
359
+ });
360
+ });
361
+
362
+ describe("E2E: Platform Rewrite + Batch Adapt", () => {
363
+ it("adapt_platform rewrites content for douyin", async () => {
364
+ const result = await runner.execute("autocrew_rewrite", {
365
+ action: "adapt_platform",
366
+ title: "北京胡同宝藏面馆",
367
+ body: "今天给大家分享一家隐藏在北京胡同里的面馆。招牌炸酱面,面条劲道。人均30元。",
368
+ target_platform: "douyin",
369
+ });
370
+ expect(result.ok).toBe(true);
371
+ expect(result.platform).toBe("douyin");
372
+ expect(result.body).toBeDefined();
373
+ expect(result.title).toBeDefined();
374
+ });
375
+
376
+ it("batch_adapt rewrites for multiple platforms", async () => {
377
+ const result = await runner.execute("autocrew_rewrite", {
378
+ action: "batch_adapt",
379
+ title: "北京胡同宝藏面馆",
380
+ body: "今天分享一家面馆。招牌炸酱面很好吃。人均30元。#北京美食",
381
+ target_platforms: ["xiaohongshu", "douyin"],
382
+ });
383
+ expect(result.ok).toBe(true);
384
+ expect(result.results).toBeDefined();
385
+ const results = result.results as any[];
386
+ expect(results.length).toBe(2);
387
+ });
388
+ });
389
+
390
+ describe("E2E: Cover Review (without Gemini key)", () => {
391
+ let testContentId: string;
392
+
393
+ it("create content for cover test", async () => {
394
+ const result = await runner.execute("autocrew_content", {
395
+ action: "save",
396
+ title: "封面测试内容",
397
+ body: "这是用来测试封面生成的内容。需要一个好看的封面图。",
398
+ platform: "xiaohongshu",
399
+ });
400
+ expect(result.ok).toBe(true);
401
+ testContentId = (result.content as any).id;
402
+ });
403
+
404
+ it("create_candidates fails gracefully without Gemini key", async () => {
405
+ const result = await runner.execute("autocrew_cover_review", {
406
+ action: "create_candidates",
407
+ content_id: testContentId,
408
+ });
409
+ // Without Gemini API key, should fail with helpful hint
410
+ expect(result.ok).toBe(false);
411
+ expect(result.error || result.hint).toBeDefined();
412
+ });
413
+
414
+ it("get cover review returns empty when none exists", async () => {
415
+ const result = await runner.execute("autocrew_cover_review", {
416
+ action: "get",
417
+ content_id: testContentId,
418
+ });
419
+ // Should handle gracefully - either not found or empty
420
+ expect(result).toBeDefined();
421
+ });
422
+ });
423
+
424
+ describe("E2E: Asset Management", () => {
425
+ let assetContentId: string;
426
+
427
+ it("create content for asset test", async () => {
428
+ const result = await runner.execute("autocrew_content", {
429
+ action: "save",
430
+ title: "资产管理测试",
431
+ body: "测试资产管理功能。",
432
+ platform: "xiaohongshu",
433
+ });
434
+ expect(result.ok).toBe(true);
435
+ assetContentId = (result.content as any).id;
436
+ });
437
+
438
+ it("add an asset to content", async () => {
439
+ const result = await runner.execute("autocrew_asset", {
440
+ action: "add",
441
+ content_id: assetContentId,
442
+ filename: "cover.png",
443
+ asset_type: "cover",
444
+ description: "封面图片",
445
+ });
446
+ expect(result.ok).toBe(true);
447
+ });
448
+
449
+ it("list assets for content", async () => {
450
+ const result = await runner.execute("autocrew_asset", {
451
+ action: "list",
452
+ content_id: assetContentId,
453
+ });
454
+ expect(result.ok).toBe(true);
455
+ expect(result.assets).toBeDefined();
456
+ const assets = result.assets as any[];
457
+ expect(assets.length).toBeGreaterThanOrEqual(1);
458
+ expect(assets.some((a: any) => a.filename === "cover.png")).toBe(true);
459
+ });
460
+
461
+ it("list versions", async () => {
462
+ const result = await runner.execute("autocrew_asset", {
463
+ action: "versions",
464
+ content_id: assetContentId,
465
+ });
466
+ expect(result.ok).toBe(true);
467
+ expect(result.versions).toBeDefined();
468
+ });
469
+ });
470
+
471
+ describe("E2E: Content Status Transitions", () => {
472
+ let transContentId: string;
473
+
474
+ it("ensure profile is complete before transition tests", async () => {
475
+ // Re-write a fresh complete profile to avoid state leaks from earlier tests
476
+ await fs.mkdir(TEST_DIR, { recursive: true });
477
+ await fs.writeFile(path.join(TEST_DIR, "creator-profile.json"), JSON.stringify({
478
+ industry: "美食探店",
479
+ platforms: ["xiaohongshu", "douyin"],
480
+ audiencePersona: {
481
+ name: "美食爱好者", age: "20-35", job: "白领",
482
+ painPoints: ["不知道吃什么"], scrollStopTriggers: ["高颜值菜品"],
483
+ },
484
+ writingRules: [{ rule: "不要用赋能", source: "auto_distilled", confidence: 0.85, createdAt: new Date().toISOString() }],
485
+ styleBoundaries: { never: [], always: [] },
486
+ competitorAccounts: [],
487
+ performanceHistory: [],
488
+ styleCalibrated: true,
489
+ createdAt: new Date().toISOString(),
490
+ updatedAt: new Date().toISOString(),
491
+ }, null, 2));
492
+ });
493
+
494
+ it("create content and transition through states", async () => {
495
+ const saveResult = await runner.execute("autocrew_content", {
496
+ action: "save",
497
+ title: "状态流转测试",
498
+ body: "测试内容状态机流转。",
499
+ platform: "xiaohongshu",
500
+ });
501
+ expect(saveResult.ok).toBe(true);
502
+ transContentId = (saveResult.content as any).id;
503
+ });
504
+
505
+ it("transition: draft_ready → reviewing", async () => {
506
+ const result = await runner.execute("autocrew_content", {
507
+ action: "transition",
508
+ id: transContentId,
509
+ target_status: "reviewing",
510
+ });
511
+ expect(result.ok).toBe(true);
512
+ });
513
+
514
+ it("transition: reviewing → approved", async () => {
515
+ const result = await runner.execute("autocrew_content", {
516
+ action: "transition",
517
+ id: transContentId,
518
+ target_status: "approved",
519
+ });
520
+ expect(result.ok).toBe(true);
521
+ });
522
+
523
+ it("invalid transition is rejected", async () => {
524
+ // approved → drafting should not be allowed
525
+ const result = await runner.execute("autocrew_content", {
526
+ action: "transition",
527
+ id: transContentId,
528
+ target_status: "drafting",
529
+ });
530
+ expect(result.ok).toBe(false);
531
+ });
532
+ });
533
+
534
+ describe("E2E: Sensitive Words Detection", () => {
535
+ it("scan_only detects sensitive words", async () => {
536
+ const result = await runner.execute("autocrew_review", {
537
+ action: "scan_only",
538
+ text: "这个产品能治疗癌症,日赚一万不是梦,赶紧加微信了解",
539
+ platform: "xiaohongshu",
540
+ });
541
+ expect(result.ok).toBe(true);
542
+ // Should detect medical claims and financial claims
543
+ const found = result.found || result.sensitiveWords || result.words;
544
+ expect(found).toBeDefined();
545
+ });
546
+
547
+ it("quality_score rates content quality", async () => {
548
+ const result = await runner.execute("autocrew_review", {
549
+ action: "quality_score",
550
+ text: "今天给大家分享一家面馆。招牌炸酱面,面条劲道,酱料浓郁。人均30元。推荐指数五颗星。",
551
+ platform: "xiaohongshu",
552
+ });
553
+ expect(result.ok).toBe(true);
554
+ expect(result.qualityScore).toBeDefined();
555
+ });
556
+
557
+ it("auto_fix replaces sensitive words", async () => {
558
+ const result = await runner.execute("autocrew_review", {
559
+ action: "auto_fix",
560
+ text: "加我微信,保证月入过万",
561
+ platform: "xiaohongshu",
562
+ });
563
+ expect(result.ok).toBe(true);
564
+ expect(result.autoFixedText).toBeDefined();
565
+ });
566
+ });
567
+
568
+ describe("E2E: Status Dashboard", () => {
569
+ it("status shows correct counts", async () => {
570
+ const result = await runner.execute("autocrew_status", {});
571
+ expect(result.ok).toBe(true);
572
+ // status returns { topics, contents } as count numbers
573
+ expect(typeof result.topics).toBe("number");
574
+ expect(typeof result.contents).toBe("number");
575
+ expect(result.topics).toBeGreaterThanOrEqual(1);
576
+ expect(result.contents).toBeGreaterThanOrEqual(1);
577
+ });
578
+ });
579
+
580
+ describe("E2E: EventBus Integration", () => {
581
+ it("tool execution emits events", async () => {
582
+ const events: any[] = [];
583
+ const subId = eventBus.on("*", (e) => events.push(e));
584
+
585
+ await runner.execute("autocrew_topic", {
586
+ action: "create",
587
+ title: "事件测试选题",
588
+ tags: ["test"],
589
+ });
590
+
591
+ eventBus.off(subId);
592
+
593
+ expect(events.length).toBeGreaterThanOrEqual(1);
594
+ expect(events.some((e) => e.type === "tool:pre_execute")).toBe(true);
595
+ });
596
+ });