2026-10-29 02:49:46 +00:00
/**
* 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 ( '&y=<b>' ) && ! 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 ( '복사' ), '셸 안내에 복사 방법' );
2026-11-06 08:40:09 +00:00
eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.4.1' ); eq ( d . version , '0.4.1' );
2026-10-29 02:49:46 +00:00
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 ();