@zzclub/pipeline 0.2.0 → 0.4.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 (194) hide show
  1. package/README.md +366 -170
  2. package/package.json +4 -7
  3. package/src/adapter-loader.ts +138 -0
  4. package/src/adapter-types.ts +172 -0
  5. package/src/adapters/builtin-image-renderer.ts +353 -0
  6. package/src/adapters/builtin-markdown-renderer.ts +63 -0
  7. package/src/adapters/index.ts +2 -0
  8. package/src/args.test.ts +118 -0
  9. package/src/args.ts +90 -0
  10. package/src/cli.ts +51 -0
  11. package/src/commands/abandon.test.ts +104 -0
  12. package/src/commands/abandon.ts +202 -0
  13. package/src/commands/attach-body-images.ts +88 -0
  14. package/src/commands/attach-body.ts +86 -0
  15. package/src/commands/attach-newspic-spec.ts +41 -0
  16. package/src/commands/checkpoint.test.ts +91 -0
  17. package/src/commands/checkpoint.ts +66 -0
  18. package/src/commands/config.ts +106 -0
  19. package/src/commands/cos-upload.ts +62 -0
  20. package/src/commands/doctor.ts +66 -0
  21. package/src/commands/find-run.ts +55 -0
  22. package/src/commands/hermes-metrics.ts +226 -0
  23. package/src/commands/imgx.ts +60 -0
  24. package/src/commands/ingest-handoff.ts +509 -0
  25. package/src/commands/init.ts +126 -0
  26. package/src/commands/prepare-finalize.ts +348 -0
  27. package/src/commands/prepare.ts +310 -0
  28. package/src/commands/publish.ts +147 -0
  29. package/src/commands/reconcile.ts +36 -0
  30. package/src/commands/render.ts +244 -0
  31. package/src/commands/reset.test.ts +151 -0
  32. package/src/commands/reset.ts +159 -0
  33. package/src/commands/review.ts +77 -0
  34. package/src/commands/status.ts +68 -0
  35. package/src/commands/sync-blog.ts +39 -0
  36. package/src/commands/tasks.ts +50 -0
  37. package/src/commands/wechat-export.ts +68 -0
  38. package/src/config.ts +0 -0
  39. package/src/imgx/ascii-portrait.ts +77 -0
  40. package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
  41. package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
  42. package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
  43. package/src/imgx/assets/icons/logo.png +0 -0
  44. package/src/imgx/assets/icons/logo.svg +8 -0
  45. package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
  46. package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
  47. package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
  48. package/src/imgx/cli.ts +57 -0
  49. package/src/imgx/geometry.ts +68 -0
  50. package/src/imgx/index.ts +5 -0
  51. package/src/imgx/longform-theme.ts +243 -0
  52. package/src/imgx/poster-recipe.ts +81 -0
  53. package/src/imgx/pretext-adapter.ts +14 -0
  54. package/src/imgx/pretext-runtime.ts +61 -0
  55. package/src/imgx/references/ascii-portrait-3-4.md +59 -0
  56. package/src/imgx/references/longform-3-4.md +173 -0
  57. package/src/imgx/references/poster-3-4.md +87 -0
  58. package/src/imgx/references/tips-3-4.md +69 -0
  59. package/src/imgx/references/wechat-cover-split.md +89 -0
  60. package/src/imgx/references/x-like-posts.md +139 -0
  61. package/src/imgx/render-article.test.ts +53 -0
  62. package/src/imgx/render-article.ts +1364 -0
  63. package/src/imgx/render-ascii-portrait.ts +53 -0
  64. package/src/imgx/render-card.ts +232 -0
  65. package/src/imgx/render-x-like-posts.ts +235 -0
  66. package/src/imgx/runtime.ts +403 -0
  67. package/src/imgx/typographic-scale.test.ts +56 -0
  68. package/src/imgx/typographic-scale.ts +44 -0
  69. package/src/output.ts +632 -0
  70. package/src/plugins.ts +101 -0
  71. package/src/profiles.ts +149 -0
  72. package/src/providers/blog.test.ts +58 -0
  73. package/src/providers/blog.ts +154 -0
  74. package/src/providers/cos.ts +183 -0
  75. package/src/providers/index.test.ts +26 -0
  76. package/src/providers/index.ts +185 -0
  77. package/src/providers/wechat.test.ts +215 -0
  78. package/src/providers/wechat.ts +548 -0
  79. package/src/routes.ts +287 -0
  80. package/src/runtime-paths.ts +179 -0
  81. package/src/schema/config.ts +133 -0
  82. package/src/schema/state.ts +376 -0
  83. package/src/spawn.ts +103 -0
  84. package/src/state.ts +265 -0
  85. package/src/task-manager.ts +657 -0
  86. package/src/task-views.ts +222 -0
  87. package/src/text.test.ts +689 -0
  88. package/src/text.ts +683 -0
  89. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  90. package/src/wechat-preview/browser/editor-export.css +241 -0
  91. package/src/wechat-preview/browser/editor-export.ts +103 -0
  92. package/src/wechat-preview/frontmatter-handler.ts +73 -0
  93. package/src/wechat-preview/index.test.ts +154 -0
  94. package/src/wechat-preview/index.ts +294 -0
  95. package/src/wechat-preview/themes.ts +141 -0
  96. package/src/wechat-preview/wechat-formatter.test.ts +288 -0
  97. package/src/wechat-preview/wechat-formatter.ts +1506 -0
  98. package/src/wechat-preview.test.ts +73 -0
  99. package/src/workflow-materials.test.ts +225 -0
  100. package/src/workflow-materials.ts +217 -0
  101. package/src/workflow.test.ts +2087 -0
  102. package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
  103. package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
  104. package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
  105. package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
  106. package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
  107. package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
  108. package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
  109. package/dist/cli.js +0 -432
  110. package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
  111. package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
  112. package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
  113. package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
  114. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
  115. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
  116. package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
  117. package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
  118. package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
  119. package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
  120. package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
  121. package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
  122. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
  123. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
  124. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
  125. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
  126. /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
  127. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
  128. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
  129. /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
  130. /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
  131. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
  132. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  133. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  134. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  135. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  136. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  137. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  138. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  139. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  140. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  141. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  142. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  143. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  144. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  145. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  146. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  147. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  148. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  149. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  150. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  151. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  152. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  153. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  154. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  155. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  156. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  157. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  158. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  159. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  160. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  161. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  162. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  163. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  164. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  165. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  166. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  167. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  168. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  169. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  170. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  171. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  172. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  173. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  174. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  175. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  176. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  177. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  178. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  179. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  180. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  181. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  182. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  183. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  184. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  185. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  186. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  187. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  188. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  189. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  190. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  191. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
  192. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
  193. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
  194. /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
