luang-cli 0.2.0 → 0.3.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.
@@ -0,0 +1,806 @@
1
+ 'use strict';
2
+
3
+ // test/run-e2e.js — E2E offline luang-cli dengan stub WS + stub HTTP.
4
+ // TIDAK pernah menjalankan harness provider asli (opencode/claude/gemini):
5
+ // binary `opencode` di PATH uji adalah stub (test/bin/opencode).
6
+ // HOME selalu fake (~/.luang-cli milik user tidak tersentuh).
7
+ //
8
+ // Pakai: node test/run-e2e.js
9
+
10
+ const { spawn, spawnSync, fork } = require('child_process');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const http = require('http');
15
+
16
+ const { StubWsServer } = require('./ws-stub');
17
+
18
+ const ROOT = path.join(__dirname, '..');
19
+ const BIN = path.join(ROOT, 'bin', 'luang-cli.js');
20
+
21
+ // ── util ──
22
+ const baseTmp = process.env.E2E_TMP || path.join(os.tmpdir(), 'opencode');
23
+ let passCount = 0;
24
+ let failCount = 0;
25
+
26
+ function assert(cond, msg) {
27
+ if (cond) {
28
+ passCount += 1;
29
+ console.log(` ✅ ${msg}`);
30
+ } else {
31
+ failCount += 1;
32
+ console.error(` ❌ ${msg}`);
33
+ throw new Error(`ASSERT: ${msg}`);
34
+ }
35
+ }
36
+
37
+ function mkdirp(p) {
38
+ fs.mkdirSync(p, { recursive: true });
39
+ return p;
40
+ }
41
+
42
+ function writeJson(p, obj) {
43
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
44
+ }
45
+
46
+ function sleep(ms) {
47
+ return new Promise((r) => setTimeout(r, ms));
48
+ }
49
+
50
+ function until(fn, ms, label) {
51
+ const t0 = Date.now();
52
+ return new Promise((resolve, reject) => {
53
+ const tick = () => {
54
+ let v;
55
+ try { v = fn(); } catch (e) { return reject(e); }
56
+ if (v) return resolve(v);
57
+ if (Date.now() - t0 > ms) return reject(new Error(`Timeout menunggu: ${label}`));
58
+ setTimeout(tick, 100);
59
+ };
60
+ tick();
61
+ });
62
+ }
63
+
64
+ function mkFakeHome(name) {
65
+ const home = mkdtempUnder(baseTmp, `luang-e2e-${name}-`);
66
+ mkdirp(path.join(home, '.luang-cli'));
67
+ return home;
68
+ }
69
+
70
+ function mkdtempUnder(base, prefix) {
71
+ mkdirp(base);
72
+ return fs.mkdtempSync(path.join(base, prefix));
73
+ }
74
+
75
+ function daemonEnv(home) {
76
+ return {
77
+ ...process.env,
78
+ HOME: home,
79
+ LUANG_NO_OPEN: '1',
80
+ // ── Kebijakan pengujian provider ───────────────────────────────────────
81
+ // PATH uji DIKUNCI ke stub test/bin + util dasar: `which claude` /
82
+ // `which gemini` HARUS NOT_FOUND di sini, sehingga deteksi provider
83
+ // (which(1)) dan probe versi (→ `<bin> --version`) tidak pernah
84
+ // mengeksekusi binary claude/gemini. Provider uji: opencode (stub).
85
+ PATH: `${path.join(__dirname, 'bin')}:/usr/bin:/bin`,
86
+ // heartbeat cepat supaya E2E melihat frame heartbeat tanpa nunggu 30s
87
+ LUANG_HEARTBEAT_SEC: '3',
88
+ };
89
+ }
90
+
91
+ function spawnDaemon(env, args, stdinData) {
92
+ const p = spawn(process.execPath, [BIN, ...args], { env, cwd: ROOT });
93
+ const cap = { out: '', err: '' };
94
+ p.stdout.on('data', (d) => { cap.out += d.toString(); });
95
+ p.stderr.on('data', (d) => { cap.err += d.toString(); });
96
+ if (stdinData !== undefined) p.stdin.write(stdinData);
97
+ p.killedCleanly = false;
98
+ p.finish = async (sig = 'SIGTERM') => {
99
+ if (!p.killedCleanly) {
100
+ p.killedCleanly = true;
101
+ try { p.kill(sig); } catch (e) { /* abaikan */ }
102
+ }
103
+ await until(() => p.exitCode !== null || p.signalCode !== null, 6000, 'daemon keluar');
104
+ };
105
+ return { proc: p, cap };
106
+ }
107
+
108
+ function runCli(env, args) {
109
+ const r = spawnSync(process.execPath, [BIN, ...args], { env, cwd: ROOT, encoding: 'utf8', timeout: 20000 });
110
+ return { code: r.status, stdout: String(r.stdout || ''), stderr: String(r.stderr || '') };
111
+ }
112
+
113
+ function homeConfig(home) {
114
+ return JSON.parse(fs.readFileSync(path.join(home, '.luang-cli', 'config.json'), 'utf8'));
115
+ }
116
+
117
+ // ── Skenario 1: sintaks ──
118
+ function scenarioSyntax() {
119
+ console.log('\n[1] node --check semua file JS + guard PATH uji (kebijakan provider opencode)');
120
+ const files = ['bin/luang-cli.js', 'lib/core.js', 'lib/commands.js', 'lib/agent.js', 'lib/models.js', 'lib/ws.js', 'test/run-e2e.js', 'test/ws-stub.js', 'test/http-stub.js'];
121
+ for (const f of files) {
122
+ const r = spawnSync(process.execPath, ['--check', path.join(ROOT, f)], { encoding: 'utf8' });
123
+ assert(r.status === 0, `${f} lolos --check${r.stderr ? ' — ' + r.stderr.slice(0, 200) : ''}`);
124
+ }
125
+ // Guard kebijakan: PATH yang dipakai daemon TIDAK BOLEH menemukan claude/gemini,
126
+ // dan `opencode` harus resolve ke stub di test/bin (bukan binary asli).
127
+ const guardEnv = { ...process.env, PATH: `${path.join(__dirname, 'bin')}:/usr/bin:/bin` };
128
+ for (const bin of ['claude', 'gemini', 'codex']) {
129
+ const which = spawnSync('/usr/bin/which', [bin], { env: guardEnv, encoding: 'utf8' });
130
+ assert(which.status !== 0, `PATH uji TIDAK menemukan ${bin} (tidak akan dieksekusi) — dapat: ${String(which.stdout || '').trim() || 'tidak ada'}`);
131
+ }
132
+ const whichOpen = spawnSync('/usr/bin/which', ['opencode'], { env: guardEnv, encoding: 'utf8' });
133
+ const openPath = String(whichOpen.stdout || '').trim();
134
+ assert(openPath.startsWith(path.join(__dirname, 'bin')), `PATH uji resolve opencode → stub (${openPath || 'tidak ada'})`);
135
+ // HOME uji tidak boleh menyentuh host ~/.claude dst — daemonEnv memakai HOME fake.
136
+ }
137
+
138
+ // ── Skenario 2: realtime WS hello → command → snapshot → assigned → exec → telemetry → ack → heartbeat ──
139
+ async function scenarioRealtime() {
140
+ console.log('\n[2] E2E realtime WS: hello→ack→snapshot→assigned→exec→telemetry→pause/resume→heartbeat');
141
+ const home = mkFakeHome('realtime');
142
+ const port = 18101 + Math.floor(Math.random() * 400);
143
+ const config = {
144
+ api_url: `http://127.0.0.1:${port}`,
145
+ app_url: 'http://127.0.0.1',
146
+ key: 'lk_test_e2e_realtime',
147
+ default_provider: 'opencode',
148
+ providers: {
149
+ opencode: {
150
+ provider_type: 'opencode',
151
+ display_name: 'OpenCode',
152
+ path: 'stub-opencode',
153
+ exec_template: 'opencode run --model "{model}" "{task}"',
154
+ },
155
+ },
156
+ telemetry: true,
157
+ };
158
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
159
+ const env = daemonEnv(home);
160
+
161
+ const stub = new StubWsServer({ port, expectedKey: config.key });
162
+ await stub.start();
163
+
164
+ const state = { done: false };
165
+ const task101 = { id: 't-1', task_code: 'TGS-101', title: 'Refactor auth CLI', description: 'Bersihkan modul core', status: 'todo' };
166
+ const task102 = { id: 't-2', task_code: 'TGS-102', title: 'Deploy stub test', description: 'Cek pipeline offline', status: 'todo' };
167
+
168
+ const send = (type, data) => stub.broadcastText(JSON.stringify({ type, ts: Date.now(), data }));
169
+
170
+ stub.onHandshake = () => {
171
+ assert(true, 'handshake 101 diterima stub (upgrade sukses)');
172
+ send('hello.ok', { key_id: 'k9', user_id: 'u1', tenant_id: 'tn1', telemetry_enabled: true, server_time: new Date().toISOString() });
173
+ };
174
+
175
+ let phase = 0;
176
+ let gotHello = false;
177
+ stub.onMsg = (f) => {
178
+ const m = f.obj;
179
+ if (!m) return;
180
+ if (m.type === 'hello') {
181
+ assert(stub.frames.filter((x) => x.obj && x.obj.type !== 'hello').every((x) => x.ts >= f.ts), 'hello = pesan pertama klien dalam koneksi');
182
+ assert(m.data && m.data.mode === 'cli', 'hello {mode:"cli"} sesuai kontrak');
183
+ assert(typeof m.data.version === 'string' && m.data.version.length > 0, 'hello menyertakan version');
184
+ assert(Array.isArray(m.data.providers) && m.data.providers.some((p) => p.type === 'opencode'), 'hello menyertakan provider opencode');
185
+ assert(m.data.providers.find((p) => p.type === 'opencode').exec_template.includes('{task}'), 'hello menyertakan exec_template berisi {task}');
186
+ const helloOpc = m.data.providers.find((p) => p.type === 'opencode');
187
+ assert(Array.isArray(helloOpc.models) && helloOpc.models.length <= 50, 'hello menyertakan field models (≤50 item)');
188
+ assert(helloOpc.models.every((x) => typeof x === 'string' && x.length <= 120), 'tiap model di hello ≤120 karakter');
189
+ gotHello = true;
190
+ // Langsung perintah server: set_model SEBELUM tugas dikirim (agar exec pakai model)
191
+ phase = 1;
192
+ send('agent.command', { command_id: 'c1', command: 'set_model', provider: 'opencode', model: 'sonnet-test-9' });
193
+ } else if (m.type === 'command.ack') {
194
+ assert(Object.prototype.hasOwnProperty.call(m.data, 'command_id'), 'command.ack memakai command_id');
195
+ if (m.data.command_id === 'c1') {
196
+ assert(m.data.ok === true, 'ack c1 (set_model) ok:true');
197
+ const cfg = homeConfig(home);
198
+ assert(cfg.providers && cfg.providers.opencode && cfg.providers.opencode.model === 'sonnet-test-9', 'model tersimpan ke config providers.opencode.model');
199
+ phase = 2;
200
+ send('task.snapshot', { tasks: [task101] });
201
+ } else if (m.data.command_id === 'c2') {
202
+ assert(m.data.ok === true, 'ack c2 (pause) ok:true');
203
+ send('agent.command', { command_id: 'c3', command: 'resume' });
204
+ } else if (m.data.command_id === 'c3') {
205
+ assert(m.data.ok === true, 'ack c3 (resume) ok:true');
206
+ phase = 4;
207
+ stub.pingAll(); // protocol ping server → CLI balas pong
208
+ }
209
+ } else if (m.type === 'task.status') {
210
+ assert(m.data && m.data.ref, 'task.status menyertakan ref');
211
+ if (m.data.ref === 'TGS-101' && m.data.status === 'in_progress') {
212
+ assert(phase >= 2, 'setelah snapshot → in_progress terkirim via WS');
213
+ }
214
+ if (m.data.ref === 'TGS-101' && m.data.status === 'done') {
215
+ phase = 3;
216
+ send('task.assigned', { task: task102 });
217
+ }
218
+ }
219
+ };
220
+
221
+ const { proc, cap } = spawnDaemon(env, ['agent', '--exec']);
222
+ proc.stdin.write('y\n'); // jawab konfirmasi exec pertama (TGS-101); exec kedua dijawab belakangan
223
+ const dbg = (e) => {
224
+ console.error('---- daemon stdout ----\n' + cap.out);
225
+ console.error('---- daemon stderr ----\n' + cap.err);
226
+ console.error('---- ts client terakhir ----');
227
+ for (const f of stub.frames.slice(-20)) {
228
+ console.error(` op=${f.opcode} ${f.obj ? f.obj.type + ' ' + JSON.stringify(f.obj.data || f.obj).slice(0, 120) : ''}`);
229
+ }
230
+ throw e;
231
+ };
232
+ try {
233
+ // telemetry run TGS-101: started → completed
234
+ await until(() => {
235
+ const xs = stub.frames.filter((f) => f.obj && f.obj.type === 'telemetry.run' && f.obj.data && f.obj.data.task_ref === 'TGS-101');
236
+ const started = xs.find((f) => f.obj.data.status === 'started');
237
+ const done = xs.find((f) => f.obj.data.status === 'completed');
238
+ return started && done ? { started, done } : null;
239
+ }, 15000, 'telemetry.run started+completed (TGS-101)');
240
+ const xs = stub.frames.filter((f) => f.obj && f.obj.type === 'telemetry.run' && f.obj.data && f.obj.data.task_ref === 'TGS-101');
241
+ const started = xs.find((f) => f.obj.data.status === 'started');
242
+ const done = xs.find((f) => f.obj.data.status === 'completed');
243
+ assert(started.obj.data.provider === 'opencode', 'telemetry started: provider opencode');
244
+ assert(started.obj.data.model === 'sonnet-test-9', 'telemetry started: model dari set_model');
245
+ assert(!('duration_ms' in started.obj.data), 'started tanpa duration_ms');
246
+ assert(done.obj.data.exit_code === 0, 'telemetry completed: exit_code 0');
247
+ assert(typeof done.obj.data.duration_ms === 'number' && done.obj.data.duration_ms >= 0, 'telemetry completed: duration_ms ada');
248
+
249
+ // exec kedua (TGS-102 via task.assigned) sedang menunggu konfirmasi — jawab y
250
+ proc.stdin.write('y\n');
251
+
252
+ // exec TGS-101 sukses: PROVIDER-RUN dari stub binary (bukan opencode asli)
253
+ await until(() => cap.out.includes('PROVIDER-RUN'), 15000, 'exec TGS-101 dijalankan (stub binary)');
254
+ assert(cap.out.includes('--model sonnet-test-9'), `{model} di exec_template diganti 'sonnet-test-9'`);
255
+
256
+ // TGS-102 via task.assigned → exec kedua
257
+ await until(() => {
258
+ const ys = stub.frames.filter((f) => f.obj && f.obj.type === 'telemetry.run' && f.obj.data && f.obj.data.task_ref === 'TGS-102');
259
+ return ys.some((f) => f.obj.data.status === 'started') && ys.some((f) => f.obj.data.status === 'completed') ? true : null;
260
+ }, 20000, 'telemetry.started+completed (TGS-102)');
261
+
262
+ const zs = stub.frames.filter((f) => f.obj && f.obj.type === 'task.status' && f.obj.data && f.obj.data.ref === 'TGS-102');
263
+ assert(zs.some((f) => f.obj.data.status === 'in_progress'), 'TGS-102: task.status in_progress via WS');
264
+ assert(zs.some((f) => f.obj.data.status === 'done'), 'TGS-102: task.status done via WS');
265
+
266
+ // pause + resume + heartbeat (frame) + pong
267
+ send('agent.command', { command_id: 'c2', command: 'pause' });
268
+ await until(() => stub.frames.some((f) => f.obj && f.obj.type === 'command.ack' && f.obj.data && f.obj.data.command_id === 'c3'), 8000, 'ack resume');
269
+ await until(() => stub.frames.some((f) => f.obj && f.obj.type === 'command.ack' && f.obj.data && f.obj.data.command_id === 'c3'), 8000, 'ack resume (dup cek)');
270
+ await until(() => stub.frames.some((f) => f.obj && f.obj.type === 'heartbeat'), 15000, 'frame heartbeat telah dikirim CLI');
271
+ await until(() => stub.frames.some((f) => f.opcode === 0xa), 10000, 'CLI membalas protocol ping dengan pong');
272
+
273
+ assert(cap.out.includes('set_model → opencode = sonnet-test-9'), 'stdout menampilkan persetujuan perintah set_model');
274
+ assert(cap.out.includes('PAUSE'), 'stdout menampilkan PAUSE');
275
+ assert(cap.out.includes('RESUME'), 'stdout menampilkan RESUME');
276
+ assert(cap.out.includes('di-assign'), 'stdout menandai tugas di-assign realtime');
277
+ assert(!cap.out.includes('fallback polling HTTP'), 'mode realtime aktif (tanpa fallback)');
278
+
279
+ state.done = true;
280
+ await proc.finish('SIGTERM');
281
+ assert(gotHello, 'hello pernah dikirim CLI');
282
+ } catch (e) {
283
+ await proc.finish();
284
+ dbg(e);
285
+ } finally {
286
+ await proc.finish();
287
+ await stub.stop();
288
+ }
289
+ if (!state.done) throw new Error('skenario realtime belum selesai');
290
+ passCount += 1;
291
+ console.log(' ✅ Skenario realtime selesai');
292
+ }
293
+
294
+ // ── Skenario 3: fallback polling HTTP ──
295
+ async function scenarioFallback() {
296
+ console.log('\n[3] Fallback: WS gagal (port mati) → polling HTTP tetap jalan');
297
+ const home = mkFakeHome('fallback');
298
+ // Kebijakan: provider default uji = opencode (template offline walau tidak dieksekusi).
299
+ writeJson(path.join(home, '.luang-cli', 'config.json'), {
300
+ api_url: 'http://127.0.0.1:3', // port mati
301
+ app_url: 'http://127.0.0.1',
302
+ key: 'lk_test_e2e_fallback',
303
+ default_provider: 'opencode',
304
+ providers: {
305
+ opencode: {
306
+ provider_type: 'opencode',
307
+ display_name: 'OpenCode',
308
+ path: 'stub-opencode',
309
+ exec_template: 'opencode run "{task}"',
310
+ },
311
+ },
312
+ telemetry: false,
313
+ });
314
+ const env = daemonEnv(home);
315
+ const { proc, cap } = spawnDaemon(env, ['agent']);
316
+ try {
317
+ await until(() => cap.out.includes('fallback polling HTTP'), 20000, 'notice fallback muncul');
318
+ await until(() => cap.out.includes('Poll gagal'), 12000, 'message polling gagal mencoba (perilaku lama tetap hidup)');
319
+ assert(!cap.out.includes('Realtime aktif'), 'tidak mengaku realtime ketika WS gagal');
320
+ } finally {
321
+ await proc.finish();
322
+ }
323
+ passCount += 1;
324
+ console.log(' ✅ Skenario fallback selesai');
325
+ }
326
+
327
+ // ── Skenario reconnect ──
328
+ async function scenarioReconnect() {
329
+ console.log('\n[4] Reconnect: koneksi WS diputus server → hello dikirim ulang, tanpa fallback');
330
+ const home = mkFakeHome('reconnect');
331
+ const port = 18301 + Math.floor(Math.random() * 400);
332
+ writeJson(path.join(home, '.luang-cli', 'config.json'), {
333
+ api_url: `http://127.0.0.1:${port}`,
334
+ app_url: 'http://127.0.0.1',
335
+ key: 'lk_test_e2e_reconnect',
336
+ default_provider: 'opencode',
337
+ providers: {
338
+ opencode: {
339
+ provider_type: 'opencode',
340
+ display_name: 'OpenCode',
341
+ path: 'stub-opencode',
342
+ exec_template: 'opencode run "{task}"',
343
+ },
344
+ },
345
+ telemetry: false,
346
+ });
347
+ const env = daemonEnv(home);
348
+ const stub = new StubWsServer({ port, expectedKey: 'lk_test_e2e_reconnect' });
349
+ await stub.start();
350
+ let handshakes = 0;
351
+ stub.onHandshake = () => { handshakes += 1; };
352
+ stub.onMsg = (f) => {
353
+ const m = f.obj;
354
+ if (m && m.type === 'hello') {
355
+ // jawab hello.ok agar CLI menganggap setiap koneksi siap
356
+ stub.broadcastText(JSON.stringify({ type: 'hello.ok', ts: Date.now(), data: { key_id: 'kr', user_id: 'ur', tenant_id: 't1', telemetry_enabled: false, server_time: Date.now() } }));
357
+ }
358
+ };
359
+ const { proc, cap } = spawnDaemon(env, ['agent']);
360
+ try {
361
+ await until(() => handshakes >= 1, 10000, 'handshake pertama');
362
+ await until(() => cap.out.includes('Realtime aktif'), 5000, 'realtime aktif pertama');
363
+ await sleep(400);
364
+ stub.killAll(); // putus tanpa close frame
365
+ await until(() => handshakes >= 2, 12000, 'handshake kedua (reconnect)');
366
+ await until(() => (cap.out.match(/WS terhubung ulang/g) || []).length >= 1, 6000, 'daemon mencetak reconnect');
367
+ const allOut = cap.out + cap.err;
368
+ assert(!allOut.includes('fallback polling HTTP'), 'tanpa fallback saat reconnect sukses');
369
+ } finally {
370
+ try { await proc.finish(); } catch (e) { try { proc.kill('SIGKILL'); } catch (e2) {} }
371
+ await stub.stop();
372
+ }
373
+ passCount += 1;
374
+ console.log(' ✅ Skenario reconnect selesai');
375
+ }
376
+
377
+ // ── Skenario 4: telemetry konsent + provider add --model ──
378
+ async function scenarioConsentAndModel() {
379
+ console.log('\n[4] telemetry on/off/status + provider add --model');
380
+ const home = mkFakeHome('consent');
381
+
382
+ // Stub HTTP di-fork jadi proses TERPISAH supaya event loop induk tetap
383
+ // bebas melayani saat runCli (spawnSync) memblokir — kalau in-process,
384
+ // request /agent/me dari CLI selalu timeout (tidak pernah dilayani).
385
+ const stub = fork(path.join(__dirname, 'http-stub.js'), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
386
+ const port = await new Promise((resolve, reject) => {
387
+ stub.once('message', (m) => { if (m && m.cmd === 'listening') resolve(m.port); else reject(new Error('pesan stub tak dikenal')); });
388
+ stub.once('error', reject);
389
+ setTimeout(() => reject(new Error('stub http tidak siap dalam 5s')), 5000);
390
+ });
391
+ const setServerTelemetry = (v) => stub.send({ telemetry_enabled: v });
392
+
393
+ // Kumpulkan payload heartbeat (IPC) — daftar sebelum runCli karena pesan
394
+ // IPC yang tiba tanpa listener akan hilang.
395
+ const heartbeats = [];
396
+ stub.on('message', (m) => {
397
+ if (m && m.cmd === 'heartbeat' && m.body) heartbeats.push(m.body);
398
+ });
399
+
400
+ const envA = { ...daemonEnv(home), LUANG_HEARTBEAT_SEC: undefined };
401
+ writeJson(path.join(home, '.luang-cli', 'config.json'), {
402
+ api_url: `http://127.0.0.1:${port}`,
403
+ app_url: 'http://127.0.0.1',
404
+ key: 'lk_test_e2e_consent',
405
+ default_provider: null,
406
+ providers: {},
407
+ });
408
+
409
+ try {
410
+ setServerTelemetry(false);
411
+ // status: lokal false default, server false
412
+ let r = runCli(envA, ['telemetry', 'status']);
413
+ assert(r.code === 0, 'telemetry status exit 0');
414
+ assert(r.stdout.includes('config.telemetry = false'), 'status: lokal default OFF');
415
+ assert(r.stdout.includes('telemetry_enabled = false'), 'status: server flag OFF');
416
+ assert(r.stdout.includes('❌ tidak dikirim'), 'status: efektif tidak dikirim');
417
+
418
+ // on → config true
419
+ r = runCli(envA, ['telemetry', 'on']);
420
+ assert(r.code === 0 && r.stdout.includes('Telemetry lokal: ON'), 'telemetry on → lokalis ON');
421
+ assert(homeConfig(home).telemetry === true, 'config.telemetry === true tersimpan');
422
+
423
+ // server flag ON → efektif ✅
424
+ setServerTelemetry(true);
425
+ r = runCli(envA, ['telemetry', 'status']);
426
+ assert(r.stdout.includes('lokal ✅ · server ✅'), 'status: lokal+server ON');
427
+ assert(r.stdout.includes('✅ DIKIRIM'), 'status: efektif DIKIRIM');
428
+
429
+ // off → config false lagi
430
+ r = runCli(envA, ['telemetry', 'off']);
431
+ assert(r.code === 0 && r.stdout.includes('Telemetry lokal: OFF'), 'telemetry off → lokal OFF');
432
+ assert(homeConfig(home).telemetry === false, 'config.telemetry === false kembali');
433
+ r = runCli(envA, ['telemetry', 'status']);
434
+ assert(r.stdout.includes('❌ tidak dikirim'), 'status: efektif drei OFF lagi (server still tidak terkirim)');
435
+
436
+ // provider add --model
437
+ r = runCli(envA, ['provider', 'add', 'opencode', '--model', 'sonnet-test-9']);
438
+ assert(r.code === 0 && r.stdout.includes('Model : sonnet-test-9'), 'provider add --model tersimpan & tampil');
439
+ assert(homeConfig(home).providers.opencode.model === 'sonnet-test-9', 'config providers.opencode.model');
440
+ r = runCli(envA, ['provider', 'list']);
441
+ assert(r.stdout.includes('Model') && r.stdout.includes('opencode'), 'provider list menunjukkan kolom Model');
442
+
443
+ // Kontrak baru: payload heartbeat menyertakan providers[].models (≤50 × 120 char)
444
+ // Catatan: pesan IPC datang async setelah spawnSync runCli selesai → tunggu.
445
+ await until(() => heartbeats.length >= 1, 8000, 'heartbeat HTTP terkirim saat provider list');
446
+ const hb = heartbeats[heartbeats.length - 1];
447
+ assert(hb && Array.isArray(hb.providers) && hb.providers.length > 0, 'heartbeat payload memuat providers[]');
448
+ const hbOpc = hb.providers.find((p) => p.provider_type === 'opencode');
449
+ assert(!!hbOpc, 'heartbeat providers memuat opencode');
450
+ assert(Array.isArray(hbOpc.models) && hbOpc.models.length <= 50, 'heartbeat providers[].models array (≤50)');
451
+ assert(hbOpc.models.every((m) => typeof m === 'string' && m.length <= 120), 'tiap model di heartbeat ≤120 karakter');
452
+ assert(hbOpc.models.includes('stub-model-a'), 'models heartbeat datang dari deteksi dinamis (stub opencode)');
453
+ } finally {
454
+ try { stub.send({ cmd: 'exit' }); } catch (e) { /* abaikan */ }
455
+ stub.kill();
456
+ }
457
+ passCount += 1;
458
+ console.log(' ✅ Skenario konsent + model selesai');
459
+ }
460
+
461
+ // ── Skenario 5: pesan eksekusi jujur (tanpa --exec TIDAK menyalahkan template) ──
462
+ async function scenarioExecMessages() {
463
+ console.log('\n[5] Pesan eksekusi: doExec=false → "eksekusi nonaktif"; --exec tanpa template → exec_template');
464
+ const home = mkFakeHome('execmsg');
465
+ const port = 18501 + Math.floor(Math.random() * 400);
466
+ const config = {
467
+ api_url: `http://127.0.0.1:${port}`,
468
+ app_url: 'http://127.0.0.1',
469
+ key: 'lk_test_e2e_execmsg',
470
+ default_provider: 'opencode',
471
+ providers: {
472
+ opencode: {
473
+ provider_type: 'opencode',
474
+ display_name: 'OpenCode',
475
+ path: 'stub-opencode',
476
+ exec_template: 'opencode run "{task}"',
477
+ },
478
+ },
479
+ telemetry: false,
480
+ };
481
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
482
+ const env = daemonEnv(home);
483
+ const stub = new StubWsServer({ port, expectedKey: config.key });
484
+ await stub.start();
485
+ const task = { id: 't-m1', task_code: 'TGS-201', title: 'Tugas tanpa eksekusi', description: 'pesan harus jujur', status: 'todo' };
486
+ const send = (type, data) => stub.broadcastText(JSON.stringify({ type, ts: Date.now(), data }));
487
+ stub.onMsg = (f) => {
488
+ const m = f.obj;
489
+ if (!m) return;
490
+ if (m.type === 'hello') {
491
+ send('hello.ok', { key_id: 'kx', user_id: 'ux', tenant_id: 'tx', telemetry_enabled: false, server_time: new Date().toISOString() });
492
+ send('task.snapshot', { tasks: [task] });
493
+ }
494
+ };
495
+
496
+ try {
497
+ // A) foreground TANPA --exec → notice "eksekusi nonaktif" (bukan salahkan template)
498
+ const a = spawnDaemon(env, ['agent']);
499
+ try {
500
+ await until(() => a.cap.out.includes('eksekusi nonaktif'), 15000, 'notice "eksekusi nonaktif" tanpa --exec');
501
+ assert(a.cap.out.includes('Eksekusi : nonaktif'), 'banner: Eksekusi : nonaktif');
502
+ assert(a.cap.out.includes('luang-cli agent --exec'), 'notice menyebut cara menyalakan: luang-cli agent --exec');
503
+ assert(a.cap.out.includes('agent start'), 'notice menyebut mode otomatis: agent start');
504
+ assert(!a.cap.out.includes('default provider belum punya exec_template'), 'TANPA --exec: TIDAK menyalahkan exec_template');
505
+ assert(!a.cap.out.includes('Jalankan sekarang?'), 'TANPA --exec: tidak ada prompt y/N');
506
+ await a.proc.finish('SIGTERM');
507
+ } finally {
508
+ await a.proc.finish();
509
+ }
510
+ const st = JSON.parse(fs.readFileSync(path.join(home, '.luang-cli', 'state.json'), 'utf8'));
511
+ assert(!st.executed || !st.executed['TGS-201'], 'tugas TIDAK ditandai executed saat eksekusi nonaktif');
512
+
513
+ // B) --exec tapi exec_template kosong → BARU menyalahkan exec_template
514
+ config.providers.opencode.exec_template = null;
515
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
516
+ const b = spawnDaemon(env, ['agent', '--exec']);
517
+ try {
518
+ await until(() => b.cap.out.includes('default provider belum punya exec_template'), 15000, 'notice exec_template saat --exec tanpa template');
519
+ assert(b.cap.out.includes('diminta tapi nonaktif'), 'banner: diminta tapi nonaktif (exec_template kosong)');
520
+ assert(!b.cap.out.includes('eksekusi nonaktif (jalankan'), '--exec: pesan tetap soal exec_template');
521
+ await b.proc.finish('SIGTERM');
522
+ } finally {
523
+ await b.proc.finish();
524
+ }
525
+ } finally {
526
+ await stub.stop();
527
+ }
528
+ passCount += 1;
529
+ console.log(' ✅ Skenario pesan eksekusi selesai');
530
+ }
531
+
532
+ // ── Skenario 6: agent start/stop/status/logs (fake HOME) + eksekusi auto ──
533
+ async function scenarioAgentLifecycle() {
534
+ console.log('\n[6] agent start/stop/status/logs (background, fake HOME) + eksekusi auto tanpa y/N');
535
+ const home = mkFakeHome('lifecycle');
536
+ const port = 18701 + Math.floor(Math.random() * 400);
537
+ const config = {
538
+ api_url: `http://127.0.0.1:${port}`,
539
+ app_url: 'http://127.0.0.1',
540
+ key: 'lk_test_e2e_lifecycle',
541
+ default_provider: 'opencode',
542
+ providers: {
543
+ opencode: {
544
+ provider_type: 'opencode',
545
+ display_name: 'OpenCode',
546
+ path: 'stub-opencode',
547
+ exec_template: 'opencode run "{task}"',
548
+ },
549
+ },
550
+ telemetry: false,
551
+ };
552
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
553
+ const env = daemonEnv(home);
554
+ const stub = new StubWsServer({ port, expectedKey: config.key });
555
+ await stub.start();
556
+ const task = { id: 't-l1', task_code: 'TGS-301', title: 'Tugas background', description: 'jalan tanpa terminal', status: 'todo' };
557
+ const send = (type, data) => stub.broadcastText(JSON.stringify({ type, ts: Date.now(), data }));
558
+ stub.onMsg = (f) => {
559
+ const m = f.obj;
560
+ if (!m) return;
561
+ if (m.type === 'hello') {
562
+ send('hello.ok', { key_id: 'kl', user_id: 'ul', tenant_id: 'tl', telemetry_enabled: false, server_time: new Date().toISOString() });
563
+ send('task.snapshot', { tasks: [task] });
564
+ }
565
+ };
566
+
567
+ const pidFile = path.join(home, '.luang-cli', 'agent.pid');
568
+ const logFile = path.join(home, '.luang-cli', 'agent.log');
569
+ const readPid = () => JSON.parse(fs.readFileSync(pidFile, 'utf8'));
570
+
571
+ try {
572
+ let r = runCli(env, ['agent', 'start']);
573
+ assert(r.code === 0, `agent start exit 0 (dapat ${r.code}) — ${r.stderr || r.stdout}`);
574
+ assert(r.stdout.includes('berjalan di background (pid'), 'start mencetak "berjalan di background (pid …"');
575
+ assert(r.stdout.includes('log:'), 'start mencetak path log');
576
+ const info = readPid();
577
+ assert(Number.isInteger(info.pid) && info.pid > 0, `agent.pid memuat pid (dapat ${info.pid})`);
578
+ assert(info.auto === true && info.mode === 'auto', 'agent.pid: auto=true, mode="auto" (default agent start)');
579
+ assert(typeof info.started_at === 'string' && info.started_at.length > 10, 'agent.pid: started_at ISO');
580
+ assert(homeConfig(home).auto_exec === true, 'config.auto_exec = true ditulis oleh agent start');
581
+
582
+ // Eksekusi AUTO: tidak ada tulisan "y" ke stdin — tugas langsung dijalankan.
583
+ await until(() => fs.existsSync(logFile) && fs.readFileSync(logFile, 'utf8').includes('PROVIDER-RUN'), 25000, 'exec auto berjalan dari background (stub provider)');
584
+ const logTxt = fs.readFileSync(logFile, 'utf8');
585
+ assert(logTxt.includes('Realtime aktif'), 'child: mode realtime aktif (terlihat di log)');
586
+ assert(logTxt.includes('otomatis tanpa konfirmasi'), 'banner child: mode auto (tanpa konfirmasi)');
587
+ assert(!logTxt.includes('Jalankan sekarang?'), 'mode auto: TANPA prompt y/N');
588
+ assert(logTxt.includes('Konfirmasi: otomatis'), 'log mencatat konfirmasi otomatis');
589
+ await until(
590
+ () => stub.frames.some((f) => f.obj && f.obj.type === 'task.status' && f.obj.data && f.obj.data.ref === 'TGS-301' && f.obj.data.status === 'done'),
591
+ 15000,
592
+ 'task.status done dari daemon background',
593
+ );
594
+
595
+ // status: pid, uptime, mode, auto_exec, 3 baris log terakhir
596
+ r = runCli(env, ['agent', 'status']);
597
+ assert(r.code === 0, 'agent status exit 0');
598
+ assert(r.stdout.includes(String(info.pid)) && r.stdout.includes('hidup'), 'status: pid + hidup');
599
+ assert(/Uptime\s+:/.test(r.stdout), 'status: uptime tampil');
600
+ assert(r.stdout.includes('auto (otomatis tanpa konfirmasi)'), 'status: mode auto tampil');
601
+ assert(r.stdout.includes('auto_exec = true'), 'status: config auto_exec tampil');
602
+ const marker = '── log (3 terakhir) ──';
603
+ assert(r.stdout.includes(marker), 'status: 3 baris log terakhir ditampilkan');
604
+ const tail = r.stdout.split(marker)[1].split('\n').map((s) => s.trim()).filter(Boolean);
605
+ assert(tail.length === 3, `status menampilkan tepat 3 baris log (dapat ${tail.length})`);
606
+ const logNow = fs.readFileSync(logFile, 'utf8');
607
+ assert(tail.every((l) => logNow.includes(l)), 'baris yang ditampilkan benar-benar isi agent.log');
608
+
609
+ // logs [--tail N] (default 30)
610
+ r = runCli(env, ['agent', 'logs', '--tail', '5']);
611
+ assert(r.code === 0, 'agent logs --tail 5 exit 0');
612
+ const logLines = r.stdout.split('\n').filter((l) => l.trim() !== '');
613
+ assert(logLines.length <= 5, `logs --tail 5 maksimal 5 baris (dapat ${logLines.length})`);
614
+ r = runCli(env, ['agent', 'logs']);
615
+ assert(r.code === 0 && r.stdout.includes('TGS-301 selesai'), 'logs (default) memuat hasil eksekusi TGS-301');
616
+
617
+ // stop → SIGTERM graceful, pid file hilang, proses benar-benar mati
618
+ r = runCli(env, ['agent', 'stop']);
619
+ assert(r.code === 0 && r.stdout.includes('dihentikan'), 'agent stop: sukses (SIGTERM graceful)');
620
+ assert(!fs.existsSync(pidFile), 'agent.pid dihapus setelah stop');
621
+ let alive = true;
622
+ try {
623
+ process.kill(info.pid, 0);
624
+ } catch (e) {
625
+ alive = false;
626
+ }
627
+ assert(!alive, 'proses background mati setelah stop');
628
+
629
+ r = runCli(env, ['agent', 'status']);
630
+ assert(r.stdout.includes('tidak berjalan'), 'status setelah stop: tidak berjalan');
631
+ r = runCli(env, ['agent', 'stop']);
632
+ assert(r.code === 0 && r.stdout.includes('tidak berjalan'), 'stop kedua (pid file sudah hilang) tetap exit 0');
633
+
634
+ // --no-auto → mode manual + config auto_exec false
635
+ r = runCli(env, ['agent', 'start', '--no-auto']);
636
+ assert(r.code === 0, `agent start --no-auto exit 0 (dapat ${r.code})`);
637
+ const info2 = readPid();
638
+ assert(info2.auto === false && info2.mode === 'manual', 'agent.pid (--no-auto): auto=false, mode="manual"');
639
+ assert(homeConfig(home).auto_exec === false, 'config.auto_exec = false ditulis oleh --no-auto');
640
+ r = runCli(env, ['agent', 'stop']);
641
+ assert(r.code === 0, 'stop setelah --no-auto');
642
+ } finally {
643
+ // bersihkan bila skenario gagal di tengah (jangan biarkan proses liar)
644
+ try {
645
+ const info = fs.existsSync(pidFile) ? JSON.parse(fs.readFileSync(pidFile, 'utf8')) : null;
646
+ if (info) {
647
+ try {
648
+ process.kill(info.pid, 'SIGKILL');
649
+ } catch (e) { /* abaikan */ }
650
+ fs.rmSync(pidFile, { force: true });
651
+ }
652
+ } catch (e) { /* abaikan */ }
653
+ await stub.stop();
654
+ }
655
+ passCount += 1;
656
+ console.log(' ✅ Skenario lifecycle agent start/stop/status/logs selesai');
657
+ }
658
+
659
+ // ── Skenario 7: models (registry + opencode dinamis + guard binary lain) ──
660
+ async function scenarioModels() {
661
+ console.log('\n[7] models: registry statis + opencode dinamis (stub) + honeypot claude/gemini/codex');
662
+ const home = mkFakeHome('models');
663
+ const marker = path.join(home, 'honeypot-executed.log');
664
+ const binDir = mkdirp(path.join(home, 'bin'));
665
+ // Honeypot: bila binary ini dieksekusi → menulis marker. Harus TIDAK pernah terjadi.
666
+ for (const b of ['claude', 'gemini', 'codex']) {
667
+ const p = path.join(binDir, b);
668
+ fs.writeFileSync(p, '#!/usr/bin/env bash\necho "$0 $*" >> "$HONEYPOT_MARKER"\nexit 0\n');
669
+ fs.chmodSync(p, 0o755);
670
+ }
671
+ const env = {
672
+ ...process.env,
673
+ HOME: home,
674
+ LUANG_NO_OPEN: '1',
675
+ HONEYPOT_MARKER: marker,
676
+ PATH: `${binDir}:${path.join(__dirname, 'bin')}:/usr/bin:/bin`,
677
+ };
678
+
679
+ // --json: struktur benar untuk SEMUA provider
680
+ let r = runCli(env, ['models', '--json']);
681
+ assert(r.code === 0, `models --json exit 0 (dapat ${r.code}) ${r.stderr || ''}`);
682
+ let j = null;
683
+ try {
684
+ j = JSON.parse(r.stdout);
685
+ } catch (e) {
686
+ j = null;
687
+ }
688
+ assert(j !== null && typeof j === 'object', 'models --json menghasilkan JSON valid');
689
+ const types = ['claude_code', 'codex', 'opencode', 'gemini_cli', 'custom'];
690
+ assert(types.every((t) => j && j[t]), `json memuat semua provider (${types.join(', ')})`);
691
+ for (const t of types) {
692
+ const e = j[t];
693
+ assert(!!e && e.provider === t, `${t}: field provider benar`);
694
+ assert(!!e && ['static', 'binary', 'config'].includes(e.source), `${t}: source valid (${e && e.source})`);
695
+ assert(!!e && Array.isArray(e.models), `${t}: models berupa array`);
696
+ assert(!!e && e.models.length <= 50, `${t}: maksimal 50 item`);
697
+ assert(!!e && e.models.every((m) => typeof m === 'string' && m.length <= 120), `${t}: tiap model ≤120 karakter`);
698
+ }
699
+ assert(j.claude_code.source === 'static', 'claude_code: statis (binary tidak dieksekusi)');
700
+ assert(j.codex.source === 'static' && j.gemini_cli.source === 'static', 'codex & gemini_cli: statis');
701
+ assert(j.opencode.source === 'binary', `opencode: deteksi dinamis via stub (dapat ${j.opencode.source})`);
702
+ assert(j.opencode.models.includes('stub-model-a'), 'opencode: model dari `opencode models` (stub)');
703
+
704
+ // provider tunggal
705
+ r = runCli(env, ['models', 'opencode', '--json']);
706
+ j = JSON.parse(r.stdout);
707
+ assert(Object.keys(j).length === 1 && Array.isArray(j.opencode.models), 'models <provider> --json: hanya provider itu');
708
+
709
+ // provider tak dikenal → error + daftar pilihan
710
+ r = runCli(env, ['models', 'bogus_provider']);
711
+ assert(r.code === 1 && /tidak dikenal/i.test(r.stderr), 'models bogus → exit 1 + pesan pilihan');
712
+
713
+ // tampilan tabel
714
+ r = runCli(env, ['models']);
715
+ assert(r.code === 0, 'models (tabel) exit 0');
716
+ assert(r.stdout.includes('Provider') && r.stdout.includes('Jumlah'), 'tabel: header Provider | Jumlah');
717
+ assert(r.stdout.includes('claude_code') && r.stdout.includes('gemini_cli'), 'tabel: semua provider tampil');
718
+ assert(r.stdout.includes('…+'), 'tabel: wrap maks 6 baris + "…+n lagi" untuk daftar panjang');
719
+
720
+ // Guard kebijakan: claude/gemini/codex TIDAK PERNAH dieksekusi
721
+ assert(!fs.existsSync(marker), 'KEBIJAKAN: binary claude/gemini/codex tidak pernah dieksekusi (honeypot bersih)');
722
+
723
+ passCount += 1;
724
+ console.log(' ✅ Skenario models selesai');
725
+ }
726
+
727
+ // ── Skenario 8: workspace folder (set/status/reset + dipakai exec) ──
728
+ async function scenarioWorkspace() {
729
+ console.log('\n[8] workspace: set via CLI → server+lokal sinkron, status tampil, reset bersih');
730
+ const home = mkFakeHome('workspace');
731
+ const stub = fork(path.join(__dirname, 'http-stub.js'), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
732
+ const port = await new Promise((resolve, reject) => {
733
+ stub.once('message', (m) => { if (m && m.cmd === 'listening') resolve(m.port); else reject(new Error('pesan stub tak dikenal')); });
734
+ stub.once('error', reject);
735
+ setTimeout(() => reject(new Error('stub http tidak siap dalam 5s')), 5000);
736
+ });
737
+
738
+ const envA = { ...daemonEnv(home), LUANG_HEARTBEAT_SEC: undefined };
739
+ writeJson(path.join(home, '.luang-cli', 'config.json'), {
740
+ api_url: `http://127.0.0.1:${port}`,
741
+ app_url: 'http://127.0.0.1',
742
+ key: 'lk_test_e2e_workspace',
743
+ default_provider: null,
744
+ providers: {},
745
+ });
746
+ // Folder kerja valid di dalam HOME palsu.
747
+ const workdir = path.join(home, 'projek');
748
+ fs.mkdirSync(workdir, { recursive: true });
749
+
750
+ try {
751
+ // status awal: server kosong → efektif folder saat ini
752
+ let r = runCli(envA, ['workspace']);
753
+ assert(r.code === 0, 'workspace (status) exit 0');
754
+ assert(r.stdout.includes('folder saat ini'), 'status awal: efektif folder saat ini');
755
+
756
+ // set ke folder yang ada → server + lokal sinkron
757
+ r = runCli(envA, ['workspace', workdir]);
758
+ assert(r.code === 0 && r.stdout.includes('Workspace disimpan'), 'workspace set → tersimpan');
759
+ assert(homeConfig(home).workspace === workdir, 'config.workspace tersimpan lokal');
760
+
761
+ // status lagi → server menampilkan path yang sama (bukti POST /agent/workspace masuk stub)
762
+ r = runCli(envA, ['workspace']);
763
+ assert(r.code === 0 && r.stdout.includes(workdir), 'status: server menampilkan path yang diset');
764
+
765
+ // set ke folder tak ada → ditolak, config tidak berubah
766
+ r = runCli(envA, ['workspace', path.join(home, 'tidak-ada')]);
767
+ assert(r.code === 1, 'workspace path tak ada → exit 1');
768
+ assert(homeConfig(home).workspace === workdir, 'config.workspace tidak berubah saat gagal');
769
+
770
+ // reset → kosong kembali
771
+ r = runCli(envA, ['workspace', '--reset']);
772
+ assert(r.code === 0 && r.stdout.includes('direset'), 'workspace --reset → direset');
773
+ assert(!homeConfig(home).workspace, 'config.workspace kosong setelah reset');
774
+ r = runCli(envA, ['workspace']);
775
+ assert(r.code === 0 && r.stdout.includes('folder saat ini'), 'status setelah reset: folder saat ini');
776
+ } finally {
777
+ try { stub.send({ cmd: 'exit' }); } catch (e) { /* abaikan */ }
778
+ stub.kill();
779
+ }
780
+ passCount += 1;
781
+ console.log(' ✅ Skenario workspace selesai');
782
+ }
783
+
784
+ // ── main ──
785
+ async function main() {
786
+ console.log(`E2E luang-cli — fakir HOME: ${baseTmp}`);
787
+ scenarioSyntax();
788
+ await scenarioRealtime();
789
+ await scenarioFallback();
790
+ await scenarioConsentAndModel();
791
+ await scenarioExecMessages();
792
+ await scenarioAgentLifecycle();
793
+ await scenarioModels();
794
+ await scenarioWorkspace();
795
+ console.log(`\nSEMIUA PASS ✅ (${passCount} assertion)`);
796
+ }
797
+
798
+ main()
799
+ .then(() => {
800
+ process.exit(failCount ? 1 : 0);
801
+ })
802
+ .catch((e) => {
803
+ console.error(`\nE2E GAGAL ❌: ${e.message}`);
804
+ console.error(e.stack);
805
+ process.exit(1);
806
+ });