@letta-ai/letta-code 0.28.1 → 0.28.2
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 +547 -363
- package/package.json +1 -1
- package/scripts/check-test-mock-isolation.js +11 -25
- package/scripts/isolated-unit-tests.json +64 -0
- package/scripts/run-unit-tests.cjs +89 -31
- package/scripts/source-file-size-baseline.json +2 -3
package/package.json
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
6
7
|
const TESTS_DIR_ENV = "LETTA_MOCK_ISOLATION_TESTS_DIR";
|
|
7
8
|
|
|
8
9
|
const rootDir = process.cwd();
|
|
10
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
const testsDir = process.env[TESTS_DIR_ENV]
|
|
10
12
|
? resolve(process.env[TESTS_DIR_ENV])
|
|
11
13
|
: join(rootDir, "src");
|
|
14
|
+
const isolationManifest = JSON.parse(
|
|
15
|
+
readFileSync(join(scriptDir, "isolated-unit-tests.json"), "utf8"),
|
|
16
|
+
);
|
|
17
|
+
const ISOLATED_TEST_FILES = new Set(
|
|
18
|
+
isolationManifest.tests.map((entry) => entry.path),
|
|
19
|
+
);
|
|
12
20
|
|
|
13
21
|
const FORBIDDEN_MOCK_MODULES = new Map([
|
|
14
22
|
[
|
|
@@ -35,27 +43,6 @@ const COMPLETE_EXPORT_MOCK_MODULES = new Set([
|
|
|
35
43
|
"/channels/discord/runtime",
|
|
36
44
|
]);
|
|
37
45
|
|
|
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/channels/discord-registry.test.ts::../backend/api/client",
|
|
45
|
-
"src/cli/message-search-cache-warm.test.ts::../backend/api/search",
|
|
46
|
-
"src/hooks/prompt-executor.test.ts::../backend/api/generate",
|
|
47
|
-
"src/tools/memory-apply-patch.test.ts::../backend/api/client",
|
|
48
|
-
"src/tools/memory-tool.test.ts::../backend/api/client",
|
|
49
|
-
"src/tools/toolset-client-tool-rule-cleanup.test.ts::../backend/api/client",
|
|
50
|
-
"src/tools/toolset-memfs-detach.test.ts::../backend/api/client",
|
|
51
|
-
"src/websocket/listen-client-concurrency.test.ts::../agent/approval-execution",
|
|
52
|
-
"src/websocket/listen-client-concurrency.test.ts::../agent/approval-recovery",
|
|
53
|
-
"src/websocket/listen-client-concurrency.test.ts::../agent/message",
|
|
54
|
-
"src/websocket/listen-client-concurrency.test.ts::../backend/api/client",
|
|
55
|
-
"src/websocket/listen-client-concurrency.test.ts::../cli/helpers/approvalClassification",
|
|
56
|
-
"src/websocket/listen-client-concurrency.test.ts::../cli/helpers/stream",
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
46
|
const mockModulePattern = /\bmock\.module\s*\(\s*(["'`])([^"'`]+)\1/g;
|
|
60
47
|
const restoreHookPattern =
|
|
61
48
|
/\bafter(?:All|Each)\s*\(\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>[\s\S]*?\bmock\.restore\s*\(/m;
|
|
@@ -390,8 +377,7 @@ for (const filePath of collectTestFiles(testsDir)) {
|
|
|
390
377
|
isRelativeInternalModule(moduleSpecifier) &&
|
|
391
378
|
isTopLevelMockCall(sourceText, match.index ?? 0)
|
|
392
379
|
) {
|
|
393
|
-
|
|
394
|
-
if (!ALLOWED_TOP_LEVEL_MOCKS.has(key)) {
|
|
380
|
+
if (!ISOLATED_TEST_FILES.has(relativePath)) {
|
|
395
381
|
failures.push({
|
|
396
382
|
type: "top-level-mock",
|
|
397
383
|
filePath: relativePath,
|
|
@@ -457,7 +443,7 @@ if (failures.length > 0) {
|
|
|
457
443
|
` unsafe top-level internal module mock: ${failure.moduleSpecifier}`,
|
|
458
444
|
);
|
|
459
445
|
console.error(
|
|
460
|
-
" Top-level mock.module() calls are active while Bun loads other test files. Move the mock into the test/beforeEach, use dependency injection, or
|
|
446
|
+
" Top-level mock.module() calls are active while Bun loads other test files. Move the mock into the test/beforeEach, use dependency injection, or register a justified standalone process in scripts/isolated-unit-tests.json.",
|
|
461
447
|
);
|
|
462
448
|
break;
|
|
463
449
|
case "partial-runtime-mock":
|
|
@@ -481,7 +467,7 @@ if (failures.length > 0) {
|
|
|
481
467
|
"\nWhy this fails: Bun module mocks are process-global and can leak across files in the shared module cache.",
|
|
482
468
|
);
|
|
483
469
|
console.error(
|
|
484
|
-
"Prefer explicit test override helpers or dependency injection. If a module mock is unavoidable, keep it scoped and restore it with afterEach().",
|
|
470
|
+
"Prefer explicit test override helpers or dependency injection. If a module mock is unavoidable, keep it scoped and restore it with afterEach(); top-level mocks must also run in an isolated process.",
|
|
485
471
|
);
|
|
486
472
|
process.exit(1);
|
|
487
473
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tests": [
|
|
3
|
+
{
|
|
4
|
+
"path": "src/channels/discord-registry.test.ts",
|
|
5
|
+
"timeoutMs": 15000,
|
|
6
|
+
"reason": "Uses a top-level Bun module mock for the backend client."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"path": "src/channels/discord-service.test.ts",
|
|
10
|
+
"timeoutMs": 15000,
|
|
11
|
+
"reason": "Mutates shared channel account, route, pairing, and target stores."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "src/channels/slack/media.test.ts",
|
|
15
|
+
"timeoutMs": 15000,
|
|
16
|
+
"reason": "Imports the real media module while Slack adapter tests replace it."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "src/cli/message-search-cache-warm.test.ts",
|
|
20
|
+
"timeoutMs": 15000,
|
|
21
|
+
"reason": "Uses a top-level Bun module mock for backend search."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"path": "src/hooks/prompt-executor.test.ts",
|
|
25
|
+
"timeoutMs": 15000,
|
|
26
|
+
"reason": "Uses a top-level Bun module mock for backend generation."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "src/tools/bash.test.ts",
|
|
30
|
+
"timeoutMs": 30000,
|
|
31
|
+
"reason": "Spawns real shells and temporarily mutates process platform and PATH state."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"path": "src/tools/enter-worktree.test.ts",
|
|
35
|
+
"timeoutMs": 30000,
|
|
36
|
+
"reason": "Spawns real Git processes and mutates HOME, USER_CWD, cwd, and runtime singletons."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "src/tools/memory-apply-patch.test.ts",
|
|
40
|
+
"timeoutMs": 15000,
|
|
41
|
+
"reason": "Uses a top-level Bun module mock for the backend client."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "src/tools/memory-tool.test.ts",
|
|
45
|
+
"timeoutMs": 15000,
|
|
46
|
+
"reason": "Uses a top-level Bun module mock for the backend client."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "src/tools/toolset-client-tool-rule-cleanup.test.ts",
|
|
50
|
+
"timeoutMs": 15000,
|
|
51
|
+
"reason": "Uses a top-level Bun module mock for the backend client."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "src/tools/toolset-memfs-detach.test.ts",
|
|
55
|
+
"timeoutMs": 15000,
|
|
56
|
+
"reason": "Uses a top-level Bun module mock for the backend client."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "src/websocket/listen-client-concurrency.test.ts",
|
|
60
|
+
"timeoutMs": 30000,
|
|
61
|
+
"reason": "Uses several top-level Bun module mocks and exercises real concurrent listener state."
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const {
|
|
3
|
-
const {
|
|
2
|
+
const { execFileSync } = require("node:child_process");
|
|
3
|
+
const { readFileSync, readdirSync } = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
|
|
6
6
|
// Unit test directories — bun discovers *.test.ts / *.test.tsx within each.
|
|
7
|
-
// Listed explicitly so we skip src/integration-tests (API-gated)
|
|
8
|
-
// shell expansion that can exceed the Windows command-line length limit.
|
|
7
|
+
// Listed explicitly so we skip src/integration-tests (API-gated).
|
|
9
8
|
const dirs = [
|
|
10
9
|
"src/agent",
|
|
11
10
|
"src/auth",
|
|
@@ -30,23 +29,23 @@ const dirs = [
|
|
|
30
29
|
"src/utils",
|
|
31
30
|
"src/web",
|
|
32
31
|
"src/websocket",
|
|
33
|
-
// Root-level test files (not inside a subdirectory)
|
|
34
|
-
"src/*.test.ts",
|
|
35
32
|
];
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
const isolationManifest = JSON.parse(
|
|
35
|
+
readFileSync(path.join(__dirname, "isolated-unit-tests.json"), "utf8"),
|
|
36
|
+
);
|
|
37
|
+
const isolatedTests = isolationManifest.tests;
|
|
38
|
+
const isolatedPaths = new Set(isolatedTests.map((entry) => entry.path));
|
|
39
|
+
|
|
40
|
+
function findTestFiles(dir) {
|
|
42
41
|
const results = [];
|
|
43
42
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
44
43
|
const full = path.join(dir, entry.name);
|
|
45
44
|
if (entry.isDirectory()) {
|
|
46
|
-
results.push(...findTestFiles(full
|
|
45
|
+
results.push(...findTestFiles(full));
|
|
47
46
|
} else if (
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
entry.name.endsWith(".test.ts") ||
|
|
48
|
+
entry.name.endsWith(".test.tsx")
|
|
50
49
|
) {
|
|
51
50
|
results.push(full.replace(/\\/g, "/"));
|
|
52
51
|
}
|
|
@@ -54,28 +53,87 @@ function findTestFiles(dir, exclude) {
|
|
|
54
53
|
return results;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
function findRootTestFiles(dir) {
|
|
57
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
.filter(
|
|
59
|
+
(entry) =>
|
|
60
|
+
entry.isFile() &&
|
|
61
|
+
(entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")),
|
|
62
|
+
)
|
|
63
|
+
.map((entry) => path.join(dir, entry.name).replace(/\\/g, "/"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const allTestFiles = [
|
|
67
|
+
...dirs.flatMap((dir) => findTestFiles(dir)),
|
|
68
|
+
...findTestFiles("src/channels"),
|
|
69
|
+
...findRootTestFiles("src"),
|
|
70
|
+
].sort();
|
|
71
|
+
const discoveredPaths = new Set(allTestFiles);
|
|
72
|
+
|
|
73
|
+
for (const entry of isolatedTests) {
|
|
74
|
+
if (!discoveredPaths.has(entry.path)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Isolated unit test is missing from the unit-test roots: ${entry.path}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isInteger(entry.timeoutMs) || entry.timeoutMs <= 0) {
|
|
80
|
+
throw new Error(`Invalid timeout for isolated unit test: ${entry.path}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runTests(files, timeoutMs) {
|
|
85
|
+
execFileSync("bun", ["test", ...files, "--timeout", String(timeoutMs)], {
|
|
86
|
+
stdio: "inherit",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function chunkByCommandLength(files, maxChars = 20000) {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let chunk = [];
|
|
93
|
+
let chars = 0;
|
|
94
|
+
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
const nextChars = file.length + 3;
|
|
97
|
+
if (chunk.length > 0 && chars + nextChars > maxChars) {
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
chunk = [];
|
|
100
|
+
chars = 0;
|
|
101
|
+
}
|
|
102
|
+
chunk.push(file);
|
|
103
|
+
chars += nextChars;
|
|
104
|
+
}
|
|
105
|
+
if (chunk.length > 0) {
|
|
106
|
+
chunks.push(chunk);
|
|
107
|
+
}
|
|
108
|
+
return chunks;
|
|
109
|
+
}
|
|
60
110
|
|
|
61
|
-
const opts = { stdio: "inherit", shell: process.platform === "win32" };
|
|
62
111
|
let exitCode = 0;
|
|
63
112
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
113
|
+
// Bun module mocks and process-global state are shared within one test process.
|
|
114
|
+
// Keep the explicitly stateful suites in fresh processes so they cannot poison
|
|
115
|
+
// the ordinary unit batch or inherit another suite's cwd/env/module registry.
|
|
116
|
+
for (const entry of isolatedTests) {
|
|
117
|
+
try {
|
|
118
|
+
runTests([entry.path], entry.timeoutMs);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
exitCode = error.status ?? 1;
|
|
121
|
+
}
|
|
69
122
|
}
|
|
70
123
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
124
|
+
const sharedProcessTests = allTestFiles.filter(
|
|
125
|
+
(file) => !isolatedPaths.has(file),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Passing argv directly avoids cmd.exe's shorter shell command-line limit on
|
|
129
|
+
// Windows. Bounded batches also stay below CreateProcess's 32k limit as the
|
|
130
|
+
// suite grows.
|
|
131
|
+
for (const batch of chunkByCommandLength(sharedProcessTests)) {
|
|
132
|
+
try {
|
|
133
|
+
runTests(batch, 15000);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
exitCode = error.status ?? 1;
|
|
136
|
+
}
|
|
79
137
|
}
|
|
80
138
|
|
|
81
139
|
process.exit(exitCode);
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"src/agent/client-skills.test.ts": 1196,
|
|
3
3
|
"src/agent/memory-git.ts": 2324,
|
|
4
|
-
"src/agent/subagents/manager.ts": 1577,
|
|
5
4
|
"src/backend/local-backend.test.ts": 2589,
|
|
6
5
|
"src/backend/local/local-backend.ts": 1058,
|
|
7
|
-
"src/backend/local/local-store.ts":
|
|
6
|
+
"src/backend/local/local-store.ts": 3618,
|
|
8
7
|
"src/backend/pi-stream-adapter.test.ts": 1442,
|
|
9
8
|
"src/cli/app/AppCoordinator.tsx": 5198,
|
|
10
9
|
"src/cli/app/AppView.tsx": 1750,
|
|
@@ -42,7 +41,7 @@
|
|
|
42
41
|
"src/tools/manager.ts": 3293,
|
|
43
42
|
"src/tools/tool-execution-context.test.ts": 1422,
|
|
44
43
|
"src/types/protocol_v2.ts": 2932,
|
|
45
|
-
"src/websocket/listen-client-concurrency.test.ts":
|
|
44
|
+
"src/websocket/listen-client-concurrency.test.ts": 3017,
|
|
46
45
|
"src/websocket/listen-client-protocol.test.ts": 6721,
|
|
47
46
|
"src/websocket/listener/commands/channels.ts": 1390,
|
|
48
47
|
"src/websocket/listener/commands/memory.ts": 1114,
|