@@ -0,0 +1,2087 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ import { attachBody } from "./commands/attach-body";
7
+ import { attachBodyImages } from "./commands/attach-body-images";
8
+ import { attachNewspicSpec } from "./commands/attach-newspic-spec";
9
+ import { createCosUploadCommand } from "./commands/cos-upload";
10
+ import { init } from "./commands/init";
11
+ import { findRun } from "./commands/find-run";
12
+ import { ingestHandoff } from "./commands/ingest-handoff";
13
+ import { prepare } from "./commands/prepare";
14
+ import { prepareFinalize } from "./commands/prepare-finalize";
15
+ import { render } from "./commands/render";
16
+ import { publish } from "./commands/publish";
17
+ import { reconcile } from "./commands/reconcile";
18
+ import { review } from "./commands/review";
19
+ import { status } from "./commands/status";
20
+ import { syncBlog } from "./commands/sync-blog";
21
+ import { tasks } from "./commands/tasks";
22
+ import { renderPostsRelativePath, resolveWorkspacePaths, resolveWorkspaceRoot } from "./config";
23
+ import { resolveFullRoute } from "./routes";
24
+ import { resolveAuthoring } from "./profiles";
25
+ import { defaultState, readState, validateForPhase, writeState } from "./state";
26
+ import { getTaskByStatePath } from "./task-manager";
27
+ import { stripLeadingH1, stripLeadingTitleHeading } from "./text";
28
+
29
+ const TEST_CONFIG_PATH = join(
30
+ tmpdir(),
31
+ `zzhub-pipeline-test-config-${process.pid}.json`,
32
+ );
33
+ process.env.ZZHUB_PIPELINE_CONFIG = TEST_CONFIG_PATH;
34
+
35
+ async function makeTempDir(prefix: string): Promise<string> {
36
+ return mkdtemp(join(tmpdir(), prefix));
37
+ }
38
+
39
+ async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
40
+ const original = console.log;
41
+ const lines: string[] = [];
42
+ console.log = (...args: unknown[]) => {
43
+ lines.push(args.map((value) => String(value)).join(" "));
44
+ };
45
+ try {
46
+ await fn();
47
+ } finally {
48
+ console.log = original;
49
+ }
50
+
51
+ const payload = lines.join("\n").trim();
52
+ return JSON.parse(payload) as T;
53
+ }
54
+
55
+ async function captureTextOutput(fn: () => Promise<void>): Promise<string> {
56
+ const original = console.log;
57
+ const lines: string[] = [];
58
+ console.log = (...args: unknown[]) => {
59
+ lines.push(args.map((value) => String(value)).join(" "));
60
+ };
61
+ try {
62
+ await fn();
63
+ } finally {
64
+ console.log = original;
65
+ }
66
+
67
+ return lines.join("\n").trim();
68
+ }
69
+
70
+ beforeEach(async () => {
71
+ await writeFile(TEST_CONFIG_PATH, "{}", "utf-8");
72
+ });
73
+
74
+ describe("route resolution", () => {
75
+ test("throws for ambiguous public-account intent without classification", () => {
76
+ expect(() =>
77
+ resolveFullRoute("发公众号", {
78
+ contentForm: "unknown",
79
+ })
80
+ ).toThrow("Ambiguous route");
81
+ });
82
+
83
+ test("allows classified form to resolve an otherwise ambiguous intent", () => {
84
+ const route = resolveFullRoute("发公众号", {
85
+ contentForm: "article",
86
+ });
87
+ expect(route.primary).toBe("wechat-article");
88
+ });
89
+
90
+ test("ignores blog-only target in workflow routing and keeps article on wechat route", () => {
91
+ const route = resolveFullRoute("", {
92
+ contentForm: "article",
93
+ targets: ["blog"],
94
+ });
95
+ expect(route.primary).toBe("wechat-article");
96
+ expect(route.extras).toEqual([]);
97
+ });
98
+
99
+ test("does not open a second blog workflow branch for mixed targets", () => {
100
+ const route = resolveFullRoute("", {
101
+ contentForm: "article",
102
+ targets: ["wechat", "blog"],
103
+ });
104
+ expect(route.primary).toBe("wechat-article");
105
+ expect(route.extras).toEqual([]);
106
+ });
107
+
108
+ test("treats explicit newspic big-account intent as deterministic", () => {
109
+ const route = resolveFullRoute("发一篇公众号贴图给大号", {
110
+ contentForm: "newspic",
111
+ targets: ["wechat"],
112
+ });
113
+ expect(route.primary).toBe("wechat-newspic");
114
+ expect(route.account).toBe("default");
115
+ });
116
+ });
117
+
118
+ describe("init", () => {
119
+ test("normalizes underscore flags and resolves route/account from intent text", async () => {
120
+ const workspace = await makeTempDir("zzhub-init-");
121
+ const output = await captureJsonOutput<{ state_path: string }>(() =>
122
+ init([
123
+ "--workspace",
124
+ workspace,
125
+ "--task-kind",
126
+ "publish",
127
+ "--content-form",
128
+ "newspic",
129
+ "--targets",
130
+ "wechat",
131
+ "--content-origin",
132
+ "user",
133
+ "--intent-text",
134
+ "写一篇公众号贴图,发到大号",
135
+ "--requires_render",
136
+ "--requires_publish",
137
+ ]));
138
+
139
+ const state = await readState(output.state_path);
140
+ expect(state.intent.requires.render).toBe(true);
141
+ expect(state.intent.requires.publish).toBe(true);
142
+ expect(state.route.primary).toBe("wechat-newspic");
143
+ expect(state.route.account).toBe("default");
144
+ });
145
+
146
+ test("ingest-handoff creates a publish task from structured JSON", async () => {
147
+ const workspace = await makeTempDir("zzhub-ingest-handoff-");
148
+ const bodyPath = join(workspace, "handoff.md");
149
+ const handoffPath = join(workspace, "handoff.json");
150
+ await writeFile(bodyPath, "终稿正文", "utf-8");
151
+ await writeFile(
152
+ handoffPath,
153
+ JSON.stringify({
154
+ publish_handoff: {
155
+ content_form: "article",
156
+ body_path: bodyPath,
157
+ target_account: "default",
158
+ title: "交接协议测试",
159
+ user_intent_text: "发到公众号大号",
160
+ explicit_constraints: ["终稿,不再改稿"],
161
+ },
162
+ }),
163
+ "utf-8",
164
+ );
165
+
166
+ const output = await captureJsonOutput<{
167
+ accepted_handoff: { title: string; explicit_constraints: string[] };
168
+ summary: { state_path: string; metadata: { title: string | null }; route: { account: string } };
169
+ next_action: { action: string; executor: string };
170
+ }>(() =>
171
+ ingestHandoff([
172
+ "--workspace",
173
+ workspace,
174
+ "--file",
175
+ handoffPath,
176
+ ]));
177
+
178
+ expect(output.accepted_handoff.title).toBe("交接协议测试");
179
+ expect(output.accepted_handoff.explicit_constraints).toEqual(["终稿,不再改稿"]);
180
+ expect(output.summary.metadata.title).toBe("交接协议测试");
181
+ expect(output.summary.route.account).toBe("default");
182
+ expect(output.next_action.action).toBe("prepare");
183
+ expect(output.next_action.executor).toBe("cli");
184
+
185
+ const state = await readState(output.summary.state_path);
186
+ expect(state.intent.intent_text).toBe("发到公众号大号");
187
+ expect(state.intent.explicit_constraints).toEqual(["终稿,不再改稿"]);
188
+ expect(state.source_body_path).not.toBeNull();
189
+ });
190
+
191
+ test("ingest-handoff accepts workflow_handoff materials and routes to writer", async () => {
192
+ const workspace = await makeTempDir("zzhub-workflow-handoff-materials-");
193
+ const materialsPath = join(workspace, "materials.md");
194
+ const handoffPath = join(workspace, "workflow-handoff.json");
195
+ await writeFile(materialsPath, "素材A\n素材B", "utf-8");
196
+ await writeFile(
197
+ handoffPath,
198
+ JSON.stringify({
199
+ workflow_handoff: {
200
+ content_form: "article",
201
+ materials_path: materialsPath,
202
+ target_account: "default",
203
+ title: "素材接管测试",
204
+ user_intent_text: "用这些素材写一篇公众号文章发到大号",
205
+ research_policy: "skip",
206
+ authoring_policy: "write_from_materials",
207
+ review_policy: "required",
208
+ },
209
+ }),
210
+ "utf-8",
211
+ );
212
+
213
+ const output = await captureJsonOutput<{
214
+ accepted_handoff: { authoring_policy: string; materials_attached: boolean };
215
+ next_action: {
216
+ action: string;
217
+ executor: string;
218
+ command: string | null;
219
+ params?: { source_materials_path?: string; worker_mode?: string };
220
+ };
221
+ summary: { state_path: string };
222
+ }>(() =>
223
+ ingestHandoff([
224
+ "--workspace",
225
+ workspace,
226
+ "--file",
227
+ handoffPath,
228
+ ]));
229
+
230
+ expect(output.accepted_handoff.authoring_policy).toBe("write_from_materials");
231
+ expect(output.accepted_handoff.materials_attached).toBe(true);
232
+ expect(output.next_action.action).toBe("attach-body");
233
+ expect(output.next_action.executor).toBe("worker");
234
+ expect(output.next_action.command).toBeNull();
235
+ expect(output.next_action.params?.worker_mode).toBe("write-from-materials");
236
+ expect(output.next_action.params?.source_materials_path).toContain("source-materials");
237
+
238
+ const state = await readState(output.summary.state_path);
239
+ expect(state.source_body_path).toBeNull();
240
+ expect(state.handoff.source_materials_path).not.toBeNull();
241
+ expect(state.handoff.authoring_policy).toBe("write_from_materials");
242
+ });
243
+
244
+ test("ingest-handoff can resume a task and trust user review", async () => {
245
+ const workspace = await makeTempDir("zzhub-workflow-handoff-resume-");
246
+ const statePath = join(workspace, ".zzhub-media", "runs", "resume-target.json");
247
+ const sourcePath = join(workspace, "source.md");
248
+ const formattedPath = join(workspace, "formatted.md");
249
+ const handoffPath = join(workspace, "resume-handoff.json");
250
+ await mkdir(join(workspace, ".zzhub-media", "runs"), { recursive: true });
251
+ await writeFile(sourcePath, "原始正文", "utf-8");
252
+ await writeFile(formattedPath, "格式化正文", "utf-8");
253
+
254
+ const state = defaultState();
255
+ state.workspace_root = workspace;
256
+ state.state_path = statePath;
257
+ state.run_id = "resume-target";
258
+ state.intent.content_form = "article";
259
+ state.intent.targets = ["wechat"];
260
+ state.intent.content_origin = "user";
261
+ state.intent.intent_text = "发到公众号大号";
262
+ state.route.primary = "wechat-article";
263
+ state.route.account = "default";
264
+ state.source_body_path = sourcePath;
265
+ state.formatted_body_path = formattedPath;
266
+ state.metadata.title = "接管终稿";
267
+ state.metadata.slug = "resume-target";
268
+ state.metadata.date = "2026-04-21";
269
+ state.content_review.status = "unchecked";
270
+ await writeState(statePath, state);
271
+
272
+ await writeFile(
273
+ handoffPath,
274
+ JSON.stringify({
275
+ workflow_handoff: {
276
+ mode: "resume",
277
+ state_path: statePath,
278
+ review_policy: "trust_user",
279
+ authoring_policy: "format_only",
280
+ },
281
+ }),
282
+ "utf-8",
283
+ );
284
+
285
+ const output = await captureJsonOutput<{
286
+ accepted_handoff: { review_policy: string };
287
+ next_action: { action: string; executor: string; command: string | null };
288
+ }>(() =>
289
+ ingestHandoff([
290
+ "--workspace",
291
+ workspace,
292
+ "--file",
293
+ handoffPath,
294
+ ]));
295
+
296
+ expect(output.accepted_handoff.review_policy).toBe("trust_user");
297
+ expect(output.next_action.action).toBe("prepare-finalize");
298
+ expect(output.next_action.executor).toBe("cli");
299
+ expect(output.next_action.command).toBe(`zzhub-pipeline prepare-finalize --state "${statePath}"`);
300
+
301
+ const updated = await readState(statePath);
302
+ expect(updated.content_review.status).toBe("passed");
303
+ expect(updated.handoff.review_policy).toBe("trust_user");
304
+ });
305
+ });
306
+
307
+ describe("task views", () => {
308
+ test("status renders an agent-friendly markdown summary", async () => {
309
+ const workspace = await makeTempDir("zzhub-status-view-");
310
+ const statePath = join(workspace, "state.json");
311
+
312
+ const state = defaultState();
313
+ state.workspace_root = workspace;
314
+ state.state_path = statePath;
315
+ state.run_id = "run-status-view";
316
+ state.intent.content_form = "newspic";
317
+ state.intent.targets = ["wechat"];
318
+ state.intent.content_origin = "user";
319
+ state.route.primary = "wechat-newspic";
320
+ state.route.account = "default";
321
+ state.metadata.title = "天气真好";
322
+ state.mode = "active";
323
+ await writeState(statePath, state);
324
+
325
+ const output = await captureTextOutput(() =>
326
+ status([
327
+ "--state",
328
+ statePath,
329
+ "--view",
330
+ "agent",
331
+ ]));
332
+
333
+ expect(output).toContain("# Current Task");
334
+ expect(output).toContain("`run-status-view`");
335
+ expect(output).toContain("`wechat-newspic`");
336
+ expect(output).toContain("default (大号 / 早早集市)");
337
+ expect(output).toContain("## Next Action");
338
+ expect(output).toContain("Executor:");
339
+ // next_action.params should be shown in agent view
340
+ expect(output).toContain("**Params**");
341
+ expect(output).toContain("state_path:");
342
+ });
343
+
344
+ test("status renders a machine-friendly agent-json view", async () => {
345
+ const workspace = await makeTempDir("zzhub-status-agent-json-");
346
+ const statePath = join(workspace, "state.json");
347
+
348
+ const state = defaultState();
349
+ state.workspace_root = workspace;
350
+ state.state_path = statePath;
351
+ state.run_id = "run-agent-json";
352
+ state.intent.content_form = "article";
353
+ state.intent.targets = ["wechat"];
354
+ state.intent.content_origin = "user";
355
+ state.route.primary = "wechat-article";
356
+ state.route.account = "default";
357
+ state.source_body_path = join(workspace, "source.md");
358
+ state.formatted_body_path = join(workspace, "formatted.md");
359
+ state.metadata.title = "Agent JSON 测试";
360
+ state.metadata.slug = "agent-json-test";
361
+ state.metadata.date = "2026-04-21";
362
+ state.content_review.status = "passed";
363
+ await writeState(statePath, state);
364
+
365
+ const output = await captureTextOutput(() =>
366
+ status([
367
+ "--state",
368
+ statePath,
369
+ "--view",
370
+ "agent-json",
371
+ ]));
372
+
373
+ const payload = JSON.parse(output) as {
374
+ next_action: {
375
+ action: string;
376
+ executor: string;
377
+ command: string | null;
378
+ params?: { formatted_body_path?: string };
379
+ };
380
+ };
381
+ expect(payload.next_action.action).toBe("prepare-finalize");
382
+ expect(payload.next_action.executor).toBe("cli");
383
+ expect(payload.next_action.command).toBe(`zzhub-pipeline prepare-finalize --state "${statePath}"`);
384
+ expect(payload.next_action.params?.formatted_body_path).toBe(join(workspace, "formatted.md"));
385
+ });
386
+
387
+ test("tasks renders a markdown table view", async () => {
388
+ const workspace = await makeTempDir("zzhub-tasks-view-");
389
+ const runRoot = join(workspace, ".zzhub-media", "runs");
390
+ await mkdir(runRoot, { recursive: true });
391
+
392
+ const statePath = join(runRoot, "run.json");
393
+ const state = defaultState();
394
+ state.workspace_root = workspace;
395
+ state.state_path = statePath;
396
+ state.run_id = "run-table-view";
397
+ state.intent.content_form = "article";
398
+ state.intent.targets = ["wechat"];
399
+ state.intent.content_origin = "user";
400
+ state.route.primary = "wechat-article";
401
+ state.route.account = "ancientone";
402
+ state.metadata.title = "任务列表测试";
403
+ await writeState(statePath, state);
404
+
405
+ const output = await captureTextOutput(() =>
406
+ tasks([
407
+ "--workspace",
408
+ workspace,
409
+ "--view",
410
+ "markdown",
411
+ ]));
412
+
413
+ expect(output).toContain("# Task List");
414
+ expect(output).toContain("| Run ID | Title | Route | Account | Mode | Phase | Next |");
415
+ expect(output).toContain("`run-table-view`");
416
+ expect(output).toContain("任务列表测试");
417
+ });
418
+
419
+ test("find-run renders the matching task in agent view", async () => {
420
+ const workspace = await makeTempDir("zzhub-find-view-");
421
+ const initOutput = await captureJsonOutput<{ state_path: string }>(() =>
422
+ init([
423
+ "--workspace",
424
+ workspace,
425
+ "--task-kind",
426
+ "publish",
427
+ "--content-form",
428
+ "article",
429
+ "--targets",
430
+ "wechat",
431
+ "--content-origin",
432
+ "user",
433
+ "--intent-text",
434
+ "发公众号文章给小号",
435
+ ]));
436
+
437
+ const output = await captureTextOutput(() =>
438
+ findRun([
439
+ "--workspace",
440
+ workspace,
441
+ "--active",
442
+ "--view",
443
+ "agent",
444
+ ]));
445
+
446
+ const state = await readState(initOutput.state_path);
447
+ expect(output).toContain(state.run_id);
448
+ expect(output).toContain("ancientone (小号 / 古一)");
449
+ expect(output).toContain("## Missing");
450
+ });
451
+
452
+ test("status rejects stray positional arguments instead of silently resolving another task", async () => {
453
+ const workspace = await makeTempDir("zzhub-status-positional-");
454
+ const statePath = join(workspace, "state.json");
455
+ const state = defaultState();
456
+ state.workspace_root = workspace;
457
+ state.state_path = statePath;
458
+ state.run_id = "run-positional";
459
+ await writeState(statePath, state);
460
+
461
+ await expect(async () => {
462
+ await status(["run-positional", "--view", "agent"]);
463
+ }).toThrow("Unexpected positional arguments");
464
+ });
465
+ });
466
+
467
+ describe("authoring resolution", () => {
468
+ test("throws when content origin is unknown", () => {
469
+ expect(() =>
470
+ resolveAuthoring({
471
+ contentOrigin: "unknown",
472
+ styleHint: null,
473
+ hasStyleRequest: false,
474
+ })
475
+ ).toThrow("content_origin is unknown");
476
+ });
477
+ });
478
+
479
+ describe("prepare", () => {
480
+ test("uses managed temp paths for inline attached body and default formatted output", async () => {
481
+ const workspace = await makeTempDir("zzhub-prepare-managed-");
482
+ const statePath = join(workspace, "state.json");
483
+
484
+ const state = defaultState();
485
+ state.workspace_root = workspace;
486
+ state.state_path = statePath;
487
+ state.run_id = "run-managed";
488
+ state.intent.content_form = "article";
489
+ state.intent.targets = ["wechat"];
490
+ state.intent.content_origin = "user";
491
+ await writeState(statePath, state);
492
+
493
+ await captureJsonOutput(() =>
494
+ attachBody([
495
+ "--state",
496
+ statePath,
497
+ "--body-text",
498
+ "# Fresh Heading\n正文内容",
499
+ ]));
500
+
501
+ const prepared = await captureJsonOutput<{ body_formatted_path: string }>(() =>
502
+ prepare([
503
+ "--state",
504
+ statePath,
505
+ ]));
506
+
507
+ const updated = await readState(statePath);
508
+ const bodyOut = await readFile(prepared.body_formatted_path, "utf-8");
509
+
510
+ expect(updated.source_body_path).toBe(
511
+ join(workspace, ".zzhub-media", "tmp", "run-managed", "source-body.md"),
512
+ );
513
+ expect(updated.formatted_body_path).toBe(
514
+ join(workspace, ".zzhub-media", "tmp", "run-managed", "formatted-body.md"),
515
+ );
516
+ expect(prepared.body_formatted_path).toBe(
517
+ join(workspace, ".zzhub-media", "tmp", "run-managed", "formatted-body.md"),
518
+ );
519
+ expect(bodyOut).toContain("## Fresh Heading");
520
+ });
521
+
522
+ test("reuses existing metadata title and strips frontmatter from body output", async () => {
523
+ const workspace = await makeTempDir("zzhub-prepare-");
524
+ const statePath = join(workspace, "state.json");
525
+ const bodyPath = join(workspace, "body.md");
526
+ const bodyOutPath = join(workspace, "body-out.md");
527
+
528
+ const state = defaultState();
529
+ state.workspace_root = workspace;
530
+ state.state_path = statePath;
531
+ state.run_id = "run-1";
532
+ state.intent.content_form = "article";
533
+ state.intent.content_origin = "user";
534
+ state.metadata.title = "Existing Title";
535
+ await writeState(statePath, state);
536
+
537
+ await writeFile(
538
+ bodyPath,
539
+ '---\ntitle: "Ignored"\n---\n# Fresh Heading\n正文内容',
540
+ "utf-8",
541
+ );
542
+
543
+ await prepare([
544
+ "--state",
545
+ statePath,
546
+ "--body",
547
+ bodyPath,
548
+ "--body-out",
549
+ bodyOutPath,
550
+ ]);
551
+
552
+ const updated = await readState(statePath);
553
+ const bodyOut = await readFile(bodyOutPath, "utf-8");
554
+
555
+ expect(updated.metadata.title).toBe("Existing Title");
556
+ expect(updated.source_body_path).toBe(
557
+ join(workspace, ".zzhub-media", "tmp", "run-1", "source-body.md"),
558
+ );
559
+ expect(bodyOut.startsWith("---")).toBe(false);
560
+ expect(bodyOut).toContain("## Fresh Heading");
561
+ });
562
+
563
+ test("preserves canonical route fields during revision-style prepare reruns", async () => {
564
+ const workspace = await makeTempDir("zzhub-prepare-revision-");
565
+ const statePath = join(workspace, "state.json");
566
+ const bodyPath = join(workspace, "body.md");
567
+
568
+ const state = defaultState();
569
+ state.workspace_root = workspace;
570
+ state.state_path = statePath;
571
+ state.run_id = "run-revision";
572
+ state.asset_path = join(workspace, "posts", "2026-04-08-existing");
573
+ state.intent.content_form = "article";
574
+ state.intent.targets = ["wechat", "blog"];
575
+ state.intent.content_origin = "user";
576
+ state.route.primary = "wechat-article";
577
+ state.route.extras = [];
578
+ state.route.account = "ancientone";
579
+ state.route.content_profile = "storytelling";
580
+ await writeState(statePath, state);
581
+ await writeFile(bodyPath, "正文", "utf-8");
582
+
583
+ await prepare([
584
+ "--state",
585
+ statePath,
586
+ "--body",
587
+ bodyPath,
588
+ ]);
589
+
590
+ const updated = await readState(statePath);
591
+ expect(updated.route.primary).toBe("wechat-article");
592
+ expect(updated.route.extras).toEqual([]);
593
+ expect(updated.route.account).toBe("ancientone");
594
+ expect(updated.route.content_profile).toBe("storytelling");
595
+ });
596
+
597
+ test("clears stale article body_inputs when markers disappear", async () => {
598
+ const workspace = await makeTempDir("zzhub-prepare-body-inputs-");
599
+ const statePath = join(workspace, "state.json");
600
+ const bodyPath = join(workspace, "body.md");
601
+
602
+ const state = defaultState();
603
+ state.workspace_root = workspace;
604
+ state.state_path = statePath;
605
+ state.run_id = "run-body-inputs";
606
+ state.asset_path = join(workspace, "posts", "2026-04-08-existing");
607
+ state.intent.content_form = "article";
608
+ state.intent.targets = ["wechat"];
609
+ state.intent.content_origin = "user";
610
+ state.route.primary = "wechat-article";
611
+ state.images.body_inputs = {
612
+ scope: "article",
613
+ expected: 2,
614
+ received: [{ marker: "插图1", path: "/tmp/img-1.png" }],
615
+ status: "pending",
616
+ layout: "staggered",
617
+ };
618
+ await writeState(statePath, state);
619
+ await writeFile(bodyPath, "这是改过后的正文,没有任何插图标记。", "utf-8");
620
+
621
+ await prepare([
622
+ "--state",
623
+ statePath,
624
+ "--body",
625
+ bodyPath,
626
+ ]);
627
+
628
+ const updated = await readState(statePath);
629
+ expect(updated.images.body_inputs.scope).toBe("none");
630
+ expect(updated.images.body_inputs.status).toBe("none");
631
+ expect(updated.images.body_inputs.received).toEqual([]);
632
+ });
633
+ });
634
+
635
+ describe("render", () => {
636
+ test("respects explicit newspic multi-page render intent and page image markers", async () => {
637
+ const workspace = await makeTempDir("zzhub-render-spec-");
638
+ const assetPath = join(workspace, "posts", "2026-04-10-newspic");
639
+ const statePath = join(assetPath, "workflow-state.json");
640
+ const postPath = join(assetPath, "post.md");
641
+
642
+ await mkdir(assetPath, { recursive: true });
643
+ await writeFile(
644
+ postPath,
645
+ [
646
+ "---",
647
+ 'title: "Spec Newspic"',
648
+ "---",
649
+ "这是一段原本会走短贴图路径的正文。",
650
+ ].join("\n"),
651
+ "utf-8",
652
+ );
653
+
654
+ const state = defaultState();
655
+ state.workspace_root = workspace;
656
+ state.asset_path = assetPath;
657
+ state.state_path = statePath;
658
+ state.run_id = "run-spec";
659
+ state.route.primary = "wechat-newspic";
660
+ state.metadata.title = "Spec Newspic";
661
+ state.intent.newspic_render = {
662
+ pagination_mode: "multi",
663
+ min_pages: 2,
664
+ max_pages: 0,
665
+ require_image_every_page: true,
666
+ default_image_layout: "staggered",
667
+ target_fill_ratio: 0.8,
668
+ page_specs: [
669
+ { page: 1, image_markers: ["插图1", "插图2"], image_layout: null, target_fill_ratio: null, note: null },
670
+ { page: 2, image_markers: ["插图3"], image_layout: null, target_fill_ratio: null, note: null },
671
+ ],
672
+ };
673
+ await writeState(statePath, state);
674
+
675
+ await render(["--state", statePath, "--skip-render"]);
676
+
677
+ const updated = await readState(statePath);
678
+ expect(updated.images.plan.template).toBe("longform-3-4");
679
+ expect(updated.images.body_inputs.scope).toBe("newspic-longform");
680
+ expect(updated.images.body_inputs.expected).toBe(3);
681
+ expect(updated.images.body_inputs.status).toBe("pending");
682
+ expect(updated.images.body_inputs.layout).toBe("staggered");
683
+ });
684
+
685
+ test("preserves previously collected longform body images on resume", async () => {
686
+ const workspace = await makeTempDir("zzhub-render-");
687
+ const assetPath = join(workspace, "posts", "2026-04-08-longform");
688
+ const statePath = join(assetPath, "workflow-state.json");
689
+ const postPath = join(assetPath, "post.md");
690
+
691
+ await mkdir(assetPath, { recursive: true });
692
+ await writeFile(
693
+ postPath,
694
+ [
695
+ "---",
696
+ 'title: "Longform"',
697
+ "---",
698
+ "第一段内容,足够长,用来介绍背景,并且长度足以进入长图文路径。",
699
+ "",
700
+ "第二段内容,继续展开细节,补充上下文和关键信息,保持正文长度稳定。",
701
+ "",
702
+ "第三段内容,用来稳定进入 longform 路径,同时不给标记计数制造重复噪音。",
703
+ "",
704
+ "插图1",
705
+ "插图2",
706
+ ].join("\n"),
707
+ "utf-8",
708
+ );
709
+
710
+ const state = defaultState();
711
+ state.workspace_root = workspace;
712
+ state.asset_path = assetPath;
713
+ state.state_path = statePath;
714
+ state.run_id = "run-2";
715
+ state.route.primary = "wechat-newspic";
716
+ state.intent.newspic_render = {
717
+ pagination_mode: "multi",
718
+ min_pages: 2,
719
+ max_pages: 0,
720
+ require_image_every_page: false,
721
+ default_image_layout: "staggered",
722
+ target_fill_ratio: 0.8,
723
+ page_specs: [],
724
+ };
725
+ state.metadata.title = "Longform";
726
+ state.images.body_inputs = {
727
+ scope: "newspic-longform",
728
+ expected: 2,
729
+ received: [
730
+ { marker: "插图1", path: "/tmp/img-1.png" },
731
+ { marker: "插图2", path: "/tmp/img-2.png" },
732
+ ],
733
+ status: "ready",
734
+ layout: "staggered",
735
+ };
736
+ await writeState(statePath, state);
737
+
738
+ await render(["--state", statePath, "--skip-render"]);
739
+
740
+ const updated = await readState(statePath);
741
+ expect(updated.images.body_inputs.received).toHaveLength(2);
742
+ expect(updated.images.body_inputs.status).toBe("ready");
743
+ expect(updated.images.body_inputs.received[0]?.path).toBe("/tmp/img-1.png");
744
+ });
745
+
746
+ test("passes poster render args as plain strings without JSON quoting", async () => {
747
+ const workspace = await makeTempDir("zzhub-render-poster-");
748
+ const assetPath = join(workspace, "posts", "2026-04-09-recordly");
749
+ const statePath = join(assetPath, "workflow-state.json");
750
+ const postPath = join(assetPath, "post.md");
751
+ const argsLogPath = join(workspace, "render-card-args.json");
752
+
753
+ await mkdir(assetPath, { recursive: true });
754
+ await writeFile(
755
+ postPath,
756
+ [
757
+ "---",
758
+ 'title: "Recordly:开源跨平台录屏与演示视频编辑工具"',
759
+ "---",
760
+ "这是一段短正文,用来稳定走 poster-3-4 短贴图路径。",
761
+ ].join("\n"),
762
+ "utf-8",
763
+ );
764
+
765
+ const state = defaultState();
766
+ state.workspace_root = workspace;
767
+ state.asset_path = assetPath;
768
+ state.state_path = statePath;
769
+ state.run_id = "run-poster";
770
+ state.route.primary = "wechat-newspic";
771
+ state.route.account = "default";
772
+ state.route.highlight_words = ["开源", "录屏"];
773
+ state.route.account_visual_params = {
774
+ footer: "公众号 · 早早集市",
775
+ bg: "#e6f5ef",
776
+ highlight: "#22a854",
777
+ fallback_icon: "assets/icons/logo.png",
778
+ };
779
+ state.metadata.title = "Recordly:开源跨平台录屏与演示视频编辑工具";
780
+ await writeState(statePath, state);
781
+
782
+ const previousLogPath = process.env.TEST_RENDER_CARD_ARGS_PATH;
783
+ const previousStubFlag = process.env.TEST_RENDER_CARD_STUB;
784
+ process.env.TEST_RENDER_CARD_ARGS_PATH = argsLogPath;
785
+ process.env.TEST_RENDER_CARD_STUB = "1";
786
+ try {
787
+ await render(["--state", statePath]);
788
+ } finally {
789
+ if (previousLogPath === undefined) {
790
+ delete process.env.TEST_RENDER_CARD_ARGS_PATH;
791
+ } else {
792
+ process.env.TEST_RENDER_CARD_ARGS_PATH = previousLogPath;
793
+ }
794
+ if (previousStubFlag === undefined) {
795
+ delete process.env.TEST_RENDER_CARD_STUB;
796
+ } else {
797
+ process.env.TEST_RENDER_CARD_STUB = previousStubFlag;
798
+ }
799
+ }
800
+
801
+ const args = JSON.parse(await readFile(argsLogPath, "utf-8")) as string[];
802
+ expect(args).toContain("--text");
803
+ expect(args[args.indexOf("--text") + 1]).toBe("Recordly");
804
+ expect(args).toContain("--footer");
805
+ expect(args[args.indexOf("--footer") + 1]).toBe("公众号 · 早早集市");
806
+ expect(args.filter((value) => value === "--highlight-words")).toHaveLength(1);
807
+ expect(args[args.indexOf("--highlight-words") + 1]).toBe("开源,录屏");
808
+ });
809
+ });
810
+
811
+ describe("prepare-finalize", () => {
812
+ test("falls back to state.formatted_body_path when --body is omitted", async () => {
813
+ const workspace = await makeTempDir("zzhub-finalize-managed-");
814
+ const initOutput = await captureJsonOutput<{ state_path: string }>(() =>
815
+ init([
816
+ "--workspace",
817
+ workspace,
818
+ "--task-kind",
819
+ "publish",
820
+ "--content-form",
821
+ "article",
822
+ "--targets",
823
+ "wechat",
824
+ "--content-origin",
825
+ "user",
826
+ "--intent-text",
827
+ "发公众号文章给大号",
828
+ "--requires-render",
829
+ "--requires-publish",
830
+ ]));
831
+
832
+ await attachBody([
833
+ "--state",
834
+ initOutput.state_path,
835
+ "--body-text",
836
+ "第一段\n\n第二段",
837
+ ]);
838
+
839
+ const prepared = await captureJsonOutput<{ body_formatted_path: string }>(() =>
840
+ prepare([
841
+ "--state",
842
+ initOutput.state_path,
843
+ "--suggested-title",
844
+ "省掉隐藏路径这件事",
845
+ ]));
846
+
847
+ await review([
848
+ "--state",
849
+ initOutput.state_path,
850
+ "--status",
851
+ "passed",
852
+ ]);
853
+
854
+ const finalized = await captureJsonOutput<{ state_path: string; post_path: string }>(() =>
855
+ prepareFinalize([
856
+ "--state",
857
+ initOutput.state_path,
858
+ ]));
859
+
860
+ const post = await readFile(finalized.post_path, "utf-8");
861
+ expect(post).toContain("第一段");
862
+ const state = await readState(finalized.state_path);
863
+ expect(state.formatted_body_path).toBe(prepared.body_formatted_path);
864
+ });
865
+
866
+ test("creates a suffixed asset path when a new run collides with an existing slug/date", async () => {
867
+ const workspace = await makeTempDir("zzhub-finalize-");
868
+ const existingAssetPath = join(workspace, "posts", "2026-04-08-same-slug");
869
+ const statePath = join(workspace, ".zzhub-media", "runs", "run-3.json");
870
+ const bodyPath = join(workspace, "body.md");
871
+
872
+ await mkdir(existingAssetPath, { recursive: true });
873
+ await writeFile(bodyPath, "正文", "utf-8");
874
+
875
+ const state = defaultState();
876
+ state.workspace_root = workspace;
877
+ state.state_path = statePath;
878
+ state.run_id = "run-3";
879
+ state.metadata.title = "Same Slug";
880
+ state.metadata.slug = "same-slug";
881
+ state.metadata.date = "2026-04-08";
882
+ state.content_review.status = "passed";
883
+ await writeState(statePath, state);
884
+
885
+ await prepareFinalize([
886
+ "--state",
887
+ statePath,
888
+ "--body",
889
+ bodyPath,
890
+ "--workspace",
891
+ workspace,
892
+ ]);
893
+
894
+ const updated = await readState(statePath);
895
+ expect(updated.metadata.slug).toBe("same-slug-v2");
896
+ expect(updated.asset_path.endsWith("2026-04-08-same-slug-v2")).toBe(true);
897
+ });
898
+
899
+ test("reuses the canonical asset path during revision-style finalization", async () => {
900
+ const workspace = await makeTempDir("zzhub-finalize-reuse-");
901
+ const assetPath = join(workspace, "posts", "2026-04-08-existing");
902
+ const statePath = join(assetPath, "workflow-state.json");
903
+ const bodyPath = join(workspace, "body.md");
904
+
905
+ await mkdir(assetPath, { recursive: true });
906
+ await writeFile(bodyPath, "更新后的正文", "utf-8");
907
+
908
+ const state = defaultState();
909
+ state.workspace_root = workspace;
910
+ state.asset_path = assetPath;
911
+ state.state_path = statePath;
912
+ state.run_id = "run-4";
913
+ state.metadata.title = "Existing";
914
+ state.metadata.slug = "existing";
915
+ state.metadata.date = "2026-04-08";
916
+ state.content_review.status = "passed";
917
+ await writeState(statePath, state);
918
+
919
+ await prepareFinalize([
920
+ "--state",
921
+ statePath,
922
+ "--body",
923
+ bodyPath,
924
+ "--workspace",
925
+ workspace,
926
+ ]);
927
+
928
+ const updated = await readState(statePath);
929
+ expect(updated.asset_path).toBe(assetPath);
930
+ expect(updated.metadata.slug).toBe("existing");
931
+ });
932
+
933
+ test("writes assets under posts/yyyy-MM/{title} when configured", async () => {
934
+ const workspace = await makeTempDir("zzhub-finalize-pattern-");
935
+ const statePath = join(workspace, ".zzhub-media", "runs", "run-5.json");
936
+ const bodyPath = join(workspace, "body.md");
937
+
938
+ await writeFile(
939
+ TEST_CONFIG_PATH,
940
+ JSON.stringify(
941
+ {
942
+ paths: {
943
+ workspaceRoot: workspace,
944
+ postsPathPattern: "{yyyy-MM}/{title}",
945
+ },
946
+ },
947
+ null,
948
+ 2,
949
+ ),
950
+ "utf-8",
951
+ );
952
+ await writeFile(bodyPath, "正文", "utf-8");
953
+
954
+ const state = defaultState();
955
+ state.workspace_root = workspace;
956
+ state.state_path = statePath;
957
+ state.run_id = "run-5";
958
+ state.metadata.title = "A Better Workflow";
959
+ state.metadata.slug = "a-better-workflow";
960
+ state.metadata.date = "2026-04-08";
961
+ state.content_review.status = "passed";
962
+ await writeState(statePath, state);
963
+
964
+ await prepareFinalize([
965
+ "--state",
966
+ statePath,
967
+ "--body",
968
+ bodyPath,
969
+ ]);
970
+
971
+ const updated = await readState(statePath);
972
+ expect(updated.asset_path).toBe(
973
+ join(workspace, "posts", "2026-04", "A Better Workflow"),
974
+ );
975
+ });
976
+
977
+ test("copies inline markdown images into the asset directory", async () => {
978
+ const workspace = await makeTempDir("zzhub-finalize-inline-assets-");
979
+ const bodySourceDir = join(workspace, "source");
980
+ const assetImagePath = join(bodySourceDir, "demo.jpg");
981
+ const bodyPath = join(workspace, "body.md");
982
+ const statePath = join(workspace, ".zzhub-media", "runs", "run-6.json");
983
+
984
+ await mkdir(bodySourceDir, { recursive: true });
985
+ await writeFile(assetImagePath, "demo-image", "utf-8");
986
+ await writeFile(bodyPath, "正文段落\n\n![示例](./demo.jpg)\n", "utf-8");
987
+
988
+ const state = defaultState();
989
+ state.workspace_root = workspace;
990
+ state.state_path = statePath;
991
+ state.run_id = "run-6";
992
+ state.source_body_path = join(bodySourceDir, "article.md");
993
+ state.metadata.title = "Inline Asset";
994
+ state.metadata.slug = "inline-asset";
995
+ state.metadata.date = "2026-04-08";
996
+ state.content_review.status = "passed";
997
+ await writeState(statePath, state);
998
+
999
+ await prepareFinalize([
1000
+ "--state",
1001
+ statePath,
1002
+ "--body",
1003
+ bodyPath,
1004
+ "--workspace",
1005
+ workspace,
1006
+ ]);
1007
+
1008
+ const updated = await readState(statePath);
1009
+ const postPath = join(updated.asset_path, "post.md");
1010
+ const postContent = await readFile(postPath, "utf-8");
1011
+ const copiedImage = join(updated.asset_path, "demo.jpg");
1012
+ const copiedImageContent = await readFile(copiedImage, "utf-8");
1013
+
1014
+ expect(postContent).toContain("![示例](./demo.jpg)");
1015
+ expect(copiedImageContent).toBe("demo-image");
1016
+ });
1017
+
1018
+ test("removes the duplicated leading title heading from canonical post.md", async () => {
1019
+ const workspace = await makeTempDir("zzhub-finalize-title-heading-");
1020
+ const bodyPath = join(workspace, "body.md");
1021
+ const statePath = join(workspace, ".zzhub-media", "runs", "run-7.json");
1022
+
1023
+ await writeFile(bodyPath, "## Inline Asset\n\n第一段正文\n", "utf-8");
1024
+
1025
+ const state = defaultState();
1026
+ state.workspace_root = workspace;
1027
+ state.state_path = statePath;
1028
+ state.run_id = "run-7";
1029
+ state.metadata.title = "Inline Asset";
1030
+ state.metadata.slug = "inline-asset";
1031
+ state.metadata.date = "2026-04-08";
1032
+ state.content_review.status = "passed";
1033
+ await writeState(statePath, state);
1034
+
1035
+ await prepareFinalize([
1036
+ "--state",
1037
+ statePath,
1038
+ "--body",
1039
+ bodyPath,
1040
+ "--workspace",
1041
+ workspace,
1042
+ ]);
1043
+
1044
+ const updated = await readState(statePath);
1045
+ const postContent = await readFile(join(updated.asset_path, "post.md"), "utf-8");
1046
+
1047
+ expect(postContent).not.toContain("## Inline Asset");
1048
+ expect(postContent).toContain("第一段正文");
1049
+ });
1050
+ });
1051
+
1052
+ describe("text", () => {
1053
+ test("stripLeadingH1 removes the leading title block only", () => {
1054
+ const input = "\n# 标题\n\n第一段\n\n## 小节\n";
1055
+ expect(stripLeadingH1(input)).toBe("\n第一段\n\n## 小节\n");
1056
+ });
1057
+
1058
+ test("stripLeadingTitleHeading removes a duplicated top title heading", () => {
1059
+ const input = "\n## 标题\n\n第一段\n";
1060
+ expect(stripLeadingTitleHeading(input, "标题")).toBe("\n第一段\n");
1061
+ });
1062
+ });
1063
+
1064
+ describe("config", () => {
1065
+ test("uses configured workspace root when workspace is omitted", async () => {
1066
+ const workspace = await makeTempDir("zzhub-config-root-");
1067
+
1068
+ await writeFile(
1069
+ TEST_CONFIG_PATH,
1070
+ JSON.stringify(
1071
+ {
1072
+ paths: {
1073
+ workspaceRoot: workspace,
1074
+ },
1075
+ },
1076
+ null,
1077
+ 2,
1078
+ ),
1079
+ "utf-8",
1080
+ );
1081
+
1082
+ expect(resolveWorkspaceRoot(undefined)).toBe(workspace);
1083
+ });
1084
+
1085
+ test("init falls back to configured workspace root", async () => {
1086
+ const workspace = await makeTempDir("zzhub-init-config-root-");
1087
+
1088
+ await writeFile(
1089
+ TEST_CONFIG_PATH,
1090
+ JSON.stringify(
1091
+ {
1092
+ paths: {
1093
+ workspaceRoot: workspace,
1094
+ },
1095
+ },
1096
+ null,
1097
+ 2,
1098
+ ),
1099
+ "utf-8",
1100
+ );
1101
+
1102
+ const logs: string[] = [];
1103
+ const originalLog = console.log;
1104
+ console.log = (value?: unknown) => {
1105
+ logs.push(typeof value === "string" ? value : String(value));
1106
+ };
1107
+
1108
+ try {
1109
+ await init([
1110
+ "--task-kind",
1111
+ "publish",
1112
+ "--content-form",
1113
+ "article",
1114
+ "--targets",
1115
+ "wechat",
1116
+ "--content-origin",
1117
+ "user",
1118
+ ]);
1119
+ } finally {
1120
+ console.log = originalLog;
1121
+ }
1122
+
1123
+ const output = JSON.parse(logs.at(-1) ?? "{}") as { state_path?: string };
1124
+ expect(output.state_path?.startsWith(join(workspace, ".zzhub-media", "runs"))).toBe(true);
1125
+ });
1126
+
1127
+ test("init persists newspic render spec from a JSON file", async () => {
1128
+ const workspace = await makeTempDir("zzhub-init-newspic-spec-");
1129
+ const specPath = join(workspace, "newspic-spec.json");
1130
+
1131
+ await writeFile(
1132
+ specPath,
1133
+ JSON.stringify(
1134
+ {
1135
+ pagination_mode: "multi",
1136
+ min_pages: 3,
1137
+ require_image_every_page: true,
1138
+ default_image_layout: "editorial",
1139
+ target_fill_ratio: 0.82,
1140
+ page_specs: [
1141
+ { page: 1, image_markers: ["插图1", "插图2"], target_fill_ratio: 0.88 },
1142
+ { page: 2, image_markers: ["插图3"] },
1143
+ ],
1144
+ },
1145
+ null,
1146
+ 2,
1147
+ ),
1148
+ "utf-8",
1149
+ );
1150
+
1151
+ const logs: string[] = [];
1152
+ const originalLog = console.log;
1153
+ console.log = (value?: unknown) => {
1154
+ logs.push(typeof value === "string" ? value : String(value));
1155
+ };
1156
+
1157
+ try {
1158
+ await init([
1159
+ "--workspace",
1160
+ workspace,
1161
+ "--task-kind",
1162
+ "publish",
1163
+ "--content-form",
1164
+ "newspic",
1165
+ "--targets",
1166
+ "wechat",
1167
+ "--content-origin",
1168
+ "user",
1169
+ "--newspic-render-spec-file",
1170
+ specPath,
1171
+ ]);
1172
+ } finally {
1173
+ console.log = originalLog;
1174
+ }
1175
+
1176
+ const output = JSON.parse(logs.at(-1) ?? "{}") as { state_path?: string };
1177
+ const state = await readState(output.state_path!);
1178
+ expect(state.intent.newspic_render?.pagination_mode).toBe("multi");
1179
+ expect(state.intent.newspic_render?.min_pages).toBe(3);
1180
+ expect(state.intent.newspic_render?.require_image_every_page).toBe(true);
1181
+ expect(state.intent.newspic_render?.default_image_layout).toBe("editorial");
1182
+ expect(state.intent.newspic_render?.target_fill_ratio).toBe(0.82);
1183
+ expect(state.intent.newspic_render?.page_specs[0]?.image_markers).toEqual(["插图1", "插图2"]);
1184
+ expect(state.intent.newspic_render?.page_specs[0]?.target_fill_ratio).toBe(0.88);
1185
+ });
1186
+
1187
+ test("renders posts path patterns with safe title segments", () => {
1188
+ const relativePath = renderPostsRelativePath(
1189
+ {
1190
+ paths: {
1191
+ workspaceRoot: null,
1192
+ postsDirName: "posts",
1193
+ postsPathPattern: "{yyyy-MM}/{title}",
1194
+ blogRoot: null,
1195
+ zotepadExportHtml: null,
1196
+ },
1197
+ services: {
1198
+ zotepadBaseUrl: "http://127.0.0.1:54577",
1199
+ zotepadToken: "zotepad-dev-token",
1200
+ },
1201
+ commands: {
1202
+ blogPublish: ["pnpm", "publish:post"],
1203
+ },
1204
+ wx: {
1205
+ baseUrl: "https://api.example.com",
1206
+ timeout: 30000,
1207
+ defaultAccount: "default",
1208
+ accounts: {
1209
+ default: {
1210
+ pat: "",
1211
+ appId: "",
1212
+ appSecret: "",
1213
+ customCss: null,
1214
+ },
1215
+ },
1216
+ },
1217
+ cos: {
1218
+ pat: "",
1219
+ baseUrl: null,
1220
+ publicBaseUrl: "https://img.example.com",
1221
+ },
1222
+ plugins: {
1223
+ imageRenderer: null,
1224
+ markdownRenderer: null,
1225
+ },
1226
+ imgx: {
1227
+ icon: null,
1228
+ },
1229
+ },
1230
+ {
1231
+ date: "2026-04-08",
1232
+ slug: "ignored-slug",
1233
+ title: "Agent / Prompt: 编排",
1234
+ },
1235
+ );
1236
+
1237
+ expect(relativePath).toBe("2026-04/Agent - Prompt- 编排");
1238
+ });
1239
+
1240
+ test("resolves workspace paths from defaults without hardcoded absolute roots", () => {
1241
+ const paths = resolveWorkspacePaths("/tmp/workspace");
1242
+ expect(paths.postsRoot).toBe("/tmp/workspace/posts");
1243
+ expect(paths.tempRoot).toBe("/tmp/workspace/.zzhub-media/tmp");
1244
+ expect(paths.blogRoot).toBe("/tmp/workspace/blog");
1245
+ expect(paths.zotepadExportHtml).toBe("/tmp/workspace/zotepad-exports/html/post.html");
1246
+ });
1247
+ });
1248
+
1249
+ describe("publish validation", () => {
1250
+ test("blocks publish when content review has not passed and article images are still pending", async () => {
1251
+ const workspace = await makeTempDir("zzhub-publish-validate-");
1252
+ const statePath = join(workspace, "state.json");
1253
+ const state = defaultState();
1254
+
1255
+ state.workspace_root = workspace;
1256
+ state.state_path = statePath;
1257
+ state.run_id = "run-publish";
1258
+ state.asset_path = join(workspace, "posts", "2026-04-08-existing");
1259
+ state.phase.current = "publish";
1260
+ state.phase.prepare.status = "done";
1261
+ state.phase.render.status = "done";
1262
+ state.intent.content_form = "article";
1263
+ state.intent.targets = ["wechat"];
1264
+ state.intent.content_origin = "user";
1265
+ state.intent.requires.render = true;
1266
+ state.intent.requires.publish = true;
1267
+ state.route.primary = "wechat-article";
1268
+ state.metadata.title = "Existing";
1269
+ state.metadata.slug = "existing";
1270
+ state.metadata.date = "2026-04-08";
1271
+ state.content_review.status = "unchecked";
1272
+ state.images.plan.needed = true;
1273
+ state.images.plan.status = "rendered";
1274
+ state.images.body_inputs = {
1275
+ scope: "article",
1276
+ expected: 1,
1277
+ received: [],
1278
+ status: "pending",
1279
+ layout: "staggered",
1280
+ };
1281
+
1282
+ await writeState(statePath, state);
1283
+
1284
+ const errors = validateForPhase(state, "publish");
1285
+ expect(errors.map((item) => item.field)).toContain("content_review.status");
1286
+ expect(errors.map((item) => item.field)).toContain("images.body_inputs.status");
1287
+
1288
+ await expect(
1289
+ publish(["--state", statePath, "--dry-run"]),
1290
+ ).rejects.toThrow("Publish validation failed");
1291
+ });
1292
+
1293
+ test("keeps publish failures retryable instead of switching to handoff", async () => {
1294
+ const workspace = await makeTempDir("zzhub-publish-retry-");
1295
+ const assetPath = join(workspace, "posts", "2026-04-08-existing");
1296
+ const statePath = join(assetPath, "workflow-state.json");
1297
+ const blogRoot = join(workspace, "blog");
1298
+ const state = defaultState();
1299
+
1300
+ await mkdir(assetPath, { recursive: true });
1301
+ await writeFile(join(assetPath, "post.md"), "正文", "utf-8");
1302
+ await writeFile(
1303
+ TEST_CONFIG_PATH,
1304
+ JSON.stringify({
1305
+ paths: {
1306
+ workspaceRoot: workspace,
1307
+ blogRoot,
1308
+ },
1309
+ commands: {
1310
+ blogPublish: ["false"],
1311
+ },
1312
+ }),
1313
+ "utf-8",
1314
+ );
1315
+
1316
+ state.workspace_root = workspace;
1317
+ state.state_path = statePath;
1318
+ state.run_id = "run-publish-retry";
1319
+ state.asset_path = assetPath;
1320
+ state.phase.current = "publish";
1321
+ state.phase.prepare.status = "done";
1322
+ state.phase.render.status = "done";
1323
+ state.intent.requires.render = true;
1324
+ state.intent.requires.publish = true;
1325
+ state.route.primary = "blog";
1326
+ state.metadata.title = "Existing";
1327
+ state.metadata.slug = "existing";
1328
+ state.metadata.date = "2026-04-08";
1329
+ state.content_review.status = "passed";
1330
+ await writeState(statePath, state);
1331
+
1332
+ await publish(["--state", statePath]);
1333
+
1334
+ const updated = await readState(statePath);
1335
+ expect(updated.mode).toBe("active");
1336
+ expect(updated.phase.current).toBe("publish");
1337
+ expect(updated.phase.publish.status).toBe("pending");
1338
+ expect(updated.publish.results[0]?.status).toBe("failed");
1339
+ expect(updated.publish.results[0]?.detail).toContain("Unsupported workflow publish route: blog");
1340
+
1341
+ const task = await getTaskByStatePath(statePath);
1342
+ expect(task.blockers).toEqual([]);
1343
+ expect(task.next_action.action).toBe("publish");
1344
+ });
1345
+ });
1346
+
1347
+ describe("blog sync", () => {
1348
+ test("sync-blog copies canonical markdown to the blog repo without joining workflow routing", async () => {
1349
+ const workspace = await makeTempDir("zzhub-blog-sync-");
1350
+ const assetPath = join(workspace, "posts", "2026-04-10-demo");
1351
+ const statePath = join(assetPath, "workflow-state.json");
1352
+ const postPath = join(assetPath, "post.md");
1353
+ const blogRoot = join(workspace, "blog");
1354
+
1355
+ await mkdir(assetPath, { recursive: true });
1356
+ await mkdir(blogRoot, { recursive: true });
1357
+ await writeFile(
1358
+ TEST_CONFIG_PATH,
1359
+ JSON.stringify(
1360
+ {
1361
+ paths: {
1362
+ workspaceRoot: workspace,
1363
+ blogRoot,
1364
+ },
1365
+ },
1366
+ null,
1367
+ 2,
1368
+ ),
1369
+ "utf-8",
1370
+ );
1371
+ await writeFile(postPath, "---\ntitle: Demo\n---\n\n正文内容\n", "utf-8");
1372
+
1373
+ const state = defaultState();
1374
+ state.workspace_root = workspace;
1375
+ state.asset_path = assetPath;
1376
+ state.state_path = statePath;
1377
+ state.run_id = "20260410-130000-blog11";
1378
+ state.metadata.title = "Demo";
1379
+ state.metadata.slug = "demo";
1380
+ state.metadata.date = "2026-04-10";
1381
+ await writeState(statePath, state);
1382
+
1383
+ const result = await captureJsonOutput<{ route: string; status: string }>(() =>
1384
+ syncBlog(["--state", statePath, "--dry-run"]),
1385
+ );
1386
+ expect(result.route).toBe("blog");
1387
+ expect(result.status).toBe("skipped");
1388
+ });
1389
+ });
1390
+
1391
+ describe("cos upload", () => {
1392
+ test("uploads a local image with configured COS settings", async () => {
1393
+ await writeFile(
1394
+ TEST_CONFIG_PATH,
1395
+ JSON.stringify({
1396
+ wx: { baseUrl: "https://hub.example" },
1397
+ cos: { pat: "cos-pat-123", publicBaseUrl: "https://img.example.com" },
1398
+ }),
1399
+ "utf-8",
1400
+ );
1401
+ const workspace = await makeTempDir("zzhub-cos-upload-");
1402
+ const filePath = join(workspace, "cover.png");
1403
+ await writeFile(filePath, "fake image", "utf-8");
1404
+
1405
+ const upload = createCosUploadCommand(async (input) => {
1406
+ expect(input).toEqual({
1407
+ localPath: filePath,
1408
+ folder: "notes/note-1",
1409
+ baseUrl: "https://hub.example",
1410
+ cosPat: "cos-pat-123",
1411
+ publicBaseUrl: "https://img.example.com",
1412
+ });
1413
+ return {
1414
+ localPath: filePath,
1415
+ key: "notes/note-1/cover.png",
1416
+ url: "https://img.example/notes/note-1/cover.png",
1417
+ };
1418
+ });
1419
+
1420
+ const output = await captureJsonOutput<{
1421
+ local_path: string;
1422
+ key: string;
1423
+ url: string;
1424
+ markdown: string;
1425
+ }>(() => upload(["--file", filePath, "--folder", "notes/note-1", "--alt", "封面"]));
1426
+
1427
+ expect(output).toEqual({
1428
+ local_path: filePath,
1429
+ key: "notes/note-1/cover.png",
1430
+ url: "https://img.example/notes/note-1/cover.png",
1431
+ markdown: "![封面](https://img.example/notes/note-1/cover.png)",
1432
+ });
1433
+ });
1434
+ });
1435
+
1436
+ describe("managed tasks", () => {
1437
+ test("init writes a collision-resistant run id and timestamps", async () => {
1438
+ const workspace = await makeTempDir("zzhub-managed-init-");
1439
+ const output = await captureJsonOutput<{ run_id: string; state_path: string }>(() =>
1440
+ init([
1441
+ "--workspace",
1442
+ workspace,
1443
+ "--task-kind",
1444
+ "publish",
1445
+ "--content-form",
1446
+ "article",
1447
+ "--targets",
1448
+ "wechat",
1449
+ "--content-origin",
1450
+ "user",
1451
+ "--requires-render",
1452
+ "--requires-publish",
1453
+ ]),
1454
+ );
1455
+
1456
+ expect(output.run_id).toMatch(/^\d{8}-\d{6}-[a-z0-9]{6}$/);
1457
+
1458
+ const state = await readState(output.state_path);
1459
+ expect(state.created_at).toBeTruthy();
1460
+ expect(state.updated_at).toBeTruthy();
1461
+ });
1462
+
1463
+ test("tasks lists canonical tasks and status reports next action", async () => {
1464
+ const workspace = await makeTempDir("zzhub-managed-list-");
1465
+ const runStatePath = join(workspace, ".zzhub-media", "runs", "task-1.json");
1466
+ const assetPath = join(workspace, "posts", "2026-04", "Managed Task");
1467
+ const canonicalPath = join(assetPath, "workflow-state.json");
1468
+
1469
+ await mkdir(join(workspace, ".zzhub-media", "runs"), { recursive: true });
1470
+ await mkdir(assetPath, { recursive: true });
1471
+
1472
+ const state = defaultState();
1473
+ state.run_id = "20260410-120000-abc123";
1474
+ state.workspace_root = workspace;
1475
+ state.state_path = canonicalPath;
1476
+ state.asset_path = assetPath;
1477
+ state.created_at = "2026-04-10T12:00:00.000Z";
1478
+ state.updated_at = "2026-04-10T12:05:00.000Z";
1479
+ state.intent.task_kind = "publish";
1480
+ state.intent.content_form = "newspic";
1481
+ state.intent.targets = ["wechat"];
1482
+ state.intent.content_origin = "user";
1483
+ state.intent.requires.render = true;
1484
+ state.intent.requires.publish = true;
1485
+ state.route.primary = "wechat-newspic";
1486
+ state.route.account = "default";
1487
+ state.metadata.title = "Managed Task";
1488
+ state.metadata.slug = "managed-task";
1489
+ state.metadata.date = "2026-04-10";
1490
+ state.content_review.status = "passed";
1491
+ state.phase.current = "render";
1492
+ state.images.body_inputs = {
1493
+ scope: "newspic-longform",
1494
+ expected: 2,
1495
+ received: [{ marker: "插图1", path: "/tmp/1.png" }],
1496
+ status: "pending",
1497
+ layout: "staggered",
1498
+ };
1499
+
1500
+ await writeState(runStatePath, state);
1501
+ await writeState(canonicalPath, state);
1502
+
1503
+ const listed = await captureJsonOutput<Array<{
1504
+ summary: { run_id: string; route: { primary: string; account: string } };
1505
+ next_action: { action: string };
1506
+ blockers: Array<{ code: string }>;
1507
+ }>>(() => tasks(["--workspace", workspace]));
1508
+
1509
+ expect(listed).toHaveLength(1);
1510
+ expect(listed[0]?.summary.run_id).toBe("20260410-120000-abc123");
1511
+ expect(listed[0]?.summary.route.primary).toBe("wechat-newspic");
1512
+ expect(listed[0]?.next_action.action).toBe("attach-body-images");
1513
+ expect(listed[0]?.blockers.map((item) => item.code)).toContain("missing-body-images");
1514
+
1515
+ const taskStatus = await captureJsonOutput<{
1516
+ summary: { run_id: string };
1517
+ next_action: { action: string };
1518
+ blockers: Array<{ code: string }>;
1519
+ }>(() => status(["--workspace", workspace, "--run-id", "20260410-120000-abc123"]));
1520
+
1521
+ expect(taskStatus.summary.run_id).toBe("20260410-120000-abc123");
1522
+ expect(taskStatus.next_action.action).toBe("attach-body-images");
1523
+
1524
+ const found = await captureJsonOutput<{ run_id: string; route: { account: string; primary: string } }>(() =>
1525
+ findRun(["--workspace", workspace, "--route", "wechat-newspic"]),
1526
+ );
1527
+ expect(found.run_id).toBe("20260410-120000-abc123");
1528
+ expect(found.route.account).toBe("default");
1529
+ });
1530
+
1531
+ test("material attachments let pipeline infer the next missing step", async () => {
1532
+ const workspace = await makeTempDir("zzhub-managed-materials-");
1533
+ const bodyPath = join(workspace, "body.md");
1534
+ const specPath = join(workspace, "newspic-spec.json");
1535
+ const imagesPath = join(workspace, "images.json");
1536
+
1537
+ const initOutput = await captureJsonOutput<{ state_path: string; run_id: string }>(() =>
1538
+ init([
1539
+ "--workspace",
1540
+ workspace,
1541
+ "--task-kind",
1542
+ "publish",
1543
+ "--content-form",
1544
+ "newspic",
1545
+ "--targets",
1546
+ "wechat",
1547
+ "--content-origin",
1548
+ "user",
1549
+ "--requires-render",
1550
+ "--requires-publish",
1551
+ ]),
1552
+ );
1553
+
1554
+ await writeFile(
1555
+ bodyPath,
1556
+ [
1557
+ "恋爱关系排行(7个人格)",
1558
+ "",
1559
+ "第一段解释整体排行逻辑。",
1560
+ "",
1561
+ "第二段解释为什么每一类人格都值得单独成图。",
1562
+ "",
1563
+ "第三段补充结论,保证贴图会进入多图路径。",
1564
+ "",
1565
+ "插图1",
1566
+ "插图2",
1567
+ ].join("\n"),
1568
+ "utf-8",
1569
+ );
1570
+ await writeFile(
1571
+ specPath,
1572
+ JSON.stringify(
1573
+ {
1574
+ pagination_mode: "multi",
1575
+ min_pages: 2,
1576
+ require_image_every_page: true,
1577
+ target_fill_ratio: 0.8,
1578
+ page_specs: [
1579
+ { page: 1, image_markers: ["插图1"], target_fill_ratio: null },
1580
+ { page: 2, image_markers: ["插图2"], target_fill_ratio: null },
1581
+ ],
1582
+ },
1583
+ null,
1584
+ 2,
1585
+ ),
1586
+ "utf-8",
1587
+ );
1588
+ await writeFile(
1589
+ imagesPath,
1590
+ JSON.stringify(
1591
+ [
1592
+ { marker: "插图1", path: "/tmp/attach-1.png" },
1593
+ { marker: "插图2", path: "/tmp/attach-2.png" },
1594
+ ],
1595
+ null,
1596
+ 2,
1597
+ ),
1598
+ "utf-8",
1599
+ );
1600
+
1601
+ await captureJsonOutput(() => attachBody(["--state", initOutput.state_path, "--body", bodyPath]));
1602
+ await captureJsonOutput(() => attachNewspicSpec(["--state", initOutput.state_path, "--file", specPath]));
1603
+ await captureJsonOutput(() =>
1604
+ attachBodyImages([
1605
+ "--state",
1606
+ initOutput.state_path,
1607
+ "--images-file",
1608
+ imagesPath,
1609
+ "--scope",
1610
+ "newspic-longform",
1611
+ ]),
1612
+ );
1613
+
1614
+ const afterAttachments = await captureJsonOutput<{
1615
+ summary: { images: { body_inputs: { status: string; expected: number } } };
1616
+ next_action: { action: string };
1617
+ blockers: Array<{ code: string }>;
1618
+ }>(() => status(["--state", initOutput.state_path]));
1619
+
1620
+ expect(afterAttachments.summary.images.body_inputs.status).toBe("ready");
1621
+ expect(afterAttachments.summary.images.body_inputs.expected).toBe(2);
1622
+ expect(afterAttachments.next_action.action).toBe("prepare");
1623
+ expect(afterAttachments.blockers).toEqual([]);
1624
+
1625
+ await prepare([
1626
+ "--state",
1627
+ initOutput.state_path,
1628
+ "--body",
1629
+ bodyPath,
1630
+ ]);
1631
+
1632
+ const afterPrepare = await captureJsonOutput<{
1633
+ next_action: { action: string };
1634
+ }>(() => reconcile(["--state", initOutput.state_path]));
1635
+ expect(afterPrepare.next_action.action).toBe("review-content");
1636
+
1637
+ await review([
1638
+ "--state",
1639
+ initOutput.state_path,
1640
+ "--status",
1641
+ "passed",
1642
+ ]);
1643
+
1644
+ const afterReview = await captureJsonOutput<{
1645
+ next_action: { action: string };
1646
+ }>(() => status(["--state", initOutput.state_path]));
1647
+ expect(afterReview.next_action.action).toBe("prepare-finalize");
1648
+ });
1649
+
1650
+ test("wechat article materials can be attached externally and resumed to publish", async () => {
1651
+ const workspace = await makeTempDir("zzhub-managed-article-materials-");
1652
+ const bodyPath = join(workspace, "body.md");
1653
+ const imagesPath = join(workspace, "images.json");
1654
+
1655
+ const initOutput = await captureJsonOutput<{ state_path: string }>(() =>
1656
+ init([
1657
+ "--workspace",
1658
+ workspace,
1659
+ "--task-kind",
1660
+ "publish",
1661
+ "--content-form",
1662
+ "article",
1663
+ "--targets",
1664
+ "wechat",
1665
+ "--content-origin",
1666
+ "user",
1667
+ "--requires-render",
1668
+ "--requires-publish",
1669
+ ]),
1670
+ );
1671
+
1672
+ await writeFile(
1673
+ bodyPath,
1674
+ [
1675
+ "这是一篇需要插图的公众号文章。",
1676
+ "",
1677
+ "第一部分铺垫背景。",
1678
+ "",
1679
+ "插图1",
1680
+ "",
1681
+ "第二部分继续展开结论。",
1682
+ ].join("\n"),
1683
+ "utf-8",
1684
+ );
1685
+ await writeFile(
1686
+ imagesPath,
1687
+ JSON.stringify([{ marker: "插图1", path: "/tmp/article-1.png" }], null, 2),
1688
+ "utf-8",
1689
+ );
1690
+
1691
+ await captureJsonOutput(() => attachBody(["--state", initOutput.state_path, "--body", bodyPath]));
1692
+ await captureJsonOutput(() =>
1693
+ attachBodyImages([
1694
+ "--state",
1695
+ initOutput.state_path,
1696
+ "--images-file",
1697
+ imagesPath,
1698
+ "--scope",
1699
+ "article",
1700
+ ]),
1701
+ );
1702
+
1703
+ const afterAttachments = await captureJsonOutput<{
1704
+ summary: { images: { body_inputs: { status: string; expected: number } } };
1705
+ next_action: { action: string };
1706
+ }>(() => status(["--state", initOutput.state_path]));
1707
+ expect(afterAttachments.summary.images.body_inputs.status).toBe("ready");
1708
+ expect(afterAttachments.summary.images.body_inputs.expected).toBe(1);
1709
+ expect(afterAttachments.next_action.action).toBe("prepare");
1710
+
1711
+ await prepare([
1712
+ "--state",
1713
+ initOutput.state_path,
1714
+ "--body",
1715
+ bodyPath,
1716
+ ]);
1717
+ await review([
1718
+ "--state",
1719
+ initOutput.state_path,
1720
+ "--status",
1721
+ "passed",
1722
+ ]);
1723
+ await prepareFinalize([
1724
+ "--state",
1725
+ initOutput.state_path,
1726
+ "--body",
1727
+ bodyPath,
1728
+ "--workspace",
1729
+ workspace,
1730
+ ]);
1731
+
1732
+ const beforeRender = await captureJsonOutput<{
1733
+ next_action: { action: string };
1734
+ }>(() => status(["--state", initOutput.state_path]));
1735
+ expect(beforeRender.next_action.action).toBe("render");
1736
+
1737
+ const previousLogPath = process.env.TEST_RENDER_CARD_ARGS_PATH;
1738
+ const previousStubFlag = process.env.TEST_RENDER_CARD_STUB;
1739
+ process.env.TEST_RENDER_CARD_STUB = "1";
1740
+ process.env.TEST_RENDER_CARD_ARGS_PATH = join(workspace, "article-render-log.json");
1741
+ try {
1742
+ await render(["--state", initOutput.state_path]);
1743
+ } finally {
1744
+ if (previousLogPath === undefined) {
1745
+ delete process.env.TEST_RENDER_CARD_ARGS_PATH;
1746
+ } else {
1747
+ process.env.TEST_RENDER_CARD_ARGS_PATH = previousLogPath;
1748
+ }
1749
+ if (previousStubFlag === undefined) {
1750
+ delete process.env.TEST_RENDER_CARD_STUB;
1751
+ } else {
1752
+ process.env.TEST_RENDER_CARD_STUB = previousStubFlag;
1753
+ }
1754
+ }
1755
+
1756
+ await publish(["--state", initOutput.state_path, "--dry-run"]);
1757
+
1758
+ const finished = await captureJsonOutput<{
1759
+ summary: { mode: string; phase: { current: string } };
1760
+ next_action: { action: string };
1761
+ }>(() => status(["--state", initOutput.state_path]));
1762
+ expect(finished.summary.mode).toBe("done");
1763
+ expect(finished.summary.phase.current).toBe("done");
1764
+ expect(finished.next_action.action).toBe("complete");
1765
+ });
1766
+
1767
+ test("tasks and current-task queries handle multiple in-flight tasks", async () => {
1768
+ const workspace = await makeTempDir("zzhub-managed-parallel-");
1769
+ const runRoot = join(workspace, ".zzhub-media", "runs");
1770
+ await mkdir(runRoot, { recursive: true });
1771
+
1772
+ const activeOlderPath = join(runRoot, "active-older.json");
1773
+ const activeNewerPath = join(runRoot, "active-newer.json");
1774
+ const donePath = join(runRoot, "done.json");
1775
+
1776
+ const activeOlder = defaultState();
1777
+ activeOlder.run_id = "20260410-100000-older1";
1778
+ activeOlder.workspace_root = workspace;
1779
+ activeOlder.state_path = activeOlderPath;
1780
+ activeOlder.created_at = "2026-04-10T10:00:00.000Z";
1781
+ activeOlder.updated_at = "2026-04-10T10:10:00.000Z";
1782
+ activeOlder.intent.content_form = "article";
1783
+ activeOlder.intent.targets = ["wechat"];
1784
+ activeOlder.intent.content_origin = "user";
1785
+ activeOlder.intent.requires.render = true;
1786
+ activeOlder.intent.requires.publish = true;
1787
+ activeOlder.source_body_path = "/tmp/older.md";
1788
+ activeOlder.metadata.title = "Older Active";
1789
+ activeOlder.metadata.slug = "older-active";
1790
+ activeOlder.metadata.date = "2026-04-10";
1791
+ activeOlder.route.primary = "wechat-article";
1792
+ activeOlder.route.account = "default";
1793
+ activeOlder.phase.current = "prepare";
1794
+ await writeState(activeOlderPath, activeOlder);
1795
+
1796
+ const activeNewer = defaultState();
1797
+ activeNewer.run_id = "20260410-110000-newer1";
1798
+ activeNewer.workspace_root = workspace;
1799
+ activeNewer.state_path = activeNewerPath;
1800
+ activeNewer.created_at = "2026-04-10T11:00:00.000Z";
1801
+ activeNewer.updated_at = "2026-04-10T11:30:00.000Z";
1802
+ activeNewer.intent.content_form = "newspic";
1803
+ activeNewer.intent.targets = ["wechat"];
1804
+ activeNewer.intent.content_origin = "user";
1805
+ activeNewer.intent.requires.render = true;
1806
+ activeNewer.intent.requires.publish = true;
1807
+ activeNewer.source_body_path = "/tmp/newer.md";
1808
+ activeNewer.metadata.title = "Newer Active";
1809
+ activeNewer.metadata.slug = "newer-active";
1810
+ activeNewer.metadata.date = "2026-04-10";
1811
+ activeNewer.route.primary = "wechat-newspic";
1812
+ activeNewer.route.account = "big";
1813
+ activeNewer.phase.current = "render";
1814
+ activeNewer.images.body_inputs = {
1815
+ scope: "newspic-longform",
1816
+ expected: 2,
1817
+ received: [{ marker: "插图1", path: "/tmp/a.png" }],
1818
+ status: "pending",
1819
+ layout: "staggered",
1820
+ };
1821
+ await writeState(activeNewerPath, activeNewer);
1822
+
1823
+ const done = defaultState();
1824
+ done.run_id = "20260410-090000-done11";
1825
+ done.workspace_root = workspace;
1826
+ done.state_path = donePath;
1827
+ done.created_at = "2026-04-10T09:00:00.000Z";
1828
+ done.updated_at = "2026-04-10T09:30:00.000Z";
1829
+ done.intent.content_form = "article";
1830
+ done.intent.targets = ["wechat"];
1831
+ done.intent.content_origin = "user";
1832
+ done.metadata.title = "Done Task";
1833
+ done.metadata.slug = "done-task";
1834
+ done.metadata.date = "2026-04-10";
1835
+ done.route.primary = "wechat-article";
1836
+ done.mode = "done";
1837
+ done.phase.current = "done";
1838
+ done.phase.prepare.status = "done";
1839
+ done.phase.render.status = "done";
1840
+ done.phase.publish.status = "done";
1841
+ await writeState(donePath, done);
1842
+
1843
+ const activeTasks = await captureJsonOutput<Array<{ summary: { run_id: string } }>>(() =>
1844
+ tasks(["--workspace", workspace, "--active"]),
1845
+ );
1846
+ expect(activeTasks.map((item) => item.summary.run_id)).toEqual([
1847
+ "20260410-110000-newer1",
1848
+ "20260410-100000-older1",
1849
+ ]);
1850
+
1851
+ const currentTask = await captureJsonOutput<{
1852
+ run_id: string;
1853
+ route: { account: string; primary: string };
1854
+ }>(() => findRun(["--workspace", workspace, "--active"]));
1855
+ expect(currentTask.run_id).toBe("20260410-110000-newer1");
1856
+ expect(currentTask.route.account).toBe("big");
1857
+
1858
+ const currentStatus = await captureJsonOutput<{
1859
+ summary: { run_id: string };
1860
+ next_action: { action: string };
1861
+ }>(() => status(["--workspace", workspace]));
1862
+ expect(currentStatus.summary.run_id).toBe("20260410-110000-newer1");
1863
+ expect(currentStatus.next_action.action).toBe("attach-body-images");
1864
+ });
1865
+
1866
+ test("active task filtering excludes stale tasks whose phase is already done", async () => {
1867
+ const workspace = await makeTempDir("zzhub-active-filter-");
1868
+ const runRoot = join(workspace, ".zzhub-media", "runs");
1869
+ await mkdir(runRoot, { recursive: true });
1870
+
1871
+ const stalePath = join(runRoot, "stale.json");
1872
+ const freshPath = join(runRoot, "fresh.json");
1873
+
1874
+ const stale = defaultState();
1875
+ stale.run_id = "20260410-120000-stale1";
1876
+ stale.workspace_root = workspace;
1877
+ stale.state_path = stalePath;
1878
+ stale.mode = "active";
1879
+ stale.phase.current = "done";
1880
+ stale.metadata.title = "Stale Done";
1881
+ await writeState(stalePath, stale);
1882
+
1883
+ const fresh = defaultState();
1884
+ fresh.run_id = "20260410-130000-fresh1";
1885
+ fresh.workspace_root = workspace;
1886
+ fresh.state_path = freshPath;
1887
+ fresh.mode = "active";
1888
+ fresh.phase.current = "prepare";
1889
+ fresh.metadata.title = "Fresh Active";
1890
+ await writeState(freshPath, fresh);
1891
+
1892
+ const activeTasks = await captureJsonOutput<Array<{ summary: { run_id: string } }>>(() =>
1893
+ tasks(["--workspace", workspace, "--active"]),
1894
+ );
1895
+
1896
+ expect(activeTasks.map((item) => item.summary.run_id)).toEqual([
1897
+ "20260410-130000-fresh1",
1898
+ ]);
1899
+ });
1900
+
1901
+ test("can resume from an interrupted mid-task state and finish publish", async () => {
1902
+ const workspace = await makeTempDir("zzhub-managed-resume-");
1903
+ const bodyPath = join(workspace, "body.md");
1904
+
1905
+ await writeFile(
1906
+ bodyPath,
1907
+ [
1908
+ "恢复测试标题",
1909
+ "",
1910
+ "这是一段正文,用来模拟从中途中断后继续执行。",
1911
+ ].join("\n"),
1912
+ "utf-8",
1913
+ );
1914
+
1915
+ const initOutput = await captureJsonOutput<{ state_path: string }>(() =>
1916
+ init([
1917
+ "--workspace",
1918
+ workspace,
1919
+ "--task-kind",
1920
+ "publish",
1921
+ "--content-form",
1922
+ "article",
1923
+ "--targets",
1924
+ "wechat",
1925
+ "--content-origin",
1926
+ "user",
1927
+ "--requires-render",
1928
+ "--requires-publish",
1929
+ ]),
1930
+ );
1931
+
1932
+ await captureJsonOutput(() => attachBody(["--state", initOutput.state_path, "--body", bodyPath]));
1933
+ await prepare([
1934
+ "--state",
1935
+ initOutput.state_path,
1936
+ "--body",
1937
+ bodyPath,
1938
+ ]);
1939
+ await review([
1940
+ "--state",
1941
+ initOutput.state_path,
1942
+ "--status",
1943
+ "passed",
1944
+ ]);
1945
+ await prepareFinalize([
1946
+ "--state",
1947
+ initOutput.state_path,
1948
+ "--body",
1949
+ bodyPath,
1950
+ "--workspace",
1951
+ workspace,
1952
+ ]);
1953
+
1954
+ const interrupted = await captureJsonOutput<{
1955
+ summary: { phase: { current: string } };
1956
+ next_action: { action: string };
1957
+ }>(() => status(["--state", initOutput.state_path]));
1958
+ expect(interrupted.summary.phase.current).toBe("render");
1959
+ expect(interrupted.next_action.action).toBe("render");
1960
+
1961
+ const previousLogPath = process.env.TEST_RENDER_CARD_ARGS_PATH;
1962
+ const previousStubFlag = process.env.TEST_RENDER_CARD_STUB;
1963
+ process.env.TEST_RENDER_CARD_STUB = "1";
1964
+ process.env.TEST_RENDER_CARD_ARGS_PATH = join(workspace, "render-log.json");
1965
+ try {
1966
+ await render(["--state", initOutput.state_path]);
1967
+ } finally {
1968
+ if (previousLogPath === undefined) {
1969
+ delete process.env.TEST_RENDER_CARD_ARGS_PATH;
1970
+ } else {
1971
+ process.env.TEST_RENDER_CARD_ARGS_PATH = previousLogPath;
1972
+ }
1973
+ if (previousStubFlag === undefined) {
1974
+ delete process.env.TEST_RENDER_CARD_STUB;
1975
+ } else {
1976
+ process.env.TEST_RENDER_CARD_STUB = previousStubFlag;
1977
+ }
1978
+ }
1979
+
1980
+ const afterRender = await captureJsonOutput<{
1981
+ summary: { phase: { current: string } };
1982
+ next_action: { action: string };
1983
+ }>(() => status(["--state", initOutput.state_path]));
1984
+ expect(afterRender.summary.phase.current).toBe("publish");
1985
+ expect(afterRender.next_action.action).toBe("publish");
1986
+
1987
+ await publish(["--state", initOutput.state_path, "--dry-run"]);
1988
+
1989
+ const completed = await captureJsonOutput<{
1990
+ summary: { mode: string; phase: { current: string } };
1991
+ next_action: { action: string };
1992
+ }>(() => status(["--state", initOutput.state_path]));
1993
+ expect(completed.summary.mode).toBe("done");
1994
+ expect(completed.summary.phase.current).toBe("done");
1995
+ expect(completed.next_action.action).toBe("complete");
1996
+ });
1997
+
1998
+ test("keeps render as the next action after finalize when init uses underscore flags", async () => {
1999
+ const workspace = await makeTempDir("zzhub-newspic-render-");
2000
+
2001
+ const initOutput = await captureJsonOutput<{ state_path: string }>(() =>
2002
+ init([
2003
+ "--workspace",
2004
+ workspace,
2005
+ "--task-kind",
2006
+ "publish",
2007
+ "--content-form",
2008
+ "newspic",
2009
+ "--targets",
2010
+ "wechat",
2011
+ "--content-origin",
2012
+ "user",
2013
+ "--intent-text",
2014
+ "写一篇公众号贴图,发到大号",
2015
+ "--requires_render",
2016
+ "--requires_publish",
2017
+ ]));
2018
+
2019
+ await captureJsonOutput(() =>
2020
+ attachBody([
2021
+ "--state",
2022
+ initOutput.state_path,
2023
+ "--body-text",
2024
+ "今天天气太好了。\n\n阳光很好,风也很轻,适合出门走一走,把心情也晒亮一点。",
2025
+ ]));
2026
+
2027
+ const prepared = await captureJsonOutput<{ body_formatted_path: string }>(() =>
2028
+ prepare([
2029
+ "--state",
2030
+ initOutput.state_path,
2031
+ "--intent-text",
2032
+ "写一篇公众号贴图,发到大号",
2033
+ "--suggested-title",
2034
+ "今天天气太好了。",
2035
+ ]));
2036
+
2037
+ await review([
2038
+ "--state",
2039
+ initOutput.state_path,
2040
+ "--status",
2041
+ "passed",
2042
+ ]);
2043
+
2044
+ const finalized = await captureJsonOutput<{ state_path: string }>(() =>
2045
+ prepareFinalize([
2046
+ "--state",
2047
+ initOutput.state_path,
2048
+ "--body",
2049
+ prepared.body_formatted_path,
2050
+ ]));
2051
+
2052
+ const taskStatus = await captureJsonOutput<{
2053
+ next_action: { action: string };
2054
+ summary: { phase: { current: string } };
2055
+ }>(() => status(["--state", finalized.state_path]));
2056
+
2057
+ expect(taskStatus.summary.phase.current).toBe("render");
2058
+ expect(taskStatus.next_action.action).toBe("render");
2059
+ });
2060
+ });
2061
+
2062
+ describe("newspic_render default behavior", () => {
2063
+ test("does not auto-infer pagination_mode — defaults to single", async () => {
2064
+ const tmpDir = await mkdtemp(join(tmpdir(), "zzhub-infer-spec-"));
2065
+ const initOutput = await captureJsonOutput<{ state_path: string }>(() =>
2066
+ init([
2067
+ "--workspace", tmpDir,
2068
+ "--task-kind", "publish",
2069
+ "--content-form", "newspic",
2070
+ "--targets", "wechat",
2071
+ "--content-origin", "user",
2072
+ "--intent-text", "写一篇公众号贴图,发到大号",
2073
+ "--requires_render",
2074
+ ]),
2075
+ );
2076
+
2077
+ const longBody = "段落一:" + "AI技术正在改变世界。".repeat(15) + "\n\n"
2078
+ + "段落二:" + "大模型的能力越来越强。".repeat(15) + "\n\n"
2079
+ + "段落三:" + "未来的应用场景无限广阔。".repeat(15);
2080
+
2081
+ await attachBody(["--state", initOutput.state_path, "--body-text", longBody]);
2082
+
2083
+ const finalState = await readState(initOutput.state_path);
2084
+ // Default is "single" — no auto-inference from content length
2085
+ expect(finalState.intent.newspic_render?.pagination_mode ?? "single").toBe("single");
2086
+ });
2087
+ });