169 lines
20 KiB
JavaScript
169 lines
20 KiB
JavaScript
/**
|
|
* test/hal-ai-039.test.mjs — Ariweb Suite 0.3.9(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.9.md`, Oz 2026-10-29): 로그인 터미널·텍스트 셸 개선, 모델 선택, 슬래시 명령, 버그 수정.
|
|
*
|
|
* 계약(요약)
|
|
* 1) CLI 로그인: claude 의 실제 로그인 명령은 `claude auth login`(`claude login` 은 대화형 세션 시작). 셸 명령표가 이를 쓰고, 대화형 세션(bare `claude`/`codex`)도 허용 명령에 있어
|
|
* 신뢰(trust) 선택 같은 TUI 흐름을 화면에서 끝낼 수 있다. 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더).
|
|
* 2) 텍스트 셸은 원시 키 입력(화살표·Enter·Esc·Ctrl+C 등 바이트 그대로)을 받고, 단말 크기(cols·rows)를 환경으로 넘긴다. 브라우저 쪽은 순수 단말 에뮬레이터(term.mjs)로 TUI 출력을 그린다.
|
|
* 3) 세션 수 오류 수정: 끝난·종료된 세션은 한도에 세지 않고, 닫기는 세션을 끝낸다. 한도는 3.
|
|
* 4) 모델 선택: 확인된 모델 목록에서 고른다(PATCH model, 목록 밖은 거부). 방 목록이 연결의 모델 목록을 준다.
|
|
* 5) 슬래시 명령: `/help`(명령 목록, 모델 호출 없음) · `/status`·`/servers`… (모델에게 보낼 지시로 확장) · `/order <지시>` · `/model <이름>` · `/reset` · `/tools`. 모르는 명령은 400.
|
|
*/
|
|
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
|
|
import { invoke } from '../lib/http.mjs';
|
|
import { createHalDeps } from '../hal/deps.mjs';
|
|
import { buildApp } from '../hal/app.mjs';
|
|
import * as ops from '../hal/ops.mjs';
|
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
|
function fakeProvider() {
|
|
const calls = []; const script = [];
|
|
const text = (t) => ({ id: 'msg_' + calls.length, type: 'message', role: 'assistant', model: 'claude-fake', content: [{ type: 'text', text: t }], stop_reason: 'end_turn', usage: { input_tokens: 11, output_tokens: 7 } });
|
|
const fetchImpl = async (url, init = {}) => {
|
|
const u = String(url); const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); let body = null; try { body = init.body ? JSON.parse(init.body) : null; } catch (_) { body = init.body; }
|
|
const rec = { url: u, method: init.method || 'GET', headers, body }; calls.push(rec);
|
|
const respond = (j, status = 200) => ({ ok: status < 400, status, json: async () => j, text: async () => JSON.stringify(j) });
|
|
if (u.endsWith('/v1/models') && rec.method === 'GET') return respond({ data: [{ id: 'claude-fake' }, { id: 'claude-fake-mini' }] });
|
|
const fn = script.shift(); const j = fn ? fn(rec) : text(`응답 ${calls.length}`); return respond(j, j && j.__status ? j.__status : 200);
|
|
};
|
|
return { fetchImpl, calls, script, text };
|
|
}
|
|
function makeHal(fp = fakeProvider()) {
|
|
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
|
|
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
|
|
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: fp.fetchImpl });
|
|
return { d, app: buildApp(d), dataDir, fp };
|
|
}
|
|
async function loginOp(h) {
|
|
const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' });
|
|
const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok, JSON.stringify(r.json));
|
|
return { pw, as: (method, path, body) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) };
|
|
}
|
|
const texts = (msgs) => msgs.map((m) => ({ role: m.role, text: (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('') }));
|
|
const roomOf = async (op, id) => (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === id);
|
|
/** 가짜 CLI 설치(HOME/.local/bin). claude: auth login 은 TUI 흉내(화살표·Enter 로 고르고 코드 입력), interactive(bare)는 신뢰 질문 */
|
|
function fakeClis(h) {
|
|
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
|
|
writeFileSync(join(bin, 'claude'), `#!/bin/sh
|
|
if [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi
|
|
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false, "authMethod": "none"}'; exit 0; fi
|
|
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then printf 'size=%sx%s cwd=%s\\r\\n' "$COLUMNS" "$LINES" "$(pwd)"; printf 'Select login method:\\r\\n> Claude account\\r\\n Console\\r\\n'; IFS= read -r k; printf 'key=%s\\r\\n' "$(printf '%s' "$k" | od -An -c | tr -s ' ')"; printf 'Paste code here: '; IFS= read -r code; printf 'Logged in with %s\\r\\n' "$code"; exit 0; fi
|
|
if [ -z "$1" ]; then printf 'Do you trust the files in this folder? (y/n) '; IFS= read -r a; printf 'trust=%s\\r\\n' "$a"; exit 0; fi
|
|
echo "unknown $@"; exit 2
|
|
`); chmodSync(join(bin, 'claude'), 0o755);
|
|
writeFileSync(join(bin, 'codex'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.162.1"; exit 0; fi\nif [ "$1" = "login" ] && [ "$2" = "status" ]; then echo "Logged in using ChatGPT"; exit 0; fi\nif [ "$1" = "login" ]; then echo "open https://auth.openai.com/device"; IFS= read -r x; echo "done $x"; exit 0; fi\necho "interactive codex"; IFS= read -r x; exit 0\n`); chmodSync(join(bin, 'codex'), 0o755);
|
|
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
|
|
return { home, bin, restore() { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; } };
|
|
}
|
|
async function waitOut(op, id, re, n = 80) { let r; for (let i = 0; i < n; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${id}?after=0`); if (re.test(r.json.output)) return r.json; } throw new Error(`출력 대기 실패 ${re}: ${r && JSON.stringify(r.json).slice(0, 400)}`); }
|
|
async function waitEnd(op, id, n = 80) { let r; for (let i = 0; i < n; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${id}?after=0`); if (r.json.session.status !== 'running') return r.json; } throw new Error('종료 대기 실패'); }
|
|
|
|
await test('CLI 로그인 명령 수정: claude 는 `auth login`, 대화형 세션(bare)도 허용, 작업 폴더는 HOME; 원시 키 입력(화살표·Enter)과 단말 크기 전달', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const fx = fakeClis(h);
|
|
try {
|
|
let r = await op.as('GET', '/ops/api/ai/shell'); eq(r.json.commands.claude.login, ['auth', 'login']); eq(r.json.commands.claude.logout, ['auth', 'logout']); eq(r.json.commands.claude.interactive, []); eq(r.json.commands.codex.interactive, []); ok(r.json.guide.claude.interactive && r.json.guide.claude.interactive.some((g) => /신뢰|trust/i.test(g)), '대화형(신뢰 질문) 안내');
|
|
r = await op.as('GET', '/ops/api/ai/cli'); ok(/claude auth login/.test(r.json.clis.find((c) => c.tool === 'claude').loginHint), r.json.clis[0].loginHint);
|
|
// auth login: 크기·작업 폴더·화살표 키·코드 입력
|
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login', cols: 120, rows: 40 }); eq(r.status, 200, JSON.stringify(r.json)); const s = r.json.session; eq(s.cols, 120); eq(s.rows, 40);
|
|
let o = await waitOut(op, s.id, /Select login method/); ok(/size=120x40/.test(o.output), o.output); ok(o.output.includes(`cwd=${fx.home}`), '작업 폴더 = HOME');
|
|
await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: '\x1b[B\r' }); o = await waitOut(op, s.id, /key=/); ok(/033|\\033|\[ B/.test(o.output), '화살표 바이트가 그대로 전달: ' + o.output.split('\n').find((l) => /key=/.test(l)));
|
|
await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: 'CODE-9\r' }); o = await waitEnd(op, s.id); ok(/Logged in with CODE-9/.test(o.output)); eq(o.session.exitCode, 0);
|
|
// 대화형: 신뢰 질문에 y 한 글자(Enter 없이도 전달) → 응답
|
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'interactive' }); eq(r.status, 200, JSON.stringify(r.json)); const s2 = r.json.session; await waitOut(op, s2.id, /trust the files/);
|
|
await op.as('POST', `/ops/api/ai/shell/${s2.id}/input`, { data: 'y' }); await sleep(80); await op.as('POST', `/ops/api/ai/shell/${s2.id}/input`, { data: '\r' }); o = await waitEnd(op, s2.id); ok(/trust=y/.test(o.output), o.output);
|
|
// 옛 명령 이름(login 대신 'auth login' 문자열)은 거부, 임의 명령 거부
|
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'auth login' }); eq(r.status, 400); r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: '--dangerously-skip-permissions' }); eq(r.status, 400);
|
|
} finally { fx.restore(); }
|
|
});
|
|
|
|
await test('세션 한도 오류 수정: 끝난·종료한 세션은 한도에 세지 않고, 한도는 3, 목록은 살아 있는 것과 끝난 것을 구분한다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const fx = fakeClis(h);
|
|
try {
|
|
const ids = []; for (let i = 0; i < 3; i++) { const r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'interactive' }); eq(r.status, 200, JSON.stringify(r.json)); ids.push(r.json.session.id); }
|
|
let r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'status' }); eq(r.status, 409); eq(r.json.error, 'too_many_sessions'); ok(Array.isArray(r.json.detail.sessions) && r.json.detail.sessions.length === 3, '어떤 세션이 열려 있는지 알려 준다');
|
|
// 하나를 닫으면(DELETE) 바로 새로 열 수 있다
|
|
r = await op.as('DELETE', `/ops/api/ai/shell/${ids[0]}`); eq(r.status, 200); await waitEnd(op, ids[0]);
|
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'status' }); eq(r.status, 200, JSON.stringify(r.json)); const st = r.json.session; await waitEnd(op, st.id);
|
|
// 스스로 끝난 세션(status 명령)은 한도에 들지 않는다 → 또 열 수 있다
|
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'version' }); eq(r.status, 200, JSON.stringify(r.json)); await waitEnd(op, r.json.session.id);
|
|
r = await op.as('GET', '/ops/api/ai/shell'); const running = r.json.sessions.filter((x) => x.status === 'running'); eq(running.length, 2); ok(r.json.sessions.some((x) => x.status !== 'running'), '끝난 세션도 목록에 보인다(상태 구분)');
|
|
// 모두 종료
|
|
r = await op.as('POST', '/ops/api/ai/shell/kill-all'); eq(r.status, 200); for (const id of ids.slice(1)) await waitEnd(op, id); r = await op.as('GET', '/ops/api/ai/shell'); eq(r.json.sessions.filter((x) => x.status === 'running').length, 0);
|
|
} finally { fx.restore(); }
|
|
});
|
|
|
|
await test('단말 에뮬레이터(순수): 커서 이동·지우기·줄 바꿈·CR 덮어쓰기·대체 화면·스크롤·SGR 무시·OSC 무시', async () => {
|
|
const T = await import('../hal/ui/public/term.mjs'); const t = new T.Term(20, 4);
|
|
t.feed('hello\r\nworld'); eq(t.lines().slice(0, 2), ['hello', 'world']); eq(t.cursor(), { row: 1, col: 5 });
|
|
t.feed('\rWORLD'); eq(t.lines()[1], 'WORLD'); // CR 덮어쓰기(진행 표시줄)
|
|
t.feed('\x1b[1;1H\x1b[2K> Claude account'); eq(t.lines()[0], '> Claude account'); // 커서 이동 + 줄 지우기
|
|
t.feed('\x1b[2;3H\x1b[31mX\x1b[0m'); eq(t.lines()[1], 'WOXLD'); // 색(SGR)은 무시하고 글자만
|
|
t.feed('\x1b]0;title\x07\x1b[?25l'); eq(t.lines()[0], '> Claude account'); // OSC·커서 숨김 무시
|
|
t.feed('\x1b[2J\x1b[H'); eq(t.lines().join('').trim(), ''); eq(t.cursor(), { row: 0, col: 0 }); // 화면 지우기
|
|
t.feed('a\r\nb\r\nc\r\nd\r\ne'); eq(t.lines(), ['b', 'c', 'd', 'e']); ok(t.scrollback().includes('a'), '밀려난 줄은 스크롤백에'); // 스크롤
|
|
t.feed('\x1b[?1049h'); eq(t.lines().join('').trim(), '', '대체 화면은 빈 화면'); t.feed('alt'); t.feed('\x1b[?1049l'); eq(t.lines(), ['b', 'c', 'd', 'e'], '대체 화면 종료 뒤 복원');
|
|
t.feed('\x1b[4;1H\x1b[3C\x1b[Dz'); eq(t.cursor().col, 3); eq(t.lines()[3], 'e z'); // 4행 1열 → 오른쪽 3, 왼쪽 1 → 열 2 에 z 쓰고 3
|
|
const html = T.renderHtml(t); ok(html.includes('<') === false && typeof html === 'string'); const t2 = new T.Term(10, 2); t2.feed('<b>&'); ok(T.renderHtml(t2).includes('<b>&'), '이스케이프');
|
|
const t3 = new T.Term(5, 2); t3.feed('abcdefgh'); eq(t3.lines(), ['abcde', 'fgh'], '줄 끝에서 자동 줄 바꿈');
|
|
});
|
|
|
|
await test('모델 선택: 확인된 목록에서 고른다(PATCH·/model 명령), 목록 밖은 거부, 방 목록이 모델 목록을 준다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection;
|
|
await op.as('POST', `/ops/api/ai/connections/${c.id}/test`);
|
|
r = await op.as('GET', '/ops/api/ai/rooms'); const room = r.json.rooms.find((x) => x.connectionId === c.id); eq(room.connection.models, ['claude-fake', 'claude-fake-mini']); eq(room.connection.model, 'claude-fake');
|
|
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { model: 'claude-fake-mini' }); eq(r.status, 200); eq(r.json.connection.model, 'claude-fake-mini');
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/model claude-fake' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.responders, []); await h.d.ai.drain();
|
|
eq((await op.as('GET', '/ops/api/ai/connections')).json.connections[0].model, 'claude-fake'); eq(h.fp.calls.filter((x) => x.method === 'POST').length, 0, '모델 호출 없음');
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(r.json.messages.some((m) => m.role === 'system' && /claude-fake/.test(texts([m])[0].text)), '전환 안내');
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/model nope-model' }); eq(r.status, 400); eq(r.json.error, 'model_unavailable');
|
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '/model claude-fake' }); eq(r.status, 400); eq(r.json.error, 'command_needs_direct_room');
|
|
});
|
|
|
|
await test('슬래시 명령: /help 는 모델 호출 없이 명령 목록, /status·/servers 는 지시로 확장되어 모델에게, /order 는 작업 지시 틀, /tools·/reset 은 즉시, 모르는 명령은 400', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection; const room = await roomOf(op, c.id);
|
|
r = await op.as('GET', '/ops/api/ai/commands'); eq(r.status, 200); const names = r.json.commands.map((x) => x.name); for (const n of ['help', 'status', 'servers', 'jobs', 'releases', 'incidents', 'order', 'model', 'reset', 'tools', 'deploy', 'rollback']) ok(names.includes(n), '명령 ' + n); ok(r.json.commands.every((x) => x.usage && x.description));
|
|
// /help: 모델 호출 없음, 시스템 메시지에 명령 목록
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/help' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.responders, []); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.method === 'POST').length, 0);
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const help = r.json.messages.find((m) => m.role === 'system'); ok(help && /\/order/.test(texts([help])[0].text) && /\/status/.test(texts([help])[0].text), '명령 목록');
|
|
// /status → 모델에게는 확장된 지시(hal_overview 사용 지시), 화면에는 원문 유지
|
|
h.fp.script.push((req) => { const last = req.body.messages[req.body.messages.length - 1]; const t = JSON.stringify(last.content); ok(/hal_overview/.test(t) && /운영 현황/.test(t), '확장 지시: ' + t.slice(0, 200)); return h.fp.text('현황 요약'); });
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/status' }); eq(r.json.responders, [c.id]); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const um = r.json.messages.filter((m) => m.role === 'user').pop(); eq(texts([um])[0].text, '/status'); ok(um.content.some((b) => b.type === 'command' && b.name === 'status'), '명령 블록 기록');
|
|
// /servers (인자 전달) · /order
|
|
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/hal_servers_list/.test(t) && /web/.test(t), t.slice(0, 200)); return h.fp.text('ok'); });
|
|
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/servers web' }); await h.d.ai.drain();
|
|
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/디스크 점검/.test(t) && /계획/.test(t) && /승인/.test(t), t.slice(0, 300)); return h.fp.text('계획 세움'); });
|
|
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/order 웹1 디스크 점검' }); await h.d.ai.drain();
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/order' }); eq(r.status, 400); eq(r.json.error, 'command_args_required');
|
|
// /deploy 인자 → 고위험 도구 제안 지시
|
|
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/hal_module_deploy/.test(t) && /portal/.test(t) && /0\.3\.9/.test(t), t.slice(0, 300)); return h.fp.text('제안'); });
|
|
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/deploy portal 0.3.9' }); await h.d.ai.drain();
|
|
// /tools: 즉시(모델 호출 없음) · /reset · 모르는 명령
|
|
const before = h.fp.calls.length; r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/tools' }); eq(r.json.responders, []); eq(h.fp.calls.length, before);
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(/hal_health/.test(texts([r.json.messages.pop()])[0].text));
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/reset' }); eq(r.status, 200); r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(r.json.messages.length, 0);
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/nope x' }); eq(r.status, 400); eq(r.json.error, 'unknown_command'); ok(/\/help/.test(r.json.message));
|
|
// 통합 방에서도 /help 동작, 슬래시로 시작하지 않는 글은 그대로
|
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '/help' }); eq(r.status, 200); eq(r.json.responders, []);
|
|
h.fp.script.push(() => h.fp.text('ok')); r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '일반 지시 /status 아님' }); eq(r.json.responders, [c.id]); await h.d.ai.drain();
|
|
});
|
|
|
|
await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에뮬레이터·세션 목록, 모델 선택 UI, /help 안내, 패키지 0.3.9, 개발 지시 v0.3.9', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
const r = await op.as('GET', '/ops/ai'); eq(r.status, 200); for (const s of ['claude auth login', '텍스트 셸', '대화형']) ok(r.text.includes(s), '화면 문구 ' + s);
|
|
const p = await op.as('GET', '/ops/'); ok(p.text.includes('/help'), '패널 입력 안내에 /help');
|
|
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
|
|
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
|
|
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
|
|
});
|
|
done();
|