- 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>
149 lines
20 KiB
JavaScript
149 lines
20 KiB
JavaScript
/**
|
|
* test/hal-0317.test.mjs — Ariweb Suite 0.3.17(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.17.md`, Oz 2026-10-29).
|
|
*
|
|
* 계약(요약)
|
|
* 1) observability → HawkEye(`hawkeye`): 모듈 기술서·배포 순서·운영 현황 구성요소 이름 변경, 옛 이름은 별칭으로 받고 DB 행은 마이그레이션으로 바꾼다.
|
|
* 2) 역할별 통합 배포(`server.provision`): 프로필(webmaker·webhosting·arimail·backup·portal)에 따라 필요한 모듈을 한 번에 순서대로 배포한다. 포털은 하나만(이미 있으면 설치 거부, 이전 작업 `portal.relocate`).
|
|
* 서버 슬롯(slots)을 두고 배치(pickServer)가 여유 슬롯을 본다.
|
|
* 3) 화면 갱신: 주기 갱신은 바뀐 부분만 바꾼다(순수 diff + ops.js 정적 계약).
|
|
* 4) 배포 릴리스: HAL 은 배포 대상이 아니다(`hal_not_deployable`), HAL 먼저 규칙은 "HAL 이 릴리스보다 오래되지 않으면" 통과. 기본 화면은 모듈별 최신 릴리스·설치 안내·상호 연결·배치 상태, 과거 버전은 접어서.
|
|
* 5) 작업 진행 창(dock): 공통 틀 아래에 진행 중·히스토리.
|
|
* 6) 배포 점검: 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 풀고, 하나도 없으면 API 에서 바로 `no_targets`.
|
|
* 7) 대상 서버 목록·선택(라디오/체크) 렌더러.
|
|
*/
|
|
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 core from '../hal/core.mjs';
|
|
import * as ops from '../hal/ops.mjs';
|
|
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, descriptor, SERVER_PROFILES } from '../hal/modules/index.mjs';
|
|
import { MIGRATIONS } from '../hal/schema.mjs';
|
|
import * as R from '../hal/ui/render.mjs';
|
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomBytes, createHash } from 'node:crypto';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
|
function makeHal() {
|
|
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: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); } };
|
|
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
|
|
return { d, app: buildApp(d), dataDir };
|
|
}
|
|
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); return { pw, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
|
|
/** 서버 + Agent(등록·승인·heartbeat) */
|
|
async function addServer(h, { name, role, sitesHosted = 0 }) {
|
|
const s = core.createServer(h.d, { name, role }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: s.id });
|
|
const r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: name, nginx: true }, version: '0.1.1' } }); ok(r.json.ok, JSON.stringify(r.json));
|
|
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 50e9, diskTotalBytes: 100e9, sitesHosted, reservedBytes: 0 }), s.id); core.approveServer(h.d, s.id, 'op');
|
|
return { serverId: s.id, agentId: r.json.agentId };
|
|
}
|
|
function bootstrapDone(d, version) { core.claimLive(d, { instanceId: d.instanceId, version }); core.setBootstrap(d, { release_version: version, main_commit: 'abc', artifact_sha256: 'x', first_agent_id: d.store.get('SELECT id FROM agents LIMIT 1')?.id || 'ag_x', first_agent_registered_at: new Date().toISOString(), completed_at: new Date().toISOString() }); }
|
|
function addRelease(h, version, modules = ['agent', 'portal', 'webmaker', 'arimail']) {
|
|
const art = join(h.dataDir, 'artifacts', version); mkdirSync(art, { recursive: true }); const artifacts = {};
|
|
for (const m of modules) { const f = `${m}-${version}.tar.gz`; writeFileSync(join(art, f), m + version); artifacts[m] = { file: f, sha256: createHash('sha256').update(m + version).digest('hex'), size: (m + version).length }; }
|
|
core.registerRelease(h.d, { version, mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'op' });
|
|
}
|
|
/** Agent 흉내: 모든 Agent 의 작업을 받아 결과를 낸다(부모 작업이 끝날 때까지) */
|
|
async function runAgents(h, agents, result = (job) => ({ status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }), until = () => false, maxMs = 8000) {
|
|
const t0 = Date.now(); const ps = [];
|
|
while (Date.now() - t0 < maxMs) { ps.push(h.d.jobs.tick().catch(() => {})); for (const a of agents) { const j = core.nextAgentJob(h.d, a.agentId); if (j) core.agentJobResult(h.d, j.id, a.agentId, result(j)); } if (until()) break; if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) { await sleep(30); if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) break; } await sleep(15); }
|
|
await Promise.all(ps);
|
|
}
|
|
|
|
await test('1) observability → HawkEye: 기술서·배포 순서·별칭·운영 현황 구성요소·DB 마이그레이션', async () => {
|
|
ok(MODULES.hawkeye && MODULES.hawkeye.label === 'HawkEye' && MODULES.hawkeye.layout === 'agent-feature', JSON.stringify(MODULES.hawkeye)); ok(!MODULES.observability, '옛 키 없음');
|
|
eq(descriptor('observability').name, 'hawkeye', '옛 이름 별칭'); eq(DEPLOY_ORDER[DEPLOY_ORDER.length - 1], 'hawkeye'); ok(!DEPLOY_ORDER.includes('hal') && DEPLOY_ORDER[0] === 'agent', 'HAL 은 배포 순서에 없고 agent 가 먼저');
|
|
eq(DEPLOYABLE_MODULES.includes('hal'), false); ok(DEPLOYABLE_MODULES.includes('hawkeye'));
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
// 마이그레이션: observability 행 → hawkeye (새 DB 에는 이미 적용되었으므로 옛 행을 넣고 그 마이그레이션 SQL 만 다시 실행)
|
|
const mig = MIGRATIONS.find((m) => m.id === 'hal_0008_hawkeye'); ok(mig && /hawkeye/.test(mig.sql));
|
|
h.d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?)', 'observability', 'srv_old', '0.1.1', 'ok', 1, 'x', 'x'); h.d.store.run('INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?)', 'observability', '0.1.1', 'x');
|
|
h.d.store.exec(mig.sql); eq(h.d.store.all('SELECT module FROM module_targets WHERE server_id=?', 'srv_old').map((r) => r.module), ['hawkeye']); eq(h.d.store.get('SELECT name FROM modules WHERE name=?', 'hawkeye').name, 'hawkeye'); ok(!h.d.store.get('SELECT name FROM modules WHERE name=?', 'observability'));
|
|
const r = await op.as('GET', '/ops/api/overview'); ok(r.json.components.some((c) => c.name === 'hawkeye') && !r.json.components.some((c) => c.name === 'observability'), JSON.stringify(r.json.components.map((c) => c.name)));
|
|
const st = await invoke(h.app, 'GET', '/api/status'); ok(st.json.components.some((c) => /HawkEye/.test(c.name)), JSON.stringify(st.json.components));
|
|
const ov = await op.as('GET', '/ops/'); ok(ov.text.includes('HawkEye'));
|
|
});
|
|
|
|
await test('4·6) 배포 릴리스: HAL 은 배포 대상 아님, HAL 먼저 규칙은 "HAL 이 더 오래되지 않으면" 통과, 대상 서버 비우면 역할 전체로, 없으면 즉시 no_targets', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const web = await addServer(h, { name: '웹1', role: 'web' }); const mail = await addServer(h, { name: '메일1', role: 'mail' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.10'); addRelease(h, '0.3.17');
|
|
let r = await op.as('GET', '/ops/api/releases'); ok(!r.json.modules.includes('hal') && r.json.modules.includes('hawkeye'), JSON.stringify(r.json.modules)); eq(r.json.deployOrder[0], 'agent');
|
|
ok(r.json.latest && r.json.latest.portal === '0.3.17' && r.json.latest.agent === '0.3.17', JSON.stringify(r.json.latest)); ok(r.json.guides && r.json.guides.webmaker && /agent/.test(r.json.guides.webmaker.requires.join(',')) && r.json.guides.portal.singleton === true, JSON.stringify(r.json.guides && r.json.guides.webmaker));
|
|
ok(r.json.profiles && r.json.profiles.some((p) => p.id === 'webhosting'), '프로필');
|
|
r = await op.as('POST', '/ops/api/modules/hal/deploy', { version: '0.3.17', serverIds: [web.serverId] }); eq(r.status, 400); eq(r.json.error, 'hal_not_deployable');
|
|
// 대상 비움 → 역할에 맞는 서버 전체(portal: control·web → 웹1), agent: 전체
|
|
r = await op.as('POST', '/ops/api/modules/portal/deploy', { version: '0.3.17', serverIds: [] }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.job.subject ? r.json.job.subject.serverIds : r.json.targets, [web.serverId]); ok(r.json.targets && r.json.targets.length === 1);
|
|
r = await op.as('POST', '/ops/api/modules/agent/deploy', { version: '0.3.17', serverIds: [], nonce: '2' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.targets.sort(), [web.serverId, mail.serverId].sort());
|
|
h.d.store.run('UPDATE servers SET status=? WHERE id=?', 'maintenance', mail.serverId); // mail 서버가 준비 상태가 아니면 arimail 의 기본 대상이 없다
|
|
r = await op.as('POST', '/ops/api/modules/arimail/deploy', { version: '0.3.17', serverIds: [] }); eq(r.status, 400); eq(r.json.error, 'no_targets'); ok(/arimail/.test(r.json.message)); h.d.store.run('UPDATE servers SET status=? WHERE id=?', 'ready', mail.serverId);
|
|
// HAL 먼저: HAL 0.3.17 실행 중에 0.3.10 릴리스(더 오래됨) 배포는 precheck 통과(그 뒤 Agent 결과로 진행)
|
|
const j = h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module: 'portal', version: '0.3.10', serverIds: [web.serverId] }, createdBy: 'op', actorType: 'operator' }).job;
|
|
await runAgents(h, [web]); const jd = h.d.jobs.get(j.id); ok(jd.error_code !== 'hal_first', `hal_first 아님: ${jd.error_code} ${jd.error}`); eq(jd.status, 'done', JSON.stringify(jd.error));
|
|
// HAL 이 더 오래됐으면 hal_first
|
|
core.setBootstrap(h.d, { hal_running_version: '0.3.9' }); const j2 = h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module: 'portal', version: '0.3.10', serverIds: [web.serverId] }, createdBy: 'op', actorType: 'operator', idempotencyKey: 'x2' }).job; await h.d.jobs.drain(); eq(h.d.jobs.get(j2.id).error_code, 'hal_first');
|
|
// 화면: 기본은 모듈별 최신 릴리스·안내·상호 연결, 과거 버전은 접힘, HAL 배포 단추 없음
|
|
r = await op.as('GET', '/ops/releases'); eq(r.status, 200); for (const s of ['최신 릴리스', '과거 버전 보기', 'HawkEye', '상호 연결', 'HAL 은 배포 대상이 아닙니다']) ok(r.text.includes(s), '릴리스 화면 ' + s); ok(!r.text.includes('data-m="hal"'), 'HAL 배포 단추 없음'); ok(r.text.includes('data-act="module-deploy"') && r.text.includes('data-m="portal"'));
|
|
});
|
|
|
|
await test('2) 역할별 통합 배포: 프로필대로 모듈을 순서대로 배포하고 서버에 프로필·역할·슬롯을 기록, 포털은 하나만(이미 있으면 거부) + 포털 이전 작업, 슬롯 기반 배치', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const s1 = await addServer(h, { name: '새서버', role: 'web' }); const s2 = await addServer(h, { name: '컨트롤', role: 'control' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.17', ['agent', 'portal', 'webmaker', 'arimail']);
|
|
ok(SERVER_PROFILES.webmaker && SERVER_PROFILES.webmaker.modules.includes('webmaker') && SERVER_PROFILES.webmaker.modules.includes('hawkeye') && SERVER_PROFILES.webmaker.modules[0] === 'agent', JSON.stringify(SERVER_PROFILES.webmaker));
|
|
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,hal,portal,webhosting,webmaker'); // [0.4.1] hal 프로필(HAL 서버: agent·hawkeye)
|
|
// webmaker 프로필을 새서버에: agent → webmaker → hawkeye 순서로 자식 작업, 서버 profile·role·slots 기록
|
|
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'webmaker', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); const pj = r.json.job;
|
|
const seen = []; await runAgents(h, [s1, s2], (job) => { seen.push(job.payload.module); return { status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }; });
|
|
const done1 = h.d.jobs.get(pj.id); eq(done1.status, 'done', JSON.stringify({ e: done1.error, c: done1.error_code, steps: done1.steps })); eq(seen, ['agent', 'webmaker', 'hawkeye'], '순서');
|
|
const srv = core.getServer(h.d, s1.serverId); eq(srv.profile, 'webmaker'); eq(srv.role, 'web'); ok(srv.slots && srv.slots.webmaker === 1, JSON.stringify(srv.slots));
|
|
eq(h.d.store.all('SELECT module, actual_version FROM module_targets WHERE server_id=? ORDER BY module', s1.serverId).map((t) => t.module + ':' + t.actual_version), ['agent:0.3.17', 'hawkeye:0.3.17', 'webmaker:0.3.17']);
|
|
ok(h.d.jobs.list({ parentId: pj.id, limit: 10 }).length === 3, '자식 작업 3개');
|
|
// 포털: 컨트롤 서버에 설치 → 다른 서버에 또 설치는 거부(portal_exists)
|
|
r = await op.as('POST', '/ops/api/provision', { serverId: s2.serverId, profile: 'portal', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); await runAgents(h, [s1, s2]); eq(h.d.jobs.get(r.json.job.id).status, 'done');
|
|
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'portal', version: '0.3.17' }); eq(r.status, 409); eq(r.json.error, 'portal_exists'); ok(/이전/.test(r.json.message));
|
|
// 포털 이전: 새 서버에 설치 → 운영자 확인(데이터 이전·DNS) 대기 → 재개 → 옛 서버 비활성
|
|
stale: r = await op.as('POST', '/ops/api/portal/relocate', { fromServerId: s2.serverId, toServerId: s1.serverId, version: '0.3.17', confirmImpact: true, confirmIdentifiers: [s2.serverId, s1.serverId], confirmExecute: true }); eq(r.status, 200, JSON.stringify(r.json)); const rj = r.json.job;
|
|
await runAgents(h, [s1, s2], undefined, () => h.d.jobs.get(rj.id).status === 'needs_user'); for (let i = 0; i < 100 && h.d.jobs.get(rj.id).status !== 'needs_user'; i++) await sleep(20);
|
|
eq(h.d.jobs.get(rj.id).status, 'needs_user', JSON.stringify(h.d.jobs.get(rj.id).error)); ok(/DNS|데이터/.test(h.d.jobs.get(rj.id).user_message));
|
|
eq(h.d.store.get('SELECT actual_version FROM module_targets WHERE module=? AND server_id=?', 'portal', s1.serverId).actual_version, '0.3.17', '새 서버에 설치됨');
|
|
r = await op.as('POST', `/ops/api/jobs/${rj.id}/resume`); eq(r.status, 200); await runAgents(h, [s1, s2]); eq(h.d.jobs.get(rj.id).status, 'done', JSON.stringify(h.d.jobs.get(rj.id).error));
|
|
eq(h.d.store.get('SELECT active FROM module_targets WHERE module=? AND server_id=?', 'portal', s2.serverId).active, 0, '옛 포털 비활성'); eq(h.d.store.get('SELECT active FROM module_targets WHERE module=? AND server_id=?', 'portal', s1.serverId).active, 1);
|
|
// 슬롯 기반 배치: web 서버 두 대 중 슬롯이 찬 서버는 제외
|
|
const w2 = await addServer(h, { name: '웹2', role: 'web', sitesHosted: 1 }); await op.as('PATCH', `/ops/api/servers/${w2.serverId}`, { slots: { sites: 1 } }); const w3 = await addServer(h, { name: '웹3', role: 'web' }); await op.as('PATCH', `/ops/api/servers/${w3.serverId}`, { slots: { sites: 10 } });
|
|
await op.as('PATCH', `/ops/api/servers/${s1.serverId}`, { accepts_new: 0 });
|
|
const pick = core.pickServer(h.d, { role: 'web' }); eq(pick.serverId, w3.serverId, '여유 슬롯 있는 서버'); ok(core.publicServer(h.d, core.getServer(h.d, w2.serverId)).slotsFree.sites === 0);
|
|
// 모르는 프로필·준비 안 된 서버
|
|
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'nope', version: '0.3.17' }); eq(r.status, 400);
|
|
const pend = core.createServer(h.d, { name: '대기', role: 'web' }); r = await op.as('POST', '/ops/api/provision', { serverId: pend.id, profile: 'webhosting', version: '0.3.17' }); eq(r.status, 409); eq(r.json.error, 'server_not_ready');
|
|
});
|
|
|
|
await test('3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약, 작업 진행 창(dock), 서버 선택 목록 렌더러', async () => {
|
|
const P = await import('../hal/ui/public/dom-patch.mjs');
|
|
eq(P.diffChildren(['<p>a</p>', '<p>b</p>'], ['<p>a</p>', '<p>b</p>']), [], '같으면 변경 없음');
|
|
eq(P.diffChildren(['<p>a</p>', '<p>b</p>'], ['<p>a</p>', '<p>B</p>', '<p>c</p>']), [{ i: 1, op: 'replace', html: '<p>B</p>' }, { i: 2, op: 'append', html: '<p>c</p>' }]);
|
|
eq(P.diffChildren(['<p>a</p>', '<p>b</p>', '<p>c</p>'], ['<p>a</p>']), [{ i: 1, op: 'remove' }, { i: 2, op: 'remove' }].reverse(), '뒤에서부터 제거');
|
|
eq(P.splitTopLevel('<div>x<p>y</p></div><p>z</p>text'), ['<div>x<p>y</p></div>', '<p>z</p>', 'text']);
|
|
const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); ok(js.includes("from './dom-patch.mjs'") && js.includes('patchHtml('), 'ops.js 가 부분 갱신을 쓴다'); ok(!/overview: async \(\) => \{ try \{ const o = await api\('GET', '\/ops\/api\/overview'\); body\(\)\.innerHTML/.test(js), '운영 현황 주기 갱신이 통째로 innerHTML 을 바꾸지 않는다');
|
|
const ai = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ai.js'), 'utf8'); ok(ai.includes('patchHtml(') || ai.includes('patchMessages('), 'AI 패널도 부분 갱신');
|
|
// dock
|
|
const dock = R.jobsDockHtml({ active: [{ id: 'j_1', kind: 'module.deploy', label: 'portal 0.3.17 배포', status: 'running', progress: 40, currentStep: { title: '대상별 결과 확인' }, updatedAt: new Date().toISOString() }], history: [{ id: 'j_0', kind: 'module.health', label: 'x <b>', status: 'done', updatedAt: new Date().toISOString() }] });
|
|
ok(dock.includes('j_1') && dock.includes('40%') && dock.includes('대상별 결과 확인') && dock.includes('x <b>') && !dock.includes('x <b>'), dock.slice(0, 300));
|
|
ok(R.jobsDockHtml({ active: [], history: [] }).includes('진행 중인 작업이 없습니다'));
|
|
// 서버 선택 목록(라디오/체크): 역할·상태·버전 표시, 준비 안 된 서버는 비활성
|
|
const servers = [{ id: 'srv_a', name: '웹1', role: 'web', status: 'ready', agentVersion: '0.3.10', profile: 'webhosting', modules: { agent: { actual: '0.3.10' } } }, { id: 'srv_b', name: '대기<', role: 'mail', status: 'pending', agentVersion: null, modules: {} }];
|
|
const radio = R.serverChoiceHtml(servers, { name: 'serverId' }); ok(radio.includes('type="radio"') && radio.includes('value="srv_a"') && radio.includes('대기<') && /disabled/.test(radio) && radio.includes('webhosting'), radio.slice(0, 400));
|
|
const checks = R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: ['srv_a'] }); ok(checks.includes('type="checkbox"') && /value="srv_a"[^>]*checked/.test(checks) && !/value="srv_b"[^>]*checked/.test(checks));
|
|
ok(R.serverChoiceHtml([], { name: 'serverId' }).includes('서버가 없습니다'));
|
|
// 공통 틀: dock 과 스크립트
|
|
const h = makeHal(); const op = await loginOp(h); const r = await op.as('GET', '/ops/servers'); for (const s of ['id="ops-dock"', '진행 중', '히스토리', 'data-act="provision"']) ok(r.text.includes(s), '틀 ' + s);
|
|
const sv = await op.as('GET', '/ops/api/servers'); ok(Array.isArray(sv.json.servers));
|
|
});
|
|
|
|
await test('버전·문서(0.3.17)', async () => {
|
|
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
|
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.17.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.17'));
|
|
ok(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').includes('HawkEye') && !/observability\b(?! 는 옛 이름)/.test(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').replace('옛 이름 observability', '')), '스킬 문서도 HawkEye');
|
|
});
|
|
done();
|