Skip to content
Navigation Menu
{{ message }}
-
-
Notifications
You must be signed in to change notification settings - Fork 655
Expand file tree
/
Copy pathtest-openclaw-e2e.sh
More file actions
338 lines (288 loc) · 12.5 KB
/
test-openclaw-e2e.sh
File metadata and controls
338 lines (288 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/usr/bin/env bash
# End-to-end synthetic smoke test for the OpenClaw plugin.
# Loads the real built plugin, fires all hooks in sequence with a recording
# API proxy, and queries SQLite to verify DB writes. No real gateway needed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_FILE="${RUNNER_TEMP:-/tmp}/e2e-output.log"
cd "$REPO_ROOT"
echo "=== context-mode OpenClaw E2E Synthetic Test ===" | tee "$LOG_FILE"
echo "Repo: $REPO_ROOT" | tee -a "$LOG_FILE"
echo "Node: $(node --version)" | tee -a "$LOG_FILE"
echo "Version: $(node -e "console.log(require('./package.json').version)" 2>/dev/null)" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
node --experimental-vm-modules --no-warnings --input-type=module 2>&1 <<'HARNESS_EOF' | tee -a "$LOG_FILE"
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
let passed = 0;
let failed = 0;
let warned = 0;
function pass(label) { console.log(` ✅ ${label}`); passed++; }
function fail(label, detail) { console.error(` ❌ ${label}${detail ? ': ' + detail : ''}`); failed++; }
function warn(label) { console.log(` ⚠️ ${label}`); warned++; }
function section(label) { console.log(`\n--- ${label} ---`); }
// ── 0. Load plugin ────────────────────────────────────────
section("Phase 1: Plugin load");
const pluginPath = join(process.cwd(), "build", "openclaw-plugin.js");
if (!existsSync(pluginPath)) { fail("build/openclaw-plugin.js exists"); process.exit(1); }
let plugin;
try {
const mod = await import(pluginPath);
plugin = mod.default;
pass("build/openclaw-plugin.js loaded");
} catch (err) {
fail("plugin load", err.message);
process.exit(1);
}
// ── 1. Plugin metadata ────────────────────────────────────
section("Phase 2: Plugin metadata");
[
["id === 'context-mode'", plugin.id === "context-mode"],
["name defined", !!plugin.name],
["configSchema is object", typeof plugin.configSchema === "object"],
["register is function", typeof plugin.register === "function"],
].forEach(([l, ok]) => ok ? pass(l) : fail(l));
// ── 2. Build recording API proxy ──────────────────────────
const hooks = new Map();
const lifecycle = new Map();
const commands = new Map();
let contextEngineId = null;
const api = {
registerHook(event, handler, meta) {
if (!hooks.has(event)) hooks.set(event, []);
hooks.get(event).push({ handler, meta });
},
on(event, handler, opts) {
if (!lifecycle.has(event)) lifecycle.set(event, []);
lifecycle.get(event).push({ handler, priority: opts?.priority ?? 0 });
},
registerContextEngine(id) { contextEngineId = id; },
registerCommand(cmd) { commands.set(cmd.name, cmd); },
logger: {
info: (...a) => {},
error: (...a) => console.error("[plugin:error]", ...a),
debug: (...a) => {},
warn: (...a) => {},
},
};
// ── 3. register() ─────────────────────────────────────────
section("Phase 3: register()");
try {
plugin.register(api);
pass("register() completed synchronously without throwing");
} catch (err) {
fail("register()", err.message);
process.exit(1);
}
// ── 4. Hook registration ──────────────────────────────────
section("Phase 4: Hook registration");
const expectedLifecycle = [
"before_tool_call", "after_tool_call",
"session_start", "before_compaction", "after_compaction",
"before_model_resolve", "before_prompt_build",
];
expectedLifecycle.forEach(name => {
lifecycle.has(name) && lifecycle.get(name).length > 0
? pass(`api.on("${name}") registered`)
: fail(`api.on("${name}") registered`);
});
["command:new", "command:stop"].forEach(name => {
hooks.has(name) && hooks.get(name).length > 0
? pass(`api.registerHook("${name}") registered`)
: fail(`api.registerHook("${name}") registered`);
});
const promptHandlers = lifecycle.get("before_prompt_build") || [];
promptHandlers.length >= 2
? pass(`before_prompt_build has ${promptHandlers.length} handlers (resume p=10 + routing p=5)`)
: warn(`before_prompt_build has only ${promptHandlers.length} handler(s) — expected 2`);
contextEngineId === "context-mode"
? pass("registerContextEngine('context-mode') called")
: fail("registerContextEngine('context-mode') called", `got: ${contextEngineId}`);
["/ctx-stats", "/ctx-doctor", "/ctx-upgrade"].forEach(name => {
commands.has(name.slice(1))
? pass(`${name} command registered`)
: fail(`${name} command registered`);
});
// ── 5. Hook execution sequence ────────────────────────────
section("Phase 5: Hook execution (full lifecycle)");
async function fireLifecycle(event, payload) {
const hs = lifecycle.get(event) || [];
const results = [];
for (const { handler } of hs) results.push(await handler(payload));
return results;
}
async function fireHook(event) {
const hs = hooks.get(event) || [];
for (const { handler } of hs) await handler();
}
// 5a. command:new — session init
await fireHook("command:new");
pass("command:new fired");
// 5b. session_start — re-key session
const testSessionId = randomUUID();
const testSessionKey = `e2e-agent:test:${Date.now()}`;
await fireLifecycle("session_start", {
sessionId: testSessionId,
sessionKey: testSessionKey,
startedAt: new Date().toISOString(),
});
pass("session_start fired (sessionKey provided)");
// Allow initPromise to resolve
await new Promise(r => setTimeout(r, 600));
// 5c. before_tool_call — read tool (should passthrough)
const btcResult = await (async () => {
const hs = lifecycle.get("before_tool_call") || [];
for (const { handler } of hs) return await handler({
toolName: "read",
params: { file_path: "/tmp/e2e-test.txt" },
runId: randomUUID(),
toolCallId: randomUUID(),
});
})();
pass(`before_tool_call fired (read) → ${btcResult == null ? "passthrough" : JSON.stringify(btcResult)}`);
// 5d. after_tool_call — read
await fireLifecycle("after_tool_call", {
toolName: "read",
params: { file_path: "/tmp/e2e-test.txt" },
result: "line1\nline2\nline3",
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 12,
});
pass("after_tool_call fired (read)");
// 5e. after_tool_call — exec/bash
await fireLifecycle("after_tool_call", {
toolName: "exec",
params: { command: "ls -la" },
result: "total 8\ndrwxr-xr-x 2 pedro pedro",
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 45,
});
pass("after_tool_call fired (exec)");
// 5f. after_tool_call — write
await fireLifecycle("after_tool_call", {
toolName: "write",
params: { file_path: "/tmp/e2e-out.txt", content: "test" },
result: "ok",
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 8,
});
pass("after_tool_call fired (write)");
// 5g. after_tool_call — error case
await fireLifecycle("after_tool_call", {
toolName: "exec",
params: { command: "false" },
error: "exit code 1",
isError: true,
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 5,
});
pass("after_tool_call fired (error case)");
// 5h. before_model_resolve
await fireLifecycle("before_model_resolve", {
userMessage: "Please read the config and summarize the recent changes",
});
pass("before_model_resolve fired");
// 5i. before_compaction
await fireLifecycle("before_compaction", {});
pass("before_compaction fired");
// 5j. after_compaction
await fireLifecycle("after_compaction", {});
pass("after_compaction fired");
// 5k. before_prompt_build — both handlers
const promptResults = await fireLifecycle("before_prompt_build", {});
pass(`before_prompt_build fired (${promptResults.length} handlers)`);
// ── 6. SQLite DB verification ─────────────────────────────
section("Phase 6: SQLite DB verification");
await new Promise(r => setTimeout(r, 300));
const sessionDir = join(homedir(), ".openclaw", "context-mode", "sessions");
if (!existsSync(sessionDir)) {
fail("session directory exists", sessionDir);
process.exit(1);
}
pass(`session directory exists: ${sessionDir}`);
const dbFiles = readdirSync(sessionDir).filter(f => f.endsWith(".db"));
if (dbFiles.length === 0) { fail("session DB file(s) created"); process.exit(1); }
pass(`${dbFiles.length} session DB file(s) found`);
const Database = (await import("better-sqlite3")).default;
// Use the most recently modified DB
// Find DB with our session or most recent
const { statSync } = await import("node:fs");
let chosenDb;
for (const f of dbFiles.map(f => join(sessionDir, f)).reverse()) {
try {
const d = new Database(f, { readonly: true });
const row = d.prepare("SELECT 1 FROM session_meta WHERE session_id = ? OR session_id LIKE ?")
.get(testSessionId, testSessionId.slice(0,8) + "%");
d.close();
if (row) { chosenDb = f; break; }
} catch {}
}
// Fallback: most recently modified
if (!chosenDb) {
chosenDb = dbFiles.map(f => join(sessionDir, f))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
warn(`exact session not found — using most recent DB: ${chosenDb.split('/').pop()}`);
}
const db = new Database(chosenDb, { readonly: true });
// session_meta
const allMeta = db.prepare("SELECT session_id, event_count FROM session_meta").all();
allMeta.length > 0
? pass(`session_meta has ${allMeta.length} session(s)`)
: fail("session_meta populated");
// session_events
const eventCount = db.prepare("SELECT COUNT(*) as cnt FROM session_events").get();
eventCount.cnt >= 3
? pass(`session_events has ${eventCount.cnt} event(s) (≥3 expected)`)
: fail(`session_events populated`, `only ${eventCount.cnt} events`);
// event type breakdown
const types = db.prepare("SELECT type, COUNT(*) as cnt FROM session_events GROUP BY type ORDER BY cnt DESC").all();
console.log(` event types: ${types.map(t => `${t.type}(${t.cnt})`).join(", ")}`);
// session_resume (compaction)
const resume = db.prepare("SELECT COUNT(*) as cnt FROM session_resume").get();
resume.cnt > 0
? pass(`session_resume has ${resume.cnt} snapshot(s) — before_compaction fired correctly`)
: warn("session_resume is empty — compaction hook may not have produced a snapshot yet");
// openclaw_session_map
const mapRow = db.prepare("SELECT * FROM openclaw_session_map WHERE session_key = ?").get(testSessionKey);
mapRow
? pass(`openclaw_session_map entry for key '${testSessionKey.slice(0,20)}...' → ${mapRow.session_id.slice(0,8)}`)
: warn(`openclaw_session_map entry not found for test key — session re-keying may differ`);
db.close();
// ── 7. Command handlers ───────────────────────────────────
section("Phase 7: Command handlers");
const statsCmd = commands.get("ctx-stats");
if (statsCmd) {
try {
const result = statsCmd.handler({});
const text = typeof result === "object" ? (result.text ?? JSON.stringify(result)) : String(result ?? "");
text.length > 10
? pass(`/ctx-stats returns output (${text.length} chars)`)
: fail("/ctx-stats returns non-empty output");
} catch (e) {
fail("/ctx-stats handler throws", e.message);
}
} else {
fail("/ctx-stats handler callable");
}
// ── 8. command:stop ────────────────────────────────────────
section("Phase 8: Cleanup");
try {
await fireHook("command:stop");
pass("command:stop fired without throwing");
} catch (e) {
fail("command:stop", e.message);
}
// ── Summary ────────────────────────────────────────────────
console.log("\n══════════════════════════════════════════");
console.log(`Results: ${passed} passed ${warned} warned ${failed} failed`);
console.log("══════════════════════════════════════════");
if (failed > 0) {
console.error(`\n❌ E2E test FAILED (${failed} failure(s))`);
process.exit(1);
} else {
console.log(`\n✅ E2E test PASSED`);
}
HARNESS_EOF
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "E2E test failed (exit $EXIT_CODE)" >> "$LOG_FILE"
exit $EXIT_CODE
fi
You can’t perform that action at this time.
