luang-cli 0.3.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.
- package/README.md +77 -5
- package/bin/luang-cli.js +72 -3
- package/lib/agent.js +386 -22
- package/lib/commands.js +157 -1
- package/lib/core.js +30 -0
- package/lib/models.js +343 -0
- package/package.json +2 -2
- package/test/bin/opencode +5 -0
- package/test/http-stub.js +39 -1
- package/test/run-e2e.js +349 -1
package/test/run-e2e.js
CHANGED
|
@@ -117,7 +117,7 @@ function homeConfig(home) {
|
|
|
117
117
|
// ── Skenario 1: sintaks ──
|
|
118
118
|
function scenarioSyntax() {
|
|
119
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/ws.js', 'test/run-e2e.js', 'test/ws-stub.js'];
|
|
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
121
|
for (const f of files) {
|
|
122
122
|
const r = spawnSync(process.execPath, ['--check', path.join(ROOT, f)], { encoding: 'utf8' });
|
|
123
123
|
assert(r.status === 0, `${f} lolos --check${r.stderr ? ' — ' + r.stderr.slice(0, 200) : ''}`);
|
|
@@ -183,6 +183,9 @@ async function scenarioRealtime() {
|
|
|
183
183
|
assert(typeof m.data.version === 'string' && m.data.version.length > 0, 'hello menyertakan version');
|
|
184
184
|
assert(Array.isArray(m.data.providers) && m.data.providers.some((p) => p.type === 'opencode'), 'hello menyertakan provider opencode');
|
|
185
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');
|
|
186
189
|
gotHello = true;
|
|
187
190
|
// Langsung perintah server: set_model SEBELUM tugas dikirim (agar exec pakai model)
|
|
188
191
|
phase = 1;
|
|
@@ -387,6 +390,13 @@ async function scenarioConsentAndModel() {
|
|
|
387
390
|
});
|
|
388
391
|
const setServerTelemetry = (v) => stub.send({ telemetry_enabled: v });
|
|
389
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
|
+
|
|
390
400
|
const envA = { ...daemonEnv(home), LUANG_HEARTBEAT_SEC: undefined };
|
|
391
401
|
writeJson(path.join(home, '.luang-cli', 'config.json'), {
|
|
392
402
|
api_url: `http://127.0.0.1:${port}`,
|
|
@@ -429,6 +439,17 @@ async function scenarioConsentAndModel() {
|
|
|
429
439
|
assert(homeConfig(home).providers.opencode.model === 'sonnet-test-9', 'config providers.opencode.model');
|
|
430
440
|
r = runCli(envA, ['provider', 'list']);
|
|
431
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)');
|
|
432
453
|
} finally {
|
|
433
454
|
try { stub.send({ cmd: 'exit' }); } catch (e) { /* abaikan */ }
|
|
434
455
|
stub.kill();
|
|
@@ -437,6 +458,329 @@ async function scenarioConsentAndModel() {
|
|
|
437
458
|
console.log(' ✅ Skenario konsent + model selesai');
|
|
438
459
|
}
|
|
439
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
|
+
|
|
440
784
|
// ── main ──
|
|
441
785
|
async function main() {
|
|
442
786
|
console.log(`E2E luang-cli — fakir HOME: ${baseTmp}`);
|
|
@@ -444,6 +788,10 @@ async function main() {
|
|
|
444
788
|
await scenarioRealtime();
|
|
445
789
|
await scenarioFallback();
|
|
446
790
|
await scenarioConsentAndModel();
|
|
791
|
+
await scenarioExecMessages();
|
|
792
|
+
await scenarioAgentLifecycle();
|
|
793
|
+
await scenarioModels();
|
|
794
|
+
await scenarioWorkspace();
|
|
447
795
|
console.log(`\nSEMIUA PASS ✅ (${passCount} assertion)`);
|
|
448
796
|
}
|
|
449
797
|
|