@letta-ai/letta-code 0.24.12 → 0.25.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/letta.js +128355 -81818
- package/package.json +4 -1
- package/scripts/check-test-mock-isolation.js +438 -22
- package/scripts/check.js +1 -1
- package/vendor/ink-text-input/build/index.js +14 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letta-ai/letta-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,12 +48,15 @@
|
|
|
48
48
|
"@vscode/ripgrep": "^1.17.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@ai-sdk/anthropic": "^3.0.73",
|
|
52
|
+
"@ai-sdk/openai": "^3.0.55",
|
|
51
53
|
"@slack/bolt": "^4.7.0",
|
|
52
54
|
"@types/bun": "^1.3.7",
|
|
53
55
|
"@types/diff": "^8.0.0",
|
|
54
56
|
"@types/picomatch": "^4.0.2",
|
|
55
57
|
"@types/react": "^19.2.9",
|
|
56
58
|
"@types/ws": "^8.18.1",
|
|
59
|
+
"ai": "^6.0.171",
|
|
57
60
|
"diff": "^8.0.2",
|
|
58
61
|
"grammy": "^1.42.0",
|
|
59
62
|
"husky": "9.1.7",
|
|
@@ -1,12 +1,77 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
4
|
-
import { join, relative } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const TESTS_DIR_ENV = "LETTA_MOCK_ISOLATION_TESTS_DIR";
|
|
5
7
|
|
|
6
8
|
const rootDir = process.cwd();
|
|
7
|
-
const testsDir =
|
|
9
|
+
const testsDir = process.env[TESTS_DIR_ENV]
|
|
10
|
+
? resolve(process.env[TESTS_DIR_ENV])
|
|
11
|
+
: join(rootDir, "src", "tests");
|
|
12
|
+
|
|
13
|
+
const FORBIDDEN_MOCK_MODULES = new Map([
|
|
14
|
+
[
|
|
15
|
+
"/channels/config",
|
|
16
|
+
"Use __testOverrideChannelsRoot() instead of replacing the shared channel config module.",
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
"/agent/context",
|
|
20
|
+
"Use explicit env/context override seams instead of mocking the shared agent context module.",
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
"/runtime-context",
|
|
24
|
+
"Use RuntimeContextSnapshot builders/overrides instead of mocking the shared runtime context module.",
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
"/settings-manager",
|
|
28
|
+
"Use settings temp files or test override helpers instead of mocking the singleton settings manager.",
|
|
29
|
+
],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const COMPLETE_EXPORT_MOCK_MODULES = new Set([
|
|
33
|
+
"/channels/slack/runtime",
|
|
34
|
+
"/channels/telegram/runtime",
|
|
35
|
+
"/channels/discord/runtime",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Existing top-level module mocks that predate this guard. New top-level
|
|
39
|
+
// internal mocks are rejected by default because they are active while Bun loads
|
|
40
|
+
// other test files in the same process. Prefer dependency injection or an
|
|
41
|
+
// explicit test override helper. If a new top-level mock is truly unavoidable,
|
|
42
|
+
// add a file+module entry here in the same PR with a clear explanation.
|
|
43
|
+
const ALLOWED_TOP_LEVEL_MOCKS = new Set([
|
|
44
|
+
"src/tests/channels/discord-registry.test.ts::../../backend/api/client",
|
|
45
|
+
"src/tests/channels/slack-adapter-interop.test.ts::../../channels/slack/media",
|
|
46
|
+
"src/tests/channels/slack-adapter-interop.test.ts::../../channels/slack/runtime",
|
|
47
|
+
"src/tests/channels/slack-adapter.test.ts::../../channels/slack/media",
|
|
48
|
+
"src/tests/channels/slack-adapter.test.ts::../../channels/slack/runtime",
|
|
49
|
+
"src/tests/channels/telegram-adapter.test.ts::../../channels/telegram/runtime",
|
|
50
|
+
"src/tests/cli/message-search-cache-warm.test.ts::../../backend/api/search",
|
|
51
|
+
"src/tests/hooks/prompt-executor.test.ts::../../backend/api/generate",
|
|
52
|
+
"src/tests/tools/memory-apply-patch.test.ts::../../backend/api/client",
|
|
53
|
+
"src/tests/tools/memory-tool.test.ts::../../backend/api/client",
|
|
54
|
+
"src/tests/tools/toolset-client-tool-rule-cleanup.test.ts::../../backend/api/client",
|
|
55
|
+
"src/tests/tools/toolset-memfs-detach.test.ts::../../backend/api/client",
|
|
56
|
+
"src/tests/websocket/listen-client-concurrency.test.ts::../../agent/approval-execution",
|
|
57
|
+
"src/tests/websocket/listen-client-concurrency.test.ts::../../agent/approval-recovery",
|
|
58
|
+
"src/tests/websocket/listen-client-concurrency.test.ts::../../agent/message",
|
|
59
|
+
"src/tests/websocket/listen-client-concurrency.test.ts::../../backend/api/client",
|
|
60
|
+
"src/tests/websocket/listen-client-concurrency.test.ts::../../cli/helpers/approvalClassification",
|
|
61
|
+
"src/tests/websocket/listen-client-concurrency.test.ts::../../cli/helpers/stream",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const mockModulePattern = /\bmock\.module\s*\(\s*(["'`])([^"'`]+)\1/g;
|
|
65
|
+
const restoreHookPattern =
|
|
66
|
+
/\bafter(?:All|Each)\s*\(\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>[\s\S]*?\bmock\.restore\s*\(/m;
|
|
67
|
+
const restoreHookFunctionPattern =
|
|
68
|
+
/\bafter(?:All|Each)\s*\(\s*function\b[\s\S]*?\bmock\.restore\s*\(/m;
|
|
8
69
|
|
|
9
70
|
function collectTestFiles(dir) {
|
|
71
|
+
if (!existsSync(dir)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
10
75
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
11
76
|
const files = [];
|
|
12
77
|
|
|
@@ -27,11 +92,259 @@ function collectTestFiles(dir) {
|
|
|
27
92
|
return files;
|
|
28
93
|
}
|
|
29
94
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
95
|
+
function normalizeModuleSpecifier(moduleSpecifier) {
|
|
96
|
+
return moduleSpecifier
|
|
97
|
+
.replaceAll("\\", "/")
|
|
98
|
+
.replace(/\.(?:ts|tsx|js|jsx)$/u, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function moduleMatches(moduleSpecifier, suffixes) {
|
|
102
|
+
const normalized = normalizeModuleSpecifier(moduleSpecifier);
|
|
103
|
+
for (const suffix of suffixes) {
|
|
104
|
+
if (normalized.endsWith(suffix)) {
|
|
105
|
+
return suffix;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isRelativeInternalModule(moduleSpecifier) {
|
|
112
|
+
return moduleSpecifier.startsWith("../") || moduleSpecifier.startsWith("./");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function lineAndColumn(sourceText, index) {
|
|
116
|
+
const before = sourceText.slice(0, index);
|
|
117
|
+
const lines = before.split("\n");
|
|
118
|
+
return {
|
|
119
|
+
line: lines.length,
|
|
120
|
+
column: (lines.at(-1)?.length ?? 0) + 1,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isTopLevelMockCall(sourceText, index) {
|
|
125
|
+
const lineStart = sourceText.lastIndexOf("\n", index - 1) + 1;
|
|
126
|
+
return lineStart === index;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isLineCommentMatch(sourceText, index) {
|
|
130
|
+
const lineStart = sourceText.lastIndexOf("\n", index - 1) + 1;
|
|
131
|
+
const linePrefix = sourceText.slice(lineStart, index);
|
|
132
|
+
return linePrefix.includes("//");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isInsideQuotedText(sourceText, index) {
|
|
136
|
+
let quote = null;
|
|
137
|
+
let escaped = false;
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < index; i += 1) {
|
|
140
|
+
const char = sourceText[i];
|
|
141
|
+
|
|
142
|
+
if (quote) {
|
|
143
|
+
if (escaped) {
|
|
144
|
+
escaped = false;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (char === "\\") {
|
|
148
|
+
escaped = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (char === quote) {
|
|
152
|
+
quote = null;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
158
|
+
quote = char;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return quote !== null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findMatchingParen(sourceText, openParenIndex) {
|
|
166
|
+
let depth = 0;
|
|
167
|
+
let quote = null;
|
|
168
|
+
let escaped = false;
|
|
169
|
+
|
|
170
|
+
for (let i = openParenIndex; i < sourceText.length; i += 1) {
|
|
171
|
+
const char = sourceText[i];
|
|
172
|
+
|
|
173
|
+
if (quote) {
|
|
174
|
+
if (escaped) {
|
|
175
|
+
escaped = false;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (char === "\\") {
|
|
179
|
+
escaped = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (char === quote) {
|
|
183
|
+
quote = null;
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
189
|
+
quote = char;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (char === "(") {
|
|
194
|
+
depth += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (char === ")") {
|
|
198
|
+
depth -= 1;
|
|
199
|
+
if (depth === 0) {
|
|
200
|
+
return i;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return -1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function extractMockCallText(sourceText, index) {
|
|
209
|
+
const openParenIndex = sourceText.indexOf("(", index);
|
|
210
|
+
if (openParenIndex === -1) {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
const closeParenIndex = findMatchingParen(sourceText, openParenIndex);
|
|
214
|
+
if (closeParenIndex === -1) {
|
|
215
|
+
return sourceText.slice(index);
|
|
216
|
+
}
|
|
217
|
+
return sourceText.slice(index, closeParenIndex + 1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolveMockTargetPath(filePath, moduleSpecifier) {
|
|
221
|
+
const basePath = resolve(dirname(filePath), moduleSpecifier);
|
|
222
|
+
const candidates = [
|
|
223
|
+
basePath,
|
|
224
|
+
`${basePath}.ts`,
|
|
225
|
+
`${basePath}.tsx`,
|
|
226
|
+
`${basePath}.js`,
|
|
227
|
+
`${basePath}.jsx`,
|
|
228
|
+
join(basePath, "index.ts"),
|
|
229
|
+
join(basePath, "index.tsx"),
|
|
230
|
+
join(basePath, "index.js"),
|
|
231
|
+
join(basePath, "index.jsx"),
|
|
232
|
+
];
|
|
233
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getExportedNames(filePath) {
|
|
237
|
+
const sourceText = readFileSync(filePath, "utf8");
|
|
238
|
+
const exportedNames = new Set();
|
|
239
|
+
|
|
240
|
+
for (const match of sourceText.matchAll(
|
|
241
|
+
/\bexport\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
|
|
242
|
+
)) {
|
|
243
|
+
if (match[1]) exportedNames.add(match[1]);
|
|
244
|
+
}
|
|
245
|
+
for (const match of sourceText.matchAll(
|
|
246
|
+
/\bexport\s+(?:const|let|var|class)\s+([A-Za-z_$][\w$]*)/g,
|
|
247
|
+
)) {
|
|
248
|
+
if (match[1]) exportedNames.add(match[1]);
|
|
249
|
+
}
|
|
250
|
+
for (const match of sourceText.matchAll(/\bexport\s*{([^}]+)}/g)) {
|
|
251
|
+
const specifiers = match[1]?.split(",") ?? [];
|
|
252
|
+
for (const specifier of specifiers) {
|
|
253
|
+
const cleaned = specifier.trim();
|
|
254
|
+
if (!cleaned || cleaned.startsWith("type ")) continue;
|
|
255
|
+
const aliasMatch = cleaned.match(/\bas\s+([A-Za-z_$][\w$]*)$/);
|
|
256
|
+
const nameMatch = cleaned.match(/^(?:type\s+)?([A-Za-z_$][\w$]*)/);
|
|
257
|
+
const exportedName = aliasMatch?.[1] ?? nameMatch?.[1];
|
|
258
|
+
if (exportedName) exportedNames.add(exportedName);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return exportedNames;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getMockedObjectKeys(mockCallText) {
|
|
266
|
+
const keys = new Set();
|
|
267
|
+
const arrowObjectIndex = mockCallText.indexOf("=> ({");
|
|
268
|
+
const objectStartIndex =
|
|
269
|
+
arrowObjectIndex === -1
|
|
270
|
+
? mockCallText.indexOf("return {")
|
|
271
|
+
: mockCallText.indexOf("{", arrowObjectIndex);
|
|
272
|
+
if (objectStartIndex === -1) {
|
|
273
|
+
return keys;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let depth = 0;
|
|
277
|
+
let quote = null;
|
|
278
|
+
let escaped = false;
|
|
279
|
+
let keyCandidate = "";
|
|
280
|
+
let readingKey = false;
|
|
281
|
+
|
|
282
|
+
for (let i = objectStartIndex; i < mockCallText.length; i += 1) {
|
|
283
|
+
const char = mockCallText[i];
|
|
284
|
+
|
|
285
|
+
if (quote) {
|
|
286
|
+
if (escaped) {
|
|
287
|
+
escaped = false;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (char === "\\") {
|
|
291
|
+
escaped = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (char === quote) {
|
|
295
|
+
quote = null;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
301
|
+
quote = char;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (char === "{") {
|
|
306
|
+
depth += 1;
|
|
307
|
+
readingKey = depth === 1;
|
|
308
|
+
keyCandidate = "";
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (char === "}") {
|
|
312
|
+
depth -= 1;
|
|
313
|
+
readingKey = depth === 1;
|
|
314
|
+
keyCandidate = "";
|
|
315
|
+
if (depth <= 0) break;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (depth !== 1) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (char === ",") {
|
|
324
|
+
readingKey = true;
|
|
325
|
+
keyCandidate = "";
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!readingKey) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (char === ":") {
|
|
334
|
+
const key = keyCandidate.trim();
|
|
335
|
+
if (/^[A-Za-z_$][\w$]*$/u.test(key)) {
|
|
336
|
+
keys.add(key);
|
|
337
|
+
}
|
|
338
|
+
readingKey = false;
|
|
339
|
+
keyCandidate = "";
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
keyCandidate += char;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return keys;
|
|
347
|
+
}
|
|
35
348
|
|
|
36
349
|
const failures = [];
|
|
37
350
|
|
|
@@ -39,40 +352,143 @@ for (const filePath of collectTestFiles(testsDir)) {
|
|
|
39
352
|
const sourceText = readFileSync(filePath, "utf8");
|
|
40
353
|
const mockedModules = Array.from(
|
|
41
354
|
sourceText.matchAll(mockModulePattern),
|
|
42
|
-
|
|
355
|
+
).filter(
|
|
356
|
+
(match) =>
|
|
357
|
+
!isLineCommentMatch(sourceText, match.index ?? 0) &&
|
|
358
|
+
!isInsideQuotedText(sourceText, match.index ?? 0),
|
|
43
359
|
);
|
|
44
360
|
|
|
45
361
|
if (mockedModules.length === 0) continue;
|
|
46
362
|
|
|
363
|
+
const relativePath = relative(rootDir, filePath).replaceAll("\\", "/");
|
|
47
364
|
const hasRestoreHook =
|
|
48
365
|
restoreHookPattern.test(sourceText) ||
|
|
49
366
|
restoreHookFunctionPattern.test(sourceText);
|
|
50
|
-
if (hasRestoreHook)
|
|
367
|
+
if (!hasRestoreHook) {
|
|
368
|
+
failures.push({
|
|
369
|
+
type: "missing-restore",
|
|
370
|
+
filePath: relativePath,
|
|
371
|
+
mockedModules: mockedModules.map(
|
|
372
|
+
(match) => match[2] ?? "<dynamic module>",
|
|
373
|
+
),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const match of mockedModules) {
|
|
378
|
+
const moduleSpecifier = match[2] ?? "<dynamic module>";
|
|
379
|
+
const location = lineAndColumn(sourceText, match.index ?? 0);
|
|
380
|
+
const forbiddenSuffix = moduleMatches(
|
|
381
|
+
moduleSpecifier,
|
|
382
|
+
FORBIDDEN_MOCK_MODULES.keys(),
|
|
383
|
+
);
|
|
384
|
+
if (forbiddenSuffix) {
|
|
385
|
+
failures.push({
|
|
386
|
+
type: "forbidden-module",
|
|
387
|
+
filePath: relativePath,
|
|
388
|
+
location,
|
|
389
|
+
moduleSpecifier,
|
|
390
|
+
reason: FORBIDDEN_MOCK_MODULES.get(forbiddenSuffix),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (
|
|
395
|
+
isRelativeInternalModule(moduleSpecifier) &&
|
|
396
|
+
isTopLevelMockCall(sourceText, match.index ?? 0)
|
|
397
|
+
) {
|
|
398
|
+
const key = `${relativePath}::${moduleSpecifier}`;
|
|
399
|
+
if (!ALLOWED_TOP_LEVEL_MOCKS.has(key)) {
|
|
400
|
+
failures.push({
|
|
401
|
+
type: "top-level-mock",
|
|
402
|
+
filePath: relativePath,
|
|
403
|
+
location,
|
|
404
|
+
moduleSpecifier,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const completeMockSuffix = moduleMatches(
|
|
410
|
+
moduleSpecifier,
|
|
411
|
+
COMPLETE_EXPORT_MOCK_MODULES,
|
|
412
|
+
);
|
|
413
|
+
if (completeMockSuffix) {
|
|
414
|
+
const targetPath = resolveMockTargetPath(filePath, moduleSpecifier);
|
|
415
|
+
if (!targetPath) continue;
|
|
51
416
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
417
|
+
const exportedNames = getExportedNames(targetPath);
|
|
418
|
+
const mockCallText = extractMockCallText(sourceText, match.index ?? 0);
|
|
419
|
+
const mockedKeys = getMockedObjectKeys(mockCallText);
|
|
420
|
+
const missingExports = [...exportedNames].filter(
|
|
421
|
+
(exportedName) => !mockedKeys.has(exportedName),
|
|
422
|
+
);
|
|
423
|
+
if (missingExports.length > 0) {
|
|
424
|
+
failures.push({
|
|
425
|
+
type: "partial-runtime-mock",
|
|
426
|
+
filePath: relativePath,
|
|
427
|
+
location,
|
|
428
|
+
moduleSpecifier,
|
|
429
|
+
missingExports,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
56
434
|
}
|
|
57
435
|
|
|
58
436
|
if (failures.length > 0) {
|
|
59
|
-
console.error(
|
|
60
|
-
"❌ Found test files that call mock.module() without a top-level afterEach/afterAll hook that calls mock.restore().\n",
|
|
61
|
-
);
|
|
437
|
+
console.error("❌ Found unsafe Bun mock.module() usage.\n");
|
|
62
438
|
|
|
63
439
|
for (const failure of failures) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
440
|
+
switch (failure.type) {
|
|
441
|
+
case "missing-restore":
|
|
442
|
+
console.error(`- ${failure.filePath}`);
|
|
443
|
+
console.error(
|
|
444
|
+
" missing: top-level afterEach/afterAll mock.restore() hook",
|
|
445
|
+
);
|
|
446
|
+
console.error(` mocked modules: ${failure.mockedModules.join(", ")}`);
|
|
447
|
+
break;
|
|
448
|
+
case "forbidden-module":
|
|
449
|
+
console.error(
|
|
450
|
+
`- ${failure.filePath}:${failure.location.line}:${failure.location.column}`,
|
|
451
|
+
);
|
|
452
|
+
console.error(
|
|
453
|
+
` forbidden shared module mock: ${failure.moduleSpecifier}`,
|
|
454
|
+
);
|
|
455
|
+
console.error(` ${failure.reason}`);
|
|
456
|
+
break;
|
|
457
|
+
case "top-level-mock":
|
|
458
|
+
console.error(
|
|
459
|
+
`- ${failure.filePath}:${failure.location.line}:${failure.location.column}`,
|
|
460
|
+
);
|
|
461
|
+
console.error(
|
|
462
|
+
` unsafe top-level internal module mock: ${failure.moduleSpecifier}`,
|
|
463
|
+
);
|
|
464
|
+
console.error(
|
|
465
|
+
" Top-level mock.module() calls are active while Bun loads other test files. Move the mock into the test/beforeEach, use dependency injection, or add an explicit test override helper.",
|
|
466
|
+
);
|
|
467
|
+
break;
|
|
468
|
+
case "partial-runtime-mock":
|
|
469
|
+
console.error(
|
|
470
|
+
`- ${failure.filePath}:${failure.location.line}:${failure.location.column}`,
|
|
471
|
+
);
|
|
472
|
+
console.error(
|
|
473
|
+
` partial channel runtime mock: ${failure.moduleSpecifier}`,
|
|
474
|
+
);
|
|
475
|
+
console.error(
|
|
476
|
+
` missing exports: ${failure.missingExports.join(", ")}`,
|
|
477
|
+
);
|
|
478
|
+
console.error(
|
|
479
|
+
" Runtime module mocks must include every runtime export so later imports do not fail with missing ESM exports.",
|
|
480
|
+
);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
67
483
|
}
|
|
68
484
|
|
|
69
485
|
console.error(
|
|
70
486
|
"\nWhy this fails: Bun module mocks are process-global and can leak across files in the shared module cache.",
|
|
71
487
|
);
|
|
72
488
|
console.error(
|
|
73
|
-
"
|
|
489
|
+
"Prefer explicit test override helpers or dependency injection. If a module mock is unavoidable, keep it scoped and restore it with afterEach().",
|
|
74
490
|
);
|
|
75
491
|
process.exit(1);
|
|
76
492
|
}
|
|
77
493
|
|
|
78
|
-
console.log("✅ No
|
|
494
|
+
console.log("✅ No unsafe mock.module() usage found.");
|
package/scripts/check.js
CHANGED
|
@@ -15,7 +15,7 @@ try {
|
|
|
15
15
|
} catch (error) {
|
|
16
16
|
console.error("❌ Mock isolation check failed\n");
|
|
17
17
|
console.error(
|
|
18
|
-
"
|
|
18
|
+
"Fix the unsafe mock.module() usage above. Prefer explicit test override helpers or scoped mocks with afterEach(mock.restore).\n",
|
|
19
19
|
);
|
|
20
20
|
failed = true;
|
|
21
21
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { Text, useInput } from 'ink';
|
|
2
|
+
import { Text, Transform, useInput } from 'ink';
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
|
+
// Use a private-use sentinel while Ink/wrap-ansi measure and wrap text.
|
|
6
|
+
// wrap-ansi with trim:true strips inverse ASCII spaces at line boundaries, but
|
|
7
|
+
// rendering an inverse NBSP can show a visible glyph in some terminals/fonts.
|
|
8
|
+
// The Transform below converts this sentinel back to an inverse ASCII space
|
|
9
|
+
// after wrapping has completed, before anything is written to the terminal.
|
|
10
|
+
const CURSOR_SENTINEL = '\u{10FFFD}';
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* Determines if the input should be treated as a control sequence (not inserted as text).
|
|
7
14
|
* This centralizes escape sequence filtering to prevent garbage characters from being inserted.
|
|
@@ -70,21 +77,21 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
|
|
|
70
77
|
let renderedValue = value;
|
|
71
78
|
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
|
72
79
|
if (showCursor && focus) {
|
|
73
|
-
renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) :
|
|
74
|
-
renderedValue = value.length > 0 ? '' :
|
|
80
|
+
renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : CURSOR_SENTINEL;
|
|
81
|
+
renderedValue = value.length > 0 ? '' : CURSOR_SENTINEL;
|
|
75
82
|
let i = 0;
|
|
76
83
|
for (const char of value) {
|
|
77
84
|
const isCursorPosition = i >= cursorOffset - cursorActualWidth && i <= cursorOffset;
|
|
78
85
|
if (isCursorPosition && char === '\n') {
|
|
79
86
|
// Newline at cursor: show inverted space (visible cursor) then the newline
|
|
80
|
-
renderedValue +=
|
|
87
|
+
renderedValue += CURSOR_SENTINEL + char;
|
|
81
88
|
} else {
|
|
82
89
|
renderedValue += isCursorPosition ? chalk.inverse(char) : char;
|
|
83
90
|
}
|
|
84
91
|
i++;
|
|
85
92
|
}
|
|
86
93
|
if (value.length > 0 && cursorOffset === value.length) {
|
|
87
|
-
renderedValue +=
|
|
94
|
+
renderedValue += CURSOR_SENTINEL;
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
97
|
useInput((input, key) => {
|
|
@@ -173,7 +180,8 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
|
|
|
173
180
|
onChange(nextValue);
|
|
174
181
|
}
|
|
175
182
|
}, { isActive: focus });
|
|
176
|
-
return (React.createElement(
|
|
183
|
+
return (React.createElement(Transform, { transform: line => line.replaceAll(CURSOR_SENTINEL, chalk.inverse(' ')) },
|
|
184
|
+
React.createElement(Text, null, placeholder ? (value.length > 0 ? renderedValue : renderedPlaceholder) : renderedValue)));
|
|
177
185
|
}
|
|
178
186
|
export default TextInput;
|
|
179
187
|
export function UncontrolledTextInput({ initialValue = '', ...props }) {
|