@modusensus/dsh-mneme 0.12.0 → 0.13.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.
- package/README.md +48 -19
- package/docs/CONFIGURATION.md +43 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +581 -29
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3537 -1205
- package/lib/config.js +139 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +92 -2
- package/lib/inject.js +189 -18
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +87 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +193 -66
- package/lib/settings.js +112 -14
- package/lib/store.js +347 -21
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +116 -2
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +581 -29
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +139 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +92 -2
- package/src/inject.js +189 -18
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +87 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +193 -66
- package/src/settings.js +112 -14
- package/src/store.js +347 -21
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +116 -2
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +375 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +779 -154
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +355 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +7 -0
- package/test/wiki-t1-jobs.test.js +125 -0
|
@@ -88,7 +88,7 @@ test("deleteEmbedding clears the cached vector; getEmbedding reflects it", () =>
|
|
|
88
88
|
assert.deepEqual(vectorIndex.getEmbedding(m.id), [0, 0, 1]);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("rebuildIndex embeds
|
|
91
|
+
test("rebuildIndex re-embeds rows with no vector and rows with no provenance", async () => {
|
|
92
92
|
const { store, vectorIndex } = setup();
|
|
93
93
|
// One memory already embedded, two missing.
|
|
94
94
|
addMemory(store, { title: "已有向量", content: "A", vector: [0, 1, 0] });
|
|
@@ -101,21 +101,60 @@ test("rebuildIndex embeds only rows missing an embedding and marks the model", a
|
|
|
101
101
|
embedder.embedSingle = async (text) => { seen.push(text); return [1, 0, 0]; };
|
|
102
102
|
|
|
103
103
|
const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
104
|
-
|
|
104
|
+
// #604: the selector is model-aware, so the row that already carried a vector
|
|
105
|
+
// but no fingerprint (embedding_model NULL) is selected too — without this it
|
|
106
|
+
// stayed permanently unattributed and the boot sweep re-embedded it forever.
|
|
107
|
+
assert.equal(result.indexed, 3, "all three rows written with provenance");
|
|
108
|
+
assert.equal(result.failed, 0);
|
|
105
109
|
assert.equal(result.skipped, 0);
|
|
110
|
+
assert.equal(result.remaining, 0, "nothing left for this model");
|
|
106
111
|
assert.equal(vectorIndex.getStats().embeddedCount, 3, "all rows embedded after rebuild");
|
|
107
|
-
assert.equal(seen.length,
|
|
112
|
+
assert.equal(seen.length, 3, "embedSingle called once per selected row");
|
|
113
|
+
assert.equal(store.needsEmbeddingFor("mock#rebuild").length, 0, "provenance recorded, nothing stale");
|
|
108
114
|
|
|
109
|
-
// Rebuild is a no-op once everything
|
|
115
|
+
// Rebuild is a no-op once everything carries this model's fingerprint.
|
|
110
116
|
const again = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
111
117
|
assert.equal(again.indexed, 0);
|
|
112
118
|
assert.equal(again.skipped, 0);
|
|
119
|
+
assert.equal(again.remaining, 0);
|
|
113
120
|
|
|
114
121
|
// Model fingerprint marked from the rebuild embedder.
|
|
115
122
|
assert.equal(vectorIndex.modelHash(), "mock#rebuild");
|
|
116
123
|
assert.equal(vectorIndex.dimension(), 3);
|
|
117
124
|
});
|
|
118
125
|
|
|
126
|
+
test("rebuildIndex picks up rows embedded by a previous model (#604)", async () => {
|
|
127
|
+
const { store, vectorIndex } = setup();
|
|
128
|
+
// Every row already has a vector — from modelA. needsEmbedding() (the old
|
|
129
|
+
// selector) matches only empty vectors, so it would select nothing here and
|
|
130
|
+
// report a successful no-op.
|
|
131
|
+
const a = addMemory(store, { title: "旧模型1", content: "A" });
|
|
132
|
+
const b = addMemory(store, { title: "旧模型2", content: "B" });
|
|
133
|
+
store.setEmbedding(a.id, [0, 1, 0], "modelA#aaa");
|
|
134
|
+
store.setEmbedding(b.id, [0, 1, 0], "modelA#aaa");
|
|
135
|
+
assert.equal(store.needsEmbedding().length, 0, "no row is missing a vector");
|
|
136
|
+
assert.equal(store.needsEmbeddingFor("modelB#bbb").length, 2, "both rows belong to modelA");
|
|
137
|
+
|
|
138
|
+
const embedder = rebuildEmbedder({ vector: [1, 0, 0], modelHash: "modelB#bbb", dimension: 3 });
|
|
139
|
+
const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
140
|
+
|
|
141
|
+
assert.equal(result.indexed, 2, "model switch is what reindex is for");
|
|
142
|
+
assert.equal(result.remaining, 0);
|
|
143
|
+
assert.equal(store.needsEmbeddingFor("modelB#bbb").length, 0, "provenance now says modelB");
|
|
144
|
+
assert.equal(store.needsEmbeddingFor("modelA#aaa").length, 2, "modelA's vectors are the stale ones now");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("rebuildIndex reports remaining work when the limit truncates the batch", async () => {
|
|
148
|
+
const { store, vectorIndex } = setup();
|
|
149
|
+
for (let i = 0; i < 5; i++) addMemory(store, { title: `行${i}`, content: `C${i}` });
|
|
150
|
+
|
|
151
|
+
const embedder = rebuildEmbedder({ vector: [1, 0, 0], modelHash: "mock#limit", dimension: 3 });
|
|
152
|
+
const result = await vectorIndex.rebuildIndex(embedder, { limit: 2 });
|
|
153
|
+
|
|
154
|
+
assert.equal(result.indexed, 2, "limit bounds the batch");
|
|
155
|
+
assert.equal(result.remaining, 3, "remaining counts what the same predicate still selects");
|
|
156
|
+
});
|
|
157
|
+
|
|
119
158
|
test("rebuildIndex handles a throwing embedder without failing the batch", async () => {
|
|
120
159
|
const { store, vectorIndex } = setup();
|
|
121
160
|
addMemory(store, { title: "好行", content: "A" });
|
|
@@ -133,7 +172,11 @@ test("rebuildIndex handles a throwing embedder without failing the batch", async
|
|
|
133
172
|
|
|
134
173
|
const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
135
174
|
assert.equal(result.indexed, 1, "only the successful row is indexed");
|
|
136
|
-
|
|
175
|
+
// #604: a thrown embed and an empty vector are failures, not "skipped" — the
|
|
176
|
+
// old single bucket made them indistinguishable from "did not fit the limit".
|
|
177
|
+
assert.equal(result.failed, 2, "thrown + empty rows count as failures");
|
|
178
|
+
assert.equal(result.skipped, 0, "nothing was skipped for lack of room");
|
|
179
|
+
assert.equal(result.remaining, 2, "failed rows stay selected");
|
|
137
180
|
assert.equal(vectorIndex.getStats().embeddedCount, 1);
|
|
138
181
|
});
|
|
139
182
|
|
|
@@ -142,7 +185,11 @@ test("rebuildIndex guard: embedder with no embed method at all is ignored", asyn
|
|
|
142
185
|
addMemory(store, { title: "缺向量", content: "A" });
|
|
143
186
|
const bare = { modelHash: "mock#bare", dimension: 3 };
|
|
144
187
|
const result = await vectorIndex.rebuildIndex(bare, { limit: 100 });
|
|
145
|
-
assert.deepEqual(
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
result,
|
|
190
|
+
{ indexed: 0, failed: 0, skipped: 0, remaining: 0, error: "no-embedder" },
|
|
191
|
+
"guard returns early when neither embed nor embedSingle is available"
|
|
192
|
+
);
|
|
146
193
|
assert.equal(vectorIndex.modelHash(), undefined, "no model marked");
|
|
147
194
|
});
|
|
148
195
|
|
|
@@ -219,3 +266,70 @@ test("search returns empty when nothing is embedded", () => {
|
|
|
219
266
|
addMemory(store, { title: "也无向量", content: "B" });
|
|
220
267
|
assert.deepEqual(vectorIndex.search([1, 0, 0], { limit: 10 }), []);
|
|
221
268
|
});
|
|
269
|
+
|
|
270
|
+
test("rebuildIndex prefers a detailed adapter so a provider outage is not reported as a dead row (#604)", async () => {
|
|
271
|
+
const { store, vectorIndex } = setup();
|
|
272
|
+
addMemory(store, { title: "甲", content: "A" });
|
|
273
|
+
addMemory(store, { title: "乙", content: "B" });
|
|
274
|
+
|
|
275
|
+
let detailedCalls = 0;
|
|
276
|
+
let plainCalls = 0;
|
|
277
|
+
const embedder = {
|
|
278
|
+
// A 503 from the provider: transient, and only the detailed path can say so.
|
|
279
|
+
embedSingleDetailed: async () => {
|
|
280
|
+
detailedCalls++;
|
|
281
|
+
return { vector: null, reason: "http-503" };
|
|
282
|
+
},
|
|
283
|
+
embedSingle: async () => {
|
|
284
|
+
plainCalls++;
|
|
285
|
+
return null;
|
|
286
|
+
},
|
|
287
|
+
modelHash: "mock#detailed",
|
|
288
|
+
dimension: 3
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
292
|
+
assert.equal(detailedCalls, 2, "every row went through the detailed entry point");
|
|
293
|
+
assert.equal(plainCalls, 0, "the reason-dropping path was not used");
|
|
294
|
+
assert.equal(result.indexed, 0);
|
|
295
|
+
assert.equal(result.failed, 2);
|
|
296
|
+
assert.deepEqual(
|
|
297
|
+
result.failureReasons,
|
|
298
|
+
{ "error:http-503": 2 },
|
|
299
|
+
"the button path keeps the provider reason instead of flattening it"
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("rebuildIndex separates a permanent row from a transient one on the same run (#604)", async () => {
|
|
304
|
+
const { store, vectorIndex } = setup();
|
|
305
|
+
addMemory(store, { title: "甲", content: "A" });
|
|
306
|
+
addMemory(store, { title: "乙", content: "B" });
|
|
307
|
+
|
|
308
|
+
let calls = 0;
|
|
309
|
+
const embedder = {
|
|
310
|
+
embedSingleDetailed: async () => {
|
|
311
|
+
calls++;
|
|
312
|
+
// First row cannot ever embed; second row is a provider hiccup.
|
|
313
|
+
return calls === 1 ? { vector: null, reason: "response-no-vector" } : { vector: null, reason: "http-503" };
|
|
314
|
+
},
|
|
315
|
+
modelHash: "mock#mixed",
|
|
316
|
+
dimension: 3
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
320
|
+
assert.equal(result.failed, 2);
|
|
321
|
+
assert.deepEqual(result.failureReasons, {
|
|
322
|
+
"error:response-no-vector": 1,
|
|
323
|
+
"error:http-503": 1
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("rebuildIndex still names the plain path's failure when no detailed adapter exists (#604)", async () => {
|
|
328
|
+
const { store, vectorIndex } = setup();
|
|
329
|
+
addMemory(store, { title: "甲", content: "A" });
|
|
330
|
+
|
|
331
|
+
const embedder = { embedSingle: async () => null, modelHash: "mock#plain", dimension: 3 };
|
|
332
|
+
const result = await vectorIndex.rebuildIndex(embedder, { limit: 100 });
|
|
333
|
+
assert.equal(result.failed, 1);
|
|
334
|
+
assert.deepEqual(result.failureReasons, { "empty-vector": 1 });
|
|
335
|
+
});
|
package/test/wiki-api.test.js
CHANGED
|
@@ -120,6 +120,11 @@ test("快乐路径:repos / modules / page / search 全通", async () => {
|
|
|
120
120
|
assert.equal(modules.status, 200);
|
|
121
121
|
assert.ok(modules.body.modules.length >= 2);
|
|
122
122
|
assert.ok(Array.isArray(modules.body.edges));
|
|
123
|
+
// 面板的地志页导入要把「根路径 + 页相对路径」拼成磁盘绝对路径(契约 §3.6),
|
|
124
|
+
// 回执里缺 relPath 时它会静默回落到「请先选模块」——这条锁的就是那个真机 bug。
|
|
125
|
+
const withPage = modules.body.modules.filter((m) => m.page_rel_path);
|
|
126
|
+
assert.ok(withPage.length >= 1, "fixture 里至少一个模块有骨架页");
|
|
127
|
+
for (const m of withPage) assert.equal(m.relPath, m.page_rel_path);
|
|
123
128
|
|
|
124
129
|
const page = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}&slug=src`);
|
|
125
130
|
assert.equal(page.status, 200);
|
|
@@ -271,3 +276,353 @@ test("/choro/search:embedder 在场 → 惰性物化 + semantic 数组;缺
|
|
|
271
276
|
rmTempBestEffort(root);
|
|
272
277
|
}
|
|
273
278
|
});
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// 契约 §3.2(kb-qoder parity):面板闭环要的新字段与新入口
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
test("/choro/repos:契约字段齐备,git 漂移 → stale/git,plan 漂移 → stale/plan", async () => {
|
|
285
|
+
const store = createStore(":memory:");
|
|
286
|
+
const root = makeRepo(store);
|
|
287
|
+
try {
|
|
288
|
+
const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
|
|
289
|
+
const url = "/api/dsh-mneme/choro/repos";
|
|
290
|
+
|
|
291
|
+
// 刚重建完:不该有任何过期信号——否则面板会对着一个刚生成的地志喊「代码已变」,
|
|
292
|
+
// 用户第一次用就学会不信任那个标记。
|
|
293
|
+
const fresh = await invoke(handler(routes, url), url);
|
|
294
|
+
assert.equal(fresh.status, 200);
|
|
295
|
+
const a = fresh.body.repos[0];
|
|
296
|
+
for (const key of ["id", "name", "rootPath", "modules", "pages", "claims", "legacy", "stale", "staleReason", "lastRunAt"]) {
|
|
297
|
+
assert.ok(key in a, `契约字段 ${key} 必须在场(面板按它渲染)`);
|
|
298
|
+
}
|
|
299
|
+
assert.equal(a.stale, false);
|
|
300
|
+
assert.equal(a.staleReason, null);
|
|
301
|
+
assert.equal(a.legacy, false);
|
|
302
|
+
assert.equal(typeof a.lastRunAt, "string");
|
|
303
|
+
assert.ok(a.modules >= 2 && a.pages >= 2, "modules/pages 是契约字段名(不是行里的 module_count)");
|
|
304
|
+
|
|
305
|
+
// 代码变了(新 commit)→ 说过期,理由必须是 git:面板那句话是「代码已变」。
|
|
306
|
+
writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
|
|
307
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
308
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
309
|
+
const drifted = await invoke(handler(routes, url), url);
|
|
310
|
+
assert.equal(drifted.body.repos[0].stale, true);
|
|
311
|
+
assert.equal(drifted.body.repos[0].staleReason, "git");
|
|
312
|
+
|
|
313
|
+
// 先重建一次把 git 基线推平(否则 git 那一路永远优先,plan 判据根本轮不到),
|
|
314
|
+
// 再只改 plan.yaml(HEAD 不动)→ 理由必须是 plan:这条判据不依赖 git,不能被
|
|
315
|
+
// git 那一路掩盖掉,否则「改了模板配置但没提交」的仓库永远显示干净。
|
|
316
|
+
runChoroT0({ repoRoot: root, store, apply: true });
|
|
317
|
+
const cleaned = await invoke(handler(routes, url), url);
|
|
318
|
+
assert.equal(cleaned.body.repos[0].stale, false, "重建后 git 基线推平,过期信号应清掉");
|
|
319
|
+
writeFileSync(join(root, ".chorography", "plan.yaml"), "pages: []\n", "utf8");
|
|
320
|
+
const planDrift = await invoke(handler(routes, url), url);
|
|
321
|
+
assert.equal(planDrift.body.repos[0].stale, true);
|
|
322
|
+
assert.equal(planDrift.body.repos[0].staleReason, "plan");
|
|
323
|
+
} finally {
|
|
324
|
+
rmTempBestEffort(root);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("/choro/run:空态按绝对路径登记新仓库,重复路径复用同一 id", async () => {
|
|
329
|
+
const store = createStore(":memory:");
|
|
330
|
+
// 只把仓库目录准备好但不登记:这就是面板「一个仓库都没登记」的真实起点。
|
|
331
|
+
const root = mkdtempSync(join(tmpdir(), "choro-empty-"));
|
|
332
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "emptyfix" }, null, 2) + "\n", "utf8");
|
|
333
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
334
|
+
writeFileSync(join(root, "src", "a.js"), "export const a = 1;\n", "utf8");
|
|
335
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
|
336
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
337
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
338
|
+
try {
|
|
339
|
+
const { routes } = setup({
|
|
340
|
+
config: { chorographyEnabled: true },
|
|
341
|
+
choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
|
|
342
|
+
});
|
|
343
|
+
const route = handler(routes, "/api/dsh-mneme/choro/run");
|
|
344
|
+
const reposUrl = "/api/dsh-mneme/choro/repos";
|
|
345
|
+
const before = await invoke(handler(routes, reposUrl), reposUrl);
|
|
346
|
+
assert.equal(before.body.repos.length, 0, "起点确实是一个仓库都没登记");
|
|
347
|
+
|
|
348
|
+
const first = await invokePost(route, "/api/dsh-mneme/choro/run", { path: root });
|
|
349
|
+
assert.equal(first.status, 200);
|
|
350
|
+
assert.equal(first.body.ok, true);
|
|
351
|
+
assert.ok(first.body.receipt.moduleCount >= 1, "走的是真 T0(有划分回执)");
|
|
352
|
+
const after = await invoke(handler(routes, reposUrl), reposUrl);
|
|
353
|
+
assert.equal(after.body.repos.length, 1, "空态 + 路径 → 自动登记");
|
|
354
|
+
|
|
355
|
+
// 第二次同一路径:不能变成两条记录,也不能报错——面板按钮会被连点。
|
|
356
|
+
const again = await invokePost(route, "/api/dsh-mneme/choro/run", { rootPath: root, refresh: true });
|
|
357
|
+
assert.equal(again.status, 200);
|
|
358
|
+
assert.equal(again.body.refresh, true, "refresh 原样回显,面板据此区分「我要求过重跑」");
|
|
359
|
+
assert.equal((await invoke(handler(routes, reposUrl), reposUrl)).body.repos.length, 1, "同一路径复用既有 id");
|
|
360
|
+
} finally {
|
|
361
|
+
rmTempBestEffort(root);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("/choro/run:路径给错时结构化报错,不落 500 一锅烩", async () => {
|
|
366
|
+
const store = createStore(":memory:");
|
|
367
|
+
const notGit = mkdtempSync(join(tmpdir(), "choro-notgit-"));
|
|
368
|
+
try {
|
|
369
|
+
const { routes } = setup({
|
|
370
|
+
config: { chorographyEnabled: true },
|
|
371
|
+
choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
|
|
372
|
+
});
|
|
373
|
+
const route = handler(routes, "/api/dsh-mneme/choro/run");
|
|
374
|
+
|
|
375
|
+
const rel = await invokePost(route, "/api/dsh-mneme/choro/run", { path: "some/rel/dir" });
|
|
376
|
+
assert.equal(rel.status, 400);
|
|
377
|
+
assert.equal(rel.body.error, "path-not-absolute", "相对路径的语义取决于宿主 cwd,必须挡");
|
|
378
|
+
|
|
379
|
+
const missing = await invokePost(route, "/api/dsh-mneme/choro/run", { path: join(notGit, "nope") });
|
|
380
|
+
assert.equal(missing.status, 400);
|
|
381
|
+
assert.equal(missing.body.error, "path-not-found");
|
|
382
|
+
|
|
383
|
+
const plain = await invokePost(route, "/api/dsh-mneme/choro/run", { path: notGit });
|
|
384
|
+
assert.equal(plain.status, 409);
|
|
385
|
+
assert.equal(plain.body.error, "not-a-git-repo", "目录在但不是 git 仓库 → 409,不是 500");
|
|
386
|
+
} finally {
|
|
387
|
+
rmTempBestEffort(notGit);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// T1 会真花钱(模型调用),所以这里注入假的 startT1Job / t1JobStatus:
|
|
392
|
+
// 路由层要锁的只是闸门与 200/409/404 的分叉形状,不是进程本身。
|
|
393
|
+
function withT1({ startT1Job, t1JobStatus } = {}) {
|
|
394
|
+
const store = createStore(":memory:");
|
|
395
|
+
const root = makeRepo(store);
|
|
396
|
+
const choro = { ...createChoroQueries({ store }) };
|
|
397
|
+
if (startT1Job) choro.startT1Job = startT1Job;
|
|
398
|
+
if (t1JobStatus) choro.t1JobStatus = t1JobStatus;
|
|
399
|
+
return { store, root, choro };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
test("/choro/t1:缺装配 503 / 面板闸关 409 / 同仓并发 409 job-running / 缺省 404", async () => {
|
|
403
|
+
const a = withT1({});
|
|
404
|
+
try {
|
|
405
|
+
const noJobs = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: a.choro });
|
|
406
|
+
const missing = await invokePost(handler(noJobs.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
|
|
407
|
+
assert.equal(missing.status, 503, "没装配任务账 → 503 环境缺件");
|
|
408
|
+
assert.equal(missing.body.error, "unavailable");
|
|
409
|
+
} finally {
|
|
410
|
+
rmTempBestEffort(a.root);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const b = withT1({ startT1Job: () => ({ started: true, jobId: "j-1" }) });
|
|
414
|
+
try {
|
|
415
|
+
const off = setup({
|
|
416
|
+
config: { chorographyEnabled: true, chorographyT1FromPanel: false },
|
|
417
|
+
choro: b.choro
|
|
418
|
+
});
|
|
419
|
+
const disabled = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
|
|
420
|
+
assert.equal(disabled.status, 409, "T1 花模型额度,面板闸关着就不许起");
|
|
421
|
+
assert.equal(disabled.body.error, "disabled");
|
|
422
|
+
|
|
423
|
+
const on = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: b.choro });
|
|
424
|
+
const t1Route = handler(on.routes, "/api/dsh-mneme/choro/t1");
|
|
425
|
+
assert.equal(t1Route.policy, "token-write", "起一个会写页面/claims 的后台任务属数据面写");
|
|
426
|
+
|
|
427
|
+
const ok = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 1, module: "src", retryFailed: true });
|
|
428
|
+
assert.equal(ok.status, 200);
|
|
429
|
+
assert.deepEqual(ok.body, { jobId: "j-1", started: true });
|
|
430
|
+
|
|
431
|
+
const unknown = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 99 });
|
|
432
|
+
assert.equal(unknown.status, 404);
|
|
433
|
+
|
|
434
|
+
const busy = withT1({ startT1Job: () => ({ started: false, code: "job-running", jobId: "j-2" }) });
|
|
435
|
+
try {
|
|
436
|
+
const busySetup = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: busy.choro });
|
|
437
|
+
const running = await invokePost(handler(busySetup.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
|
|
438
|
+
assert.equal(running.status, 409);
|
|
439
|
+
assert.equal(running.body.error, "job-running");
|
|
440
|
+
assert.equal(running.body.jobId, "j-2", "把在跑的那个 jobId 带回给面板,好让它接着轮询而不是重起");
|
|
441
|
+
} finally {
|
|
442
|
+
rmTempBestEffort(busy.root);
|
|
443
|
+
}
|
|
444
|
+
} finally {
|
|
445
|
+
rmTempBestEffort(b.root);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("/choro/t1/status:jobId 缺 400、未知 404、已知 200 带契约字段", async () => {
|
|
450
|
+
const job = {
|
|
451
|
+
jobId: "j-9",
|
|
452
|
+
repo: { id: 1, name: "x", rootPath: "/x" },
|
|
453
|
+
state: "running",
|
|
454
|
+
startedAt: "T0",
|
|
455
|
+
modules: { total: 2, done: 1, failed: 0 },
|
|
456
|
+
logTail: ["line"]
|
|
457
|
+
};
|
|
458
|
+
const f = withT1({ startT1Job: () => ({ started: true, jobId: "j-9" }), t1JobStatus: (id) => (id === "j-9" ? job : null) });
|
|
459
|
+
try {
|
|
460
|
+
const { routes } = setup({ config: { chorographyEnabled: true }, choro: f.choro });
|
|
461
|
+
const route = handler(routes, "/api/dsh-mneme/choro/t1/status");
|
|
462
|
+
|
|
463
|
+
const noId = await invoke(route, "/api/dsh-mneme/choro/t1/status");
|
|
464
|
+
assert.equal(noId.status, 400);
|
|
465
|
+
assert.equal(noId.body.error, "jobId-required");
|
|
466
|
+
|
|
467
|
+
const gone = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=nope");
|
|
468
|
+
assert.equal(gone.status, 404, "宿主重启后旧 jobId 必然查不到,面板据此回到「没有在跑的任务」");
|
|
469
|
+
|
|
470
|
+
const found = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=j-9");
|
|
471
|
+
assert.equal(found.status, 200);
|
|
472
|
+
assert.equal(found.body.state, "running");
|
|
473
|
+
assert.equal(found.body.modules.total, 2);
|
|
474
|
+
assert.deepEqual(found.body.logTail, ["line"]);
|
|
475
|
+
} finally {
|
|
476
|
+
rmTempBestEffort(f.root);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// chorographyAutoRefresh:读侧自动重跑 T0(面板文案已经把它当契约写死了:
|
|
482
|
+
// 「读地志页时如果发现仓库代码变了,自动重跑一次 T0 骨架」)。
|
|
483
|
+
// 这一批同时锁「开关关着绝不偷偷改用户工作区」与「开关开着真的会刷」——只锁一端
|
|
484
|
+
// 的测试在实现成「永远不刷」或「永远刷」时都会绿,等于没锁。
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
// 计数外壳:真实 queries + 包一层的 runT0Apply(记调用次数、可注入失败)。
|
|
488
|
+
// 不造一个假 T0——「刷了但基线没推进」这种 bug 只有真 T0 才照得出来。
|
|
489
|
+
function countingChoro(store, { throwOnRun = false } = {}) {
|
|
490
|
+
const calls = [];
|
|
491
|
+
const base = createChoroQueries({ store });
|
|
492
|
+
return {
|
|
493
|
+
calls,
|
|
494
|
+
choro: {
|
|
495
|
+
...base,
|
|
496
|
+
runT0Apply: ({ repoRoot }) => {
|
|
497
|
+
calls.push(repoRoot);
|
|
498
|
+
if (throwOnRun) throw new Error("磁盘只读(模拟)");
|
|
499
|
+
return runChoroT0({ repoRoot, store, apply: true });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// 造一个「代码已变」的仓库:登记时的 git_baseline 与当前 HEAD 不同。
|
|
506
|
+
function driftRepo(store) {
|
|
507
|
+
const root = makeRepo(store);
|
|
508
|
+
writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
|
|
509
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
510
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
511
|
+
return root;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
test("/choro/repos 自动重跑:开关关着时即使过期也一次都不刷(默认行为逐字节不变)", async () => {
|
|
515
|
+
const store = createStore(":memory:");
|
|
516
|
+
const root = driftRepo(store);
|
|
517
|
+
try {
|
|
518
|
+
const { calls, choro } = countingChoro(store);
|
|
519
|
+
const { routes } = setup({ config: { chorographyEnabled: true }, choro });
|
|
520
|
+
const url = "/api/dsh-mneme/choro/repos?repo=1";
|
|
521
|
+
const REPOS_PATH = "/api/dsh-mneme/choro/repos";
|
|
522
|
+
const res = await invoke(handler(routes, REPOS_PATH), url);
|
|
523
|
+
assert.equal(res.status, 200);
|
|
524
|
+
assert.equal(res.body.repos[0].staleReason, "git", "前提:这个仓库确实是过期的");
|
|
525
|
+
assert.equal(calls.length, 0, "开关缺省 false 时读侧一个字节都不许动用户的工作区");
|
|
526
|
+
assert.equal(res.body.autoRefreshed, false);
|
|
527
|
+
} finally {
|
|
528
|
+
rmTempBestEffort(root);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("/choro/repos 自动重跑:开关开 + ?repo= 点名且 git 过期 → 刷一次,回执按新基线说话", async () => {
|
|
533
|
+
const store = createStore(":memory:");
|
|
534
|
+
const root = driftRepo(store);
|
|
535
|
+
try {
|
|
536
|
+
const { calls, choro } = countingChoro(store);
|
|
537
|
+
const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
|
|
538
|
+
const url = "/api/dsh-mneme/choro/repos?repo=1";
|
|
539
|
+
const REPOS_PATH = "/api/dsh-mneme/choro/repos";
|
|
540
|
+
const res = await invoke(handler(routes, REPOS_PATH), url);
|
|
541
|
+
assert.equal(res.status, 200);
|
|
542
|
+
assert.equal(calls.length, 1, "开关开 + 指名 + git 过期 = 恰好刷这一个仓库");
|
|
543
|
+
assert.equal(res.body.autoRefreshed, true);
|
|
544
|
+
// 刷完必须改成不 stale:否则面板刚刷完就显示「代码已变」,等于谎报一次失败。
|
|
545
|
+
assert.equal(res.body.repos[0].stale, false);
|
|
546
|
+
assert.equal(res.body.repos[0].staleReason, null);
|
|
547
|
+
} finally {
|
|
548
|
+
rmTempBestEffort(root);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("/choro/repos 自动重跑:不 stale 或没点名 ?repo= 时都不刷(一次面板加载不能扫全库)", async () => {
|
|
553
|
+
const store = createStore(":memory:");
|
|
554
|
+
const cleanRoot = makeRepo(store);
|
|
555
|
+
try {
|
|
556
|
+
const { calls, choro } = countingChoro(store);
|
|
557
|
+
const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
|
|
558
|
+
const plain = "/api/dsh-mneme/choro/repos";
|
|
559
|
+
const clean = await invoke(handler(routes, plain), plain);
|
|
560
|
+
assert.equal(clean.status, 200);
|
|
561
|
+
assert.equal(calls.length, 0, "staleReason 为 null → 没有刷的理由");
|
|
562
|
+
|
|
563
|
+
// 造第二个过期仓库,但请求不带 ?repo=:列表路径一个都不许刷。
|
|
564
|
+
const drifted = driftRepo(store);
|
|
565
|
+
try {
|
|
566
|
+
const listed = await invoke(handler(routes, plain), plain);
|
|
567
|
+
assert.equal(listed.body.repos.length, 2);
|
|
568
|
+
assert.equal(calls.length, 0, "没点名就不刷:否则打开面板要付 N 次全仓扫描");
|
|
569
|
+
assert.equal(listed.body.autoRefreshed, false);
|
|
570
|
+
assert.ok(listed.body.repos.some((r) => r.staleReason === "git"), "过期信号照常回,只是不顺手刷");
|
|
571
|
+
} finally {
|
|
572
|
+
rmTempBestEffort(drifted);
|
|
573
|
+
}
|
|
574
|
+
} finally {
|
|
575
|
+
rmTempBestEffort(cleanRoot);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("/choro/repos 自动重跑失败:读照常 200、数据在、错误进 autoRefreshError", async () => {
|
|
580
|
+
const store = createStore(":memory:");
|
|
581
|
+
const root = driftRepo(store);
|
|
582
|
+
try {
|
|
583
|
+
const { calls, choro } = countingChoro(store, { throwOnRun: true });
|
|
584
|
+
const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
|
|
585
|
+
const url = "/api/dsh-mneme/choro/repos?repo=1";
|
|
586
|
+
const REPOS_PATH = "/api/dsh-mneme/choro/repos";
|
|
587
|
+
const res = await invoke(handler(routes, REPOS_PATH), url);
|
|
588
|
+
// 读是主诉求、刷新是附赠:打成 500 等于「顺手的事没办成,正事也不给办」。
|
|
589
|
+
assert.equal(res.status, 200, "刷新失败不许把读请求打成 500");
|
|
590
|
+
assert.equal(calls.length, 1);
|
|
591
|
+
assert.equal(res.body.autoRefreshed, false);
|
|
592
|
+
assert.match(res.body.autoRefreshError, /磁盘只读/);
|
|
593
|
+
assert.equal(res.body.repos.length, 1, "数据必须还在");
|
|
594
|
+
assert.equal(res.body.repos[0].staleReason, "git", "没刷成功就照实说过期");
|
|
595
|
+
} finally {
|
|
596
|
+
rmTempBestEffort(root);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// /choro/page 是面板主路径,也是「读地志页时发现代码变了」那句文案的真正时刻。
|
|
601
|
+
// 顶栏「代码已变」徽章读 state.repo.stale —— 回执里没有 repo 这份数据,徽章永远不亮。
|
|
602
|
+
test("/choro/page 回执带 repo.stale/staleReason,并在这条读路径上自动重跑", async () => {
|
|
603
|
+
const store = createStore(":memory:");
|
|
604
|
+
const root = driftRepo(store);
|
|
605
|
+
try {
|
|
606
|
+
const { calls, choro } = countingChoro(store);
|
|
607
|
+
const url = "/api/dsh-mneme/choro/page?repo=1&slug=src";
|
|
608
|
+
const PAGE_PATH = "/api/dsh-mneme/choro/page";
|
|
609
|
+
|
|
610
|
+
const offSetup = setup({ config: { chorographyEnabled: true }, choro });
|
|
611
|
+
const off = await invoke(handler(offSetup.routes, PAGE_PATH), url);
|
|
612
|
+
assert.equal(off.status, 200);
|
|
613
|
+
assert.equal(off.body.repo.stale, true, "徽章的数据源:开关关着也要照实报过期");
|
|
614
|
+
assert.equal(off.body.repo.staleReason, "git");
|
|
615
|
+
assert.equal(off.body.autoRefreshed, false);
|
|
616
|
+
assert.equal(calls.length, 0);
|
|
617
|
+
|
|
618
|
+
const onSetup = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
|
|
619
|
+
const on = await invoke(handler(onSetup.routes, PAGE_PATH), url);
|
|
620
|
+
assert.equal(on.status, 200);
|
|
621
|
+
assert.equal(calls.length, 1, "页读路径点名的就是这一个仓库");
|
|
622
|
+
assert.equal(on.body.autoRefreshed, true);
|
|
623
|
+
assert.equal(on.body.repo.stale, false, "刷完页面与 repo 徽章必须同一时刻一致");
|
|
624
|
+
assert.ok(on.body.page.content.includes("## 文件清单"), "页正文照常回");
|
|
625
|
+
} finally {
|
|
626
|
+
rmTempBestEffort(root);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// dsh-mneme/test/wiki-cli-hint.test.js
|
|
2
|
+
// issue #607 的回归锁:提示里那条命令必须真能粘贴就跑。
|
|
3
|
+
//
|
|
4
|
+
// 这条 issue 的病因不是「文案不好看」,是**给了一条跑不起来的命令**:插件装在
|
|
5
|
+
// profile 依赖里,npm 只为全局安装把 shim 放进 PATH,用户照抄 dsh-mneme-choro
|
|
6
|
+
// 必然 command not found。所以真正要锁的不是字符串长相,而是三件事:
|
|
7
|
+
// 1) 推导出的入口在这个仓库里真实存在(下面的 existsSync 与实跑验证);
|
|
8
|
+
// 2) 推导不依赖调用方在第几层目录(src/ 与 lib/ 同层、src/wiki/ 在第二层、
|
|
9
|
+
// bin/ 在第一层)——写死层数会悄悄算错,而算错的表现正是又给一条跑不起来的命令;
|
|
10
|
+
// 3) 找不到入口时返回 null 让调用方降级,而不是硬编一个假路径。
|
|
11
|
+
//
|
|
12
|
+
// 引号部分单独锁:安装路径带空格是常态(Windows 的 C:\Program Files\),
|
|
13
|
+
// POSIX 路径带单引号也必须转义成 '\'',否则「带引号的命令」照样跑不起来。
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { choroCliCommand, choroCliCommandFrom, choroCliEntry } from "../src/wiki/cli-hint.js";
|
|
23
|
+
|
|
24
|
+
const ENTRY_TAIL = join("bin", "dsh-mneme-choro.mjs");
|
|
25
|
+
|
|
26
|
+
test("从包内任意深度都能推出同一个 CLI 入口(不按固定层数)", () => {
|
|
27
|
+
// 四个不同深度、且真实存在于本仓的模块位置;写死 dirname 次数的实现只会有
|
|
28
|
+
// 其中一部分对,这条断言就会红。
|
|
29
|
+
const froms = [
|
|
30
|
+
new URL("../package.json", import.meta.url), // test/ -> 1 层
|
|
31
|
+
new URL("../src/tools.js", import.meta.url), // src/ -> 1 层
|
|
32
|
+
new URL("../src/wiki/t1.js", import.meta.url), // src/wiki/ -> 2 层
|
|
33
|
+
new URL("../bin/dsh-mneme-choro.mjs", import.meta.url) // bin/ -> 0 层
|
|
34
|
+
];
|
|
35
|
+
const entries = froms.map((u) => choroCliEntry(u.href));
|
|
36
|
+
for (const [i, entry] of entries.entries()) {
|
|
37
|
+
assert.equal(typeof entry, "string", "caller #" + i + " must resolve an entry");
|
|
38
|
+
assert.ok(existsSync(entry), "caller #" + i + " resolved a path that does not exist: " + entry);
|
|
39
|
+
assert.ok(entry.endsWith(ENTRY_TAIL), entry);
|
|
40
|
+
}
|
|
41
|
+
// 四条路径必须收敛到同一个入口,否则「同一份代码在不同文件里给出不同命令」
|
|
42
|
+
assert.equal(new Set(entries).size, 1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("推出来的入口是这个包自己的 bin(不是同名外来目录)", () => {
|
|
46
|
+
const entry = choroCliEntry(new URL("../src/wiki/t1.js", import.meta.url).href);
|
|
47
|
+
assert.ok(entry);
|
|
48
|
+
// 包根必须同时有 bin/ 与 package.json —— 这正是推导的两个条件,反过来验一遍
|
|
49
|
+
const pkgRoot = entry.slice(0, entry.length - ENTRY_TAIL.length - 1);
|
|
50
|
+
assert.ok(existsSync(join(pkgRoot, "package.json")), pkgRoot);
|
|
51
|
+
assert.equal(join(pkgRoot, ENTRY_TAIL), entry);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("源码树被裁剪(找不到 bin 或 package.json)时返回 null,不硬编假路径", () => {
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-cli-hint-"));
|
|
56
|
+
try {
|
|
57
|
+
// 只有 bin/ 没有 package.json:可能是别人的同名 bin,不能认
|
|
58
|
+
mkdirSync(join(dir, "bin"), { recursive: true });
|
|
59
|
+
writeFileSync(join(dir, "bin", "dsh-mneme-choro.mjs"), "");
|
|
60
|
+
assert.equal(choroCliEntry(pathToFileURL(join(dir, "bin", "dsh-mneme-choro.mjs")).href), null);
|
|
61
|
+
|
|
62
|
+
// 补齐 package.json 就该认出来(证明上一条的红不是「永远返回 null」)
|
|
63
|
+
writeFileSync(join(dir, "package.json"), "{}");
|
|
64
|
+
assert.equal(
|
|
65
|
+
choroCliEntry(pathToFileURL(join(dir, "bin", "dsh-mneme-choro.mjs")).href),
|
|
66
|
+
join(dir, "bin", "dsh-mneme-choro.mjs")
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 只有 package.json 没有 bin/:`files` 少了 bin 的裁剪树,同样不能认
|
|
70
|
+
const bare = mkdtempSync(join(tmpdir(), "mneme-cli-hint-bare-"));
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(join(bare, "package.json"), "{}");
|
|
73
|
+
mkdirSync(join(bare, "src"), { recursive: true });
|
|
74
|
+
assert.equal(choroCliEntry(pathToFileURL(join(bare, "src", "tools.js")).href), null);
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(bare, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("命令带引号:Windows 双引号,POSIX 单引号(路径含空格与单引号都要能跑)", () => {
|
|
84
|
+
// Windows:安装路径带空格是常态,不加引号那条命令在 cmd 里会被拆成两段
|
|
85
|
+
assert.equal(
|
|
86
|
+
choroCliCommand("C:\\Program Files\\mneme\\bin\\dsh-mneme-choro.mjs", "<repo>", "win32"),
|
|
87
|
+
'node "C:\\Program Files\\mneme\\bin\\dsh-mneme-choro.mjs" <repo> --apply'
|
|
88
|
+
);
|
|
89
|
+
// POSIX:无空格时也加引号(统一形态,便于用户照着改)
|
|
90
|
+
assert.equal(
|
|
91
|
+
choroCliCommand("/opt/mneme/bin/dsh-mneme-choro.mjs", "<repo>", "linux"),
|
|
92
|
+
"node '/opt/mneme/bin/dsh-mneme-choro.mjs' <repo> --apply"
|
|
93
|
+
);
|
|
94
|
+
// POSIX:路径里有单引号,必须转成 '\'',否则 shell 把命令截断在引号处
|
|
95
|
+
assert.equal(
|
|
96
|
+
choroCliCommand("/opt/it's here/bin/dsh-mneme-choro.mjs", "<repo>", "linux"),
|
|
97
|
+
"node '/opt/it'\\''s here/bin/dsh-mneme-choro.mjs' <repo> --apply"
|
|
98
|
+
);
|
|
99
|
+
// 占位符原样保留(面板给 <仓库路径>,工具报错给 <repo>),不被引号逻辑吞掉
|
|
100
|
+
assert.equal(
|
|
101
|
+
choroCliCommandFrom(new URL("../src/tools.js", import.meta.url).href, "<仓库路径>", "win32").endsWith(" <仓库路径> --apply"),
|
|
102
|
+
true
|
|
103
|
+
);
|
|
104
|
+
// 入口缺失时整体返回 null —— 调用方据此降级,绝不输出半条命令
|
|
105
|
+
assert.equal(choroCliCommand(null, "<repo>", "win32"), null);
|
|
106
|
+
assert.equal(choroCliCommandFrom("not a url", "<repo>", "win32"), null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("真的能跑:用推导出的入口执行一次 --help(issue 的验收就是「用户能跑起来」)", () => {
|
|
110
|
+
const entry = choroCliEntry(new URL("../src/wiki/t1.js", import.meta.url).href);
|
|
111
|
+
assert.ok(entry);
|
|
112
|
+
// 用真实入口起一次进程:issue 的验收标准就是「用户照着这条命令能跑起来」,
|
|
113
|
+
const res = spawnSync(process.execPath, [entry, "--help"], { encoding: "utf8", timeout: 30000 });
|
|
114
|
+
assert.equal(res.status, 0, entry + " --help failed: " + (res.stderr || res.stdout));
|
|
115
|
+
assert.match(res.stdout, /--apply/);
|
|
116
|
+
});
|
|
117
|
+
// 用真实入口起一次进程:issue 的验收标准就是「用户照着这条命令能跑起来」,
|
|
118
|
+
// 字符串层面的断言证明不了这件事。
|