Files
AriWeb-Suite/test/hal-ai-0310.test.mjs
T
zzabisagent-labandClaude Fable 5.1 2b31543a86 HAL 0.4.1: 서버 등록 TLS 수정·"이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음
- hal/tls.mjs: 공개 주소 TLS 신뢰 확인(시스템 CA → 실패면 체인·공개키 핀), 신뢰 앵커(체인 자체 서명 / <data>/tls/hal-ca.pem), 10분 캐시, 핀 설치 명령(curl -k --pinnedpubkey … --hal-pin). 공개 경로 GET /agent/v1/hal-ca.pem · /agent/v1/tls, CLI tls-probe
- deploy/agent-install.sh: --hal-pin(핀으로 CA 받기)·--ca·--insecure, curl --cacert / Node NODE_EXTRA_CA_CERTS / register --ca / 유닛 Environment, 연결 실패 원인·조치 한 줄, 이미 등록된 서버 안내
- agent/halca.mjs + agent/index.mjs·config.mjs: caFile 런타임 적용(tls.setDefaultCACertificates), fetch 실패를 읽을 수 있는 한 줄로(종료 1), 이미 등록(종료 2, --force), HAL 거부(종료 3), heartbeat observability → hawkeye
- core.registerAgent: 같은 서버의 두 번째 토큰 409 server_already_registered / 쓴 토큰 409 token_used("이미 등록된 서버"), 거부 토큰 미소비, 재등록 허용(replace: 기존 Agent 해지, 감사 agent.replace), 마이그레이션 hal_0011_token_replace, API/CLI --replace --json(재인증), ops.js 재등록 확인·핀 안내
- deploy/hal-host-agent.sh(server-create --profile hal → server-token --json → agent-install loopback → server-approve), bootstrap-hal.sh 6/7 단계(--no-agent), CLI server-create·server-approve, SERVER_PROFILES.hal
- core.deploymentStatus/selfHealth.deployment, 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기: …" / "진행 중", 배포 릴리스 화면 문구, heartbeat canonicalModuleName(0.3.17 잔여 결함)
- 개발 지시 v0.4.1(Oz)·VERSION_HISTORY 0.4.1·README·docs/HAL-AGENT §6·RUNBOOK §11·AI 스킬, 도입 모듈 변경 없음(Webmaker 1.9.0·AriMail 1.1.0)
- test/hal-041.test.mjs RED → GREEN 7/7(실제 자체 서명 TLS 픽스처·별도 프로세스 CLI), 기존 배너·프로필·버전 테스트 갱신, 전체 32파일 통과, 로컬 E2E 보고서 2026-11-06

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 08:40:09 +00:00

65 lines
5.8 KiB
JavaScript

/**
* test/hal-ai-0310.test.mjs — Ariweb Suite 0.3.10(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.10.md`, Oz 2026-10-29): 텍스트 셸 복사·주소 추출.
*
* 계약
* 1) 단말 에뮬레이터가 출력에서 URL 을 뽑아 준다(`extractUrls`). 줄 끝에서 끊겨 다음 줄로 이어진 긴 주소(줄이 꽉 찬 경우)는 이어 붙인다. 스크롤백·상자 문자(│)·마침표도 처리.
* 2) 단말 영역에서 글을 선택한 채 Ctrl+C 를 누르면 셸로 보내지 않고 복사한다(`copyDecision`). Ctrl+Shift+C 는 항상 복사, Ctrl+Insert 도 복사.
* 3) 셸 화면(순수 렌더러)에 "감지된 주소" 목록(복사·열기), 전체 출력 복사, 텍스트로 보기(선택 가능한 텍스트 상자), 복사 안내가 있다.
*/
import { test, eq, ok, tmpDir, done } 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 } 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)), '..');
await test('URL 추출: 줄 끝에서 끊긴 긴 주소 이어 붙이기, 스크롤백 포함, 상자 문자·마침표 제외, 중복 제거', async () => {
const T = await import('../hal/ui/public/term.mjs');
const t = new T.Term(40, 4);
t.feed('Open this URL in your browser:\r\n');
t.feed('https://auth.example.test/oauth/authorize?c=' + 'abc123&state=xyz paste code\r\n'); // 44자 → 40에서 끊겨 다음 줄로
eq(t.lines()[1].length, 40, '줄이 꽉 찼다'); eq(T.extractUrls(t), ['https://auth.example.test/oauth/authorize?c=abc123&state=xyz']);
// 꽉 차지 않은 줄 끝의 주소는 다음 줄과 잇지 않는다
const t2 = new T.Term(60, 4); t2.feed('see https://a.example.test/x.\r\nnext line https://b.example.test/y\r\n'); eq(T.extractUrls(t2), ['https://a.example.test/x', 'https://b.example.test/y']);
// 상자 문자 안의 주소, 스크롤백으로 밀려난 주소, 중복
const t3 = new T.Term(50, 2); t3.feed('│ https://c.example.test/login?code=1 │\r\n'); t3.feed('again https://c.example.test/login?code=1\r\n'); t3.feed('x\r\ny\r\n'); ok(t3.scrollback().length >= 1); eq(T.extractUrls(t3), ['https://c.example.test/login?code=1']);
// 대체 화면(TUI)에서도
const t4 = new T.Term(30, 3); t4.feed('\x1b[?1049h'); t4.feed('Visit https://d.example.test/dev'); t4.feed('ice?user_code=ABCD-EFGH now'); eq(T.extractUrls(t4), ['https://d.example.test/device?user_code=ABCD-EFGH']);
// 단말 글 전체(스크롤백 + 화면, 이어 붙인 줄 포함)
ok(T.plainText(t).includes('abc123&state=xyz'));
});
await test('복사 판정: 선택이 있으면 Ctrl+C 는 복사, 없으면 셸로 전송; Ctrl+Shift+C·Ctrl+Insert 는 항상 복사; Ctrl+V 는 붙여넣기', async () => {
const T = await import('../hal/ui/public/term.mjs');
eq(T.copyDecision({ key: 'c', ctrlKey: true }, true), 'copy'); eq(T.copyDecision({ key: 'c', ctrlKey: true }, false), 'send');
eq(T.copyDecision({ key: 'C', ctrlKey: true, shiftKey: true }, false), 'copy'); eq(T.copyDecision({ key: 'Insert', ctrlKey: true }, false), 'copy');
eq(T.copyDecision({ key: 'v', ctrlKey: true, shiftKey: true }, false), 'paste'); eq(T.copyDecision({ key: 'v', ctrlKey: true }, false), 'paste');
eq(T.copyDecision({ key: 'd', ctrlKey: true }, true), 'send'); eq(T.copyDecision({ key: 'Enter' }, true), 'send');
eq(T.keyToBytes({ key: 'c', ctrlKey: true }), '\u0003');
});
await test('셸 화면(순수 렌더러): 감지된 주소 목록(복사·열기, 이스케이프), 전체 복사·텍스트로 보기·복사 안내가 있는 모달', async () => {
const AR = await import('../hal/ui/public/ai-render.mjs');
const urls = AR.shellUrlsHtml(['https://auth.example.test/a?x=1&y=<b>']); ok(urls.includes('&amp;y=&lt;b&gt;') && !urls.includes('<b>'), '이스케이프'); ok(/data-act="ai-shell-copy-url"/.test(urls) && /복사/.test(urls) && /target="_blank"/.test(urls) && /rel="noopener/.test(urls) && /열기/.test(urls));
ok(AR.shellUrlsHtml([]).includes('감지된 주소 없음'));
const m = AR.shellModalHtml({ id: 's1', tool: 'claude', command: 'login', status: 'running', cols: 100, rows: 30 }, ['안내 1']);
for (const s of ['id="ai-term"', 'id="ai-shell-urls"', 'data-act="ai-shell-copy-all"', 'data-act="ai-shell-text"', 'id="ai-shell-textview"', 'Ctrl+Shift+C', '안내 1', 'data-key=']) ok(m.includes(s), '모달 요소 ' + s);
ok(!m.includes('<안내'), '안내 이스케이프');
});
await test('버전·문서(0.3.10)', async () => {
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, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d);
const pw = 'pw-' + randomBytes(12).toString('hex'); ops.createAccount(d, { login: 'ozops', password: pw, displayName: '오즈' }); const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } });
const r = await invoke(app, 'GET', '/ops/ai', { cookies: { aw_ops: l.cookies.aw_ops } }); ok(r.text.includes('복사'), '셸 안내에 복사 방법');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.10.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.10'));
});
done();