Test Specs IV7 - MZC-CSC/mc-web-console GitHub Wiki

Test Specs β€” IV7 Monitoring (μ—μ΄μ „νŠΈ μ„€μΉ˜)

생성: 2026-10-16 Β· κΈ°μ€€: v07 μ„€μΉ˜ ν›„ 톡합 검증(IV) Β· 원본: 여정별 κ³„νšμ„œ(Excel) ← Test Scenarios Β· IV ν—ˆλΈŒ Β· κΈ°λŠ₯ TC

Playwright(TypeScript) spec μ†ŒμŠ€μž…λ‹ˆλ‹€. μ‹œλ‚˜λ¦¬μ˜€λ§ˆλ‹€ ## <μ‹œλ‚˜λ¦¬μ˜€λ²ˆν˜Έ> μ„Ήμ…˜μ΄ 있고 μ•΅μ»€λŠ” #<μ‹œλ‚˜λ¦¬μ˜€λ²ˆν˜Έ μ†Œλ¬Έμž> μž…λ‹ˆλ‹€. 곡용 ν—¬νΌλŠ” Test-Specs-Common. λΉ„λ°€λ²ˆν˜Έ 등은 λ§ˆμŠ€ν‚Ήν–ˆμŠ΅λ‹ˆλ‹€.

μ‹œλ‚˜λ¦¬μ˜€ 파일 쀄 ν•΄μ‹œ
IV7_S01 tests/iv7/IV7_S01.spec.ts 47 21f3a7c6
IV7_S02 tests/iv7/IV7_S02.spec.ts 26 fba5559c
IV7_S03 tests/iv7/IV7_S03.spec.ts 40 3d70ed74
IV7_S04 tests/iv7/IV7_S04.spec.ts 53 4c18be18
IV7_S05 tests/iv7/IV7_S05.spec.ts 33 858f38fc
IV7_S06 tests/iv7/IV7_S06.spec.ts 57 ec132281
IV7_S07 tests/iv7/IV7_S07.spec.ts 65 b044fbb8
IV7_S08 tests/iv7/IV7_S08.spec.ts 30 a6b7ce95
IV7_S09 tests/iv7/IV7_S09.spec.ts 28 e4e51c4a
IV7_S10 tests/iv7/IV7_S10.spec.ts 46 98f7c330
IV7_S11 tests/iv7/IV7_S11.spec.ts 56 a4126fd6
IV7_S12 tests/iv7/IV7_S12.spec.ts 67 aa108960
IV7_S13 tests/iv7/IV7_S13.spec.ts 31 f0125ce5
IV7_S14 tests/iv7/IV7_S14.spec.ts 30 c1c4f52b
IV7_S15 tests/iv7/IV7_S15.spec.ts 34 db2d0c54
IV7_S16 tests/iv7/IV7_S16.spec.ts 30 e1851c70
IV7_S17 tests/iv7/IV7_S17.spec.ts 30 2f0355ad
IV7_S18 tests/iv7/IV7_S18.spec.ts 29 10c50ab9
IV7_S19 tests/iv7/IV7_S19.spec.ts 37 8df7af22
IV7_S20 tests/iv7/IV7_S20.spec.ts 33 9e224647
IV7_S21 tests/iv7/IV7_S21.spec.ts 44 a51ea5a9
IV7_S22 tests/iv7/IV7_S22.spec.ts 33 40bcbd88
IV7_S23 tests/iv7/IV7_S23.spec.ts 91 6c91f225
IV7_S24 tests/iv7/IV7_S24.spec.ts 38 eb5eb930
IV7_S25 tests/iv7/IV7_S25.spec.ts 63 16450ba8
IV7_S26 tests/iv7/IV7_S26.spec.ts 28 0a66fffe
IV7_S27 tests/iv7/IV7_S27.spec.ts 50 255e0125
IV7_S28 tests/iv7/IV7_S28.spec.ts 44 ee5cfc00
IV7_S29 tests/iv7/IV7_S29.spec.ts 35 1067e8f8
IV7_S30 tests/iv7/IV7_S30.spec.ts 32 211db6a2
IV7_S31 tests/iv7/IV7_S31.spec.ts 25 146ac1b8
IV7_S32 tests/iv7/IV7_S32.spec.ts 23 6e53aa33
IV7_S33 tests/iv7/IV7_S33.spec.ts 28 d211f549
IV7_S34 tests/iv7/IV7_S34.spec.ts 36 2b6c55dc

IV7_S01

tests/iv7/IV7_S01.spec.ts Β· 47쀄 Β· ν•΄μ‹œ 21f3a7c6 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S01: Monitoring: Infra Monitoring Agent μ„€μΉ˜ [정상, CSP별]
// λŒ€μƒ: Infra node (K8s μ•„λ‹˜)
// ν™”λ©΄: Monitorings > Config(Monitor Setting, /webconsole/_view/mc011yconfig, iframe) β†’ λŒ€μƒ node 의 Monitoring Agent μ—΄ Install λ²„νŠΌ
//   (Monitoring ν™”λ©΄μ—μ„œλŠ” node μ˜† 'Go to Config to install agent' λ²„νŠΌμœΌλ‘œ 같은 ν™”λ©΄μœΌλ‘œ 이동. 2026-10-07 ν”„λ‘œλΈŒ 확인)
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인 μƒνƒœ, Workspace=ws01 / Project=default μ„ νƒλœ μƒνƒœμ—μ„œ μ‹œμž‘. IV3 Infra Running
// 절차 (예: AWS κΈ°μ€€): 1.Monitorings > Config μ§„μž… 2.Workload mc1 의 첫 node(g1-1) Monitoring Agent κ°€ Not installed 인지 확인
//   3.Monitoring Agent 'Install' 클릭 β†’ 확인창 확인(Log Agent λŠ” λˆ„λ₯΄μ§€ μ•ŠμŒ) 4.μš”μ²­ μ ‘μˆ˜ 및 Installing β†’ Installed(SUCCESS) 확인
// κΈ°λŒ€κ²°κ³Ό: μ„€μΉ˜ μš”μ²­ μ ‘μˆ˜, Monitoring Agent κ°€ μ„€μΉ˜λœλ‹€
// 2026-10-12: ν™”λ©΄μ˜ '첫 Install λ²„νŠΌ' 이 μ•„λ‹ˆλΌ λŒ€μƒ node({ng}-1, MON_NODE 둜 λ³€κ²½ κ°€λŠ₯)의 Monitoring Agent 칸을 μ§€μ •ν•΄ λˆ„λ₯Έλ‹€ β€” λ‹€λ₯Έ Infra/CSP 의 node λ₯Ό 잘λͺ» λˆ„λ₯΄μ§€ μ•Šλ„λ‘(KT mc9 μΆ”κ°€).
//   이미 μ„€μΉ˜λΌ 있으면(preexisting) μ„€μΉ˜ λ‹¨κ³„λŠ” κ±΄λ„ˆλ›°κ³  μƒνƒœλ§Œ κΈ°λ‘ν•œλ‹€.
// μ„ ν–‰ Infra κ°€ Config 화면에 μ—†μœΌλ©΄ BLOCKED 둜 skip ν•œλ‹€.

import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, DEFAULT_PROJECT } from '../../lib/data';
import { openConfig, waitNodeRunning, agentAction, cellText, classify, settleAgent } from './monitoring';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // 엑셀에 μžˆλŠ” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV7_S01", c)} ` + "[Infra] Monitoring: Infra μ—μ΄μ „νŠΈ μ„€μΉ˜", async ({ page, dialogs }) => {
    test.setTimeout(30 * 60 * 1000);
    const infra = infraName(c);
    const node = process.env.MON_NODE || `${infraNodegroups(c)[0]}-1`;
    test.info().annotations.push({ type: 'target', description: `Infra node (K8s μ•„λ‹˜): infra=${infra} node=${node}` });
    const first = await openConfig(page, DEFAULT_PROJECT);
    test.skip(!first.text.includes(infra), `BLOCKED: μ„ ν–‰ Infra ${infra} κ°€ Monitor Setting 화면에 μ—†μŒ (IV3 Infra Running ν•„μš”)`);
    const fr = await waitNodeRunning(page, DEFAULT_PROJECT, node);
    const monBefore = await settleAgent(page, fr, node, 'Monitoring');
    const logBefore = await cellText(fr, node, 'Log');
    test.info().annotations.push({ type: 'before', description: `Monitoring: ${monBefore} / Log: ${logBefore}` });
    if (classify(monBefore) === 'installed' || classify(monBefore) === 'inactive') {
      test.info().annotations.push({ type: 'preexisting', description: `Monitoring Agent 이미 μ„€μΉ˜λ¨(${monBefore}) β†’ μ„€μΉ˜ 단계 κ±΄λ„ˆλœ€` });
      return;
    }
    expect(classify(monBefore), `μ‹œμž‘: Monitoring Agent Not installed(ν˜„μž¬ ${monBefore})`).toMatch(/not-installed|failed/);
    if (classify(monBefore) === 'failed') { // 이전 μ‹œλ„ 흔적(FAILED)은 Uninstall 둜 μ •λ¦¬ν•œ λ’€ λ‹€μ‹œ μ„€μΉ˜
      const u = await agentAction(page, fr, dialogs, node, 'Monitoring', 'Uninstall');
      test.info().annotations.push({ type: 'precondition', description: `Monitoring Agent(failed) Uninstall β†’ ${u.state}` });
      expect(u.done, `Monitoring Agent Uninstall ν›„ Not installed β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${u.state}`).toBe(true);
    }
    const r = await agentAction(page, fr, dialogs, node, 'Monitoring', 'Install');
    test.info().annotations.push({ type: 'monitoring-result', description: `Monitoring Agent μ„€μΉ˜ κ²°κ³Ό: ${r.cls} (${r.seen.join(' β†’ ')})` });
    const logAfter = await cellText(fr, node, 'Log');
    test.info().annotations.push({ type: 'after', description: `Monitoring: ${r.state} / Log: ${logAfter}` });
    expect.soft(logAfter, 'Log Agent μƒνƒœ λ³€ν™” μ—†μŒ').toBe(logBefore);
    expect(r.done, `Monitoring Agent μ„€μΉ˜ μ™„λ£Œ(Installed) β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${r.state}`).toBe(true);
  });
}

IV7_S02

tests/iv7/IV7_S02.spec.ts Β· 26쀄 Β· ν•΄μ‹œ fba5559c Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S02: Monitoring: K8s λͺ¨λ‹ˆν„°λ§ μ„€μΉ˜ [정상, CSP별]
// λŒ€μƒ: K8s cluster / K8s node (Infra μ•„λ‹˜)
// ν™”λ©΄: Monitorings > Config(Monitor Setting, iframe, Namespace=Project) β†’ K8s {cluster} 의 node β†’ Monitoring Agent 'Install'
// μ‹œμž‘ μƒνƒœ: admin 둜그인 μƒνƒœ, Workspace=ws01 / Project=p1 선택 μƒνƒœμ—μ„œ μ‹œμž‘. IV3 K8s Ready
// 절차 (예: AWS κΈ°μ€€): 1.Monitoring(K8s νƒ­)μ—μ„œ k1d 의 node κ°€ 'agent not installed' 인지 확인 2.Config ν™”λ©΄ K8s k1d 의 node Monitoring Agent 'Install' 클릭
//   3.μ„€μΉ˜ μš”μ²­ μ ‘μˆ˜(μ•Œλ¦Ό λ˜λŠ” μƒνƒœ λ³€ν™”) 확인
// κΈ°λŒ€κ²°κ³Ό: μ„€μΉ˜ μš”μ²­ μ ‘μˆ˜
// μ„ ν–‰ K8s(k{n}) κ°€ Config 화면에 μ—†μœΌλ©΄ BLOCKED 둜 skip ν•œλ‹€.
// λŒ€μƒ K8s: κΈ°λ³Έ k{n}(IV3_S13 Workflow 의 {prefix}-{csp} 이름은 k1-aws 라 λ‹€λ₯Ό 수 있음) β€” K8S_CLUSTER 둜 λ³€κ²½(v07 AWS: k1d)

import { test } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { openConfig, installFirstAgent } from './monitoring';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) { // 엑셀에 μžˆλŠ” CSP: AWS, Azure, Alibaba, GCP, Tencent, NHN Cloud, NCP, IBM, KT
  test(`${tid("IV7_S02", c)} ` + "[K8s] Monitoring: K8s λͺ¨λ‹ˆν„°λ§ μ„€μΉ˜", async ({ page, dialogs }) => {
    test.setTimeout(10 * 60 * 1000);
    const cluster = process.env.K8S_CLUSTER || k8sName(c);
    test.info().annotations.push({ type: 'target', description: `K8s cluster/node (Infra μ•„λ‹˜): cluster=${cluster}, project=${k8sProject(c)}` });
    const { fr, text } = await openConfig(page, k8sProject(c));
    test.skip(!text.includes(cluster), `BLOCKED: μ„ ν–‰ K8s ${cluster} κ°€ Monitor Setting 화면에 μ—†μŒ (IV3 K8s Ready, Project ${k8sProject(c)} ν•„μš”)`);
    // ν΄λŸ¬μŠ€ν„°κ°€ μ—¬λŸ¬ 개면(k1-aws λŠ” node 0) λŒ€μƒ ν΄λŸ¬μŠ€ν„° κ΅¬μ—­μ˜ Install 을 λˆŒλŸ¬μ•Ό ν•œλ‹€ β€” 'No nodes' 인 ν΄λŸ¬μŠ€ν„°λ₯Ό κ±΄λ„ˆλ›°κ³  첫 Install 을 μ‚¬μš©
    await installFirstAgent(page, fr, dialogs);
  });
}

IV7_S03

tests/iv7/IV7_S03.spec.ts Β· 40쀄 Β· ν•΄μ‹œ 3d70ed74 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S03: [Infra] Monitoring: Log Agent μ„€μΉ˜ [정상, CSP별]
// λŒ€μƒ: Infra node (K8s μ•„λ‹˜)
// ν™”λ©΄: Monitorings > Config(Monitor Setting, /webconsole/_view/mc011yconfig, iframe) β†’ λŒ€μƒ node 의 Log Agent μ—΄ Install λ²„νŠΌ
// μ „μ œ: admin(mcmp) 둜그인 μƒνƒœ, Workspace=ws01 / Project=default. IV3 Infra mc{n} Running
// 절차(AWS): 1.Monitorings > Config μ§„μž… 2.Workload mc1 의 node g1-1 Log Agent κ°€ Not installed 인지 확인(FAILED λ“± 이전 μ‹œλ„ 흔적이 있으면 Uninstall 둜 정리)
//   3.Log Agent 'Install' 클릭(Monitoring Agent λŠ” λˆ„λ₯΄μ§€ μ•ŠμŒ) β†’ 확인창 확인 4.μš”μ²­ μ ‘μˆ˜ 및 Installing β†’ Installed(SUCCESS) 확인 5.Monitoring Agent μƒνƒœλŠ” μ‹œμž‘ λ•Œμ™€ 같은지 확인
// κΈ°λŒ€κ²°κ³Ό: Log Agent μ„€μΉ˜ μš”μ²­μ΄ μ ‘μˆ˜λ˜κ³  Log Agent 만 μ„€μΉ˜λœλ‹€(Monitoring Agent μƒνƒœ λ³€ν™” μ—†μŒ)
// μƒνƒœ 문ꡬ: Not installed / INSTALLING Installing… / SUCCESS(λ˜λŠ” Installed) / FAILED / SERVICE_INACTIVE (+ Uninstall λ²„νŠΌ). FAILED λŠ” μ„€μΉ˜ μ‹€νŒ¨μ΄λ©° Uninstall 둜 정리 κ°€λŠ₯
// μ„ ν–‰ Infra κ°€ Config 화면에 μ—†μœΌλ©΄ BLOCKED 둜 skip. λŒ€μƒ node λŠ” 첫 node({ng}-1), MON_NODE ν™˜κ²½λ³€μˆ˜λ‘œ λ³€κ²½ κ°€λŠ₯(NODE λŠ” node μ‹€ν–‰ 경둜 ν™˜κ²½λ³€μˆ˜λΌ μ“°μ§€ μ•ŠλŠ”λ‹€)
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, DEFAULT_PROJECT } from '../../lib/data';
import { openConfig, waitNodeRunning, agentAction, cellText, classify, settleAgent } from './monitoring';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV7_S03", c)} ` + "[Infra] Monitoring: Log Agent μ„€μΉ˜", async ({ page, dialogs }) => {
    test.setTimeout(30 * 60 * 1000);
    const infra = infraName(c);
    const node = process.env.MON_NODE ||`${infraNodegroups(c)[0]}-1`;
    test.info().annotations.push({ type: 'target', description: `Infra node (K8s μ•„λ‹˜): infra=${infra} node=${node}` });
    const first = await openConfig(page, DEFAULT_PROJECT);
    test.skip(!first.text.includes(infra), `BLOCKED: μ„ ν–‰ Infra ${infra} κ°€ Monitor Setting 화면에 μ—†μŒ (IV3 Infra Running ν•„μš”)`);
    const fr = await waitNodeRunning(page, DEFAULT_PROJECT, node);
    const monBefore = await cellText(fr, node, 'Monitoring');
    let logBefore = await settleAgent(page, fr, node, 'Log');
    test.info().annotations.push({ type: 'before', description: `Monitoring: ${monBefore} / Log: ${logBefore}` });
    // μ „μ œ μ€€λΉ„: μ„€μΉ˜λΌ μžˆκ±°λ‚˜ μ‹€νŒ¨ 흔적이 있으면 Uninstall 둜 Not installed 둜 λ§žμΆ˜λ‹€
    if (!/not-installed/.test(classify(logBefore))) {
      const u = await agentAction(page, fr, dialogs, node, 'Log', 'Uninstall');
      test.info().annotations.push({ type: 'precondition', description: `Log Agent(${classify(logBefore)}) Uninstall β†’ ${u.state}` });
      expect(u.done, `Log Agent Uninstall ν›„ Not installed β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${u.state}`).toBe(true);
    }
    const r = await agentAction(page, fr, dialogs, node, 'Log', 'Install');
    test.info().annotations.push({ type: 'log-result', description: `Log Agent μ„€μΉ˜ κ²°κ³Ό: ${r.cls} (${r.seen.join(' β†’ ')})` });
    const monAfter = await cellText(fr, node, 'Monitoring');
    test.info().annotations.push({ type: 'after', description: `Monitoring: ${monAfter} / Log: ${r.state}` });
    expect.soft(monAfter, 'Monitoring Agent μƒνƒœ λ³€ν™” μ—†μŒ').toBe(monBefore);
    expect(r.done, `Log Agent μ„€μΉ˜ μ™„λ£Œ(Installed) β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${r.state}`).toBe(true);
  });
}

IV7_S04

tests/iv7/IV7_S04.spec.ts Β· 53쀄 Β· ν•΄μ‹œ 4c18be18 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S04: [K8s] Monitoring: Log Agent μ„€μΉ˜ [정상, CSP별]
// λŒ€μƒ: K8s cluster / K8s node (Infra μ•„λ‹˜)
// ν™”λ©΄: Monitorings > Config(Monitor Setting, iframe, Namespace=Project) β†’ K8s {cluster} 의 node β†’ Log Agent μ—΄ Install λ²„νŠΌ
// μ „μ œ: admin 둜그인 μƒνƒœ, Workspace=ws01 / Project=p{n}. IV3 K8s Active. AWS: Project p1 / k1d / NodeGroup g1s
// 절차(AWS): 1.K8s Workloads μ—μ„œ k1d NodeGroup g1s 의 node 확인 2.Monitorings > Config(p1) 의 K8s k1d μ—μ„œ κ·Έ node ν–‰μ˜ Log Agent 확인
//   3.Log Agent κ°€ Not installed κ°€ μ•„λ‹ˆλ©΄(이전 μ‹œλ„/μ„€μΉ˜) Uninstall 둜 정리(μ „μ œ μ€€λΉ„) 4.Log Agent 'Install' 클릭(Monitoring Agent λŠ” λˆ„λ₯΄μ§€ μ•ŠμŒ) β†’ 확인
//   5.Log Agent Installing β†’ Installed 확인 6.Monitoring Agent μƒνƒœλŠ” μ‹œμž‘ λ•Œμ™€ 같은지 확인
// κΈ°λŒ€κ²°κ³Ό: Log Agent μ„€μΉ˜ μš”μ²­μ΄ μ ‘μˆ˜λ˜κ³  Log Agent 만 μ„€μΉ˜λœλ‹€(Monitoring Agent μƒνƒœ λ³€ν™” μ—†μŒ)
// λŒ€μƒ ν΄λŸ¬μŠ€ν„°/NodeGroup: K8S_CLUSTER(κΈ°λ³Έ k{n}), K8S_NG(κΈ°λ³Έ g{n}s) β€” v07 AWS: k1d / g1s
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s, selectNodeGroup } from '../../lib/iv3-k8s';
import { openConfig, waitNodeRunning, agentAction, cellText, classify, settleAgent } from './monitoring';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV7_S04", c)} ` + "[K8s] Monitoring: Log Agent μ„€μΉ˜", async ({ page, dialogs }) => {
    test.setTimeout(40 * 60 * 1000);
    const prj = k8sProject(c);
    const cluster = process.env.K8S_CLUSTER || k8sName(c);
    const ngName = process.env.K8S_NG || `g${c.n}s`;
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    const st = await selectNodeGroup(page, cluster, ngName);
    const node = st.nodes[0];
    test.info().annotations.push({ type: 'target', description: `K8s cluster/node (Infra μ•„λ‹˜): ${prj} / ${cluster} / NodeGroup ${ngName} / node ${node ?? '(μ—†μŒ)'}` });
    test.skip(!node, `BLOCKED: NodeGroup ${ngName} 에 node μ—†μŒ`);
    // K8s λͺ©λ‘μ€ ν™”λ©΄ 제λͺ©λ³΄λ‹€ 늦게 μ±„μ›Œμ§„λ‹€ β†’ μ΅œλŒ€ 3회 λ‹€μ‹œ μ—΄μ–΄ 확인
    let seen = false;
    for (let i = 0; i < 3 && !seen; i++) {
      const o = await openConfig(page, prj);
      seen = o.text.includes(cluster) && o.text.includes(node);
      if (!seen) await page.waitForTimeout(15_000);
    }
    test.skip(!seen, `BLOCKED: ${cluster} / ${node} κ°€ Monitor Setting 화면에 μ—†μŒ`);
    const fr = await waitNodeRunning(page, prj, node);
    const monBefore = await settleAgent(page, fr, node, 'Monitoring');
    const logBefore = await settleAgent(page, fr, node, 'Log');
    test.info().annotations.push({ type: 'before', description: `Monitoring: ${monBefore} / Log: ${logBefore}` });
    if (classify(logBefore) !== 'not-installed') {
      const u = await agentAction(page, fr, dialogs, node, 'Log', 'Uninstall');
      test.info().annotations.push({ type: 'precondition', description: `Log Agent(${classify(logBefore)}) Uninstall β†’ ${u.state}` });
      expect(u.done, `Log Agent Uninstall ν›„ Not installed β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${u.state}`).toBe(true);
    }
    const r = await agentAction(page, fr, dialogs, node, 'Log', 'Install');
    test.info().annotations.push({ type: 'log-result', description: `Log Agent μ„€μΉ˜ κ²°κ³Ό: ${r.cls} (${r.seen.join(' β†’ ')})` });
    const monAfter = await cellText(fr, node, 'Monitoring');
    test.info().annotations.push({ type: 'after', description: `Monitoring: ${monAfter} / Log: ${r.state}` });
    expect.soft(monAfter, 'Monitoring Agent μƒνƒœ λ³€ν™” μ—†μŒ').toBe(monBefore);
    expect(r.done, `Log Agent μ„€μΉ˜ μ™„λ£Œ(Installed) β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${r.state}`).toBe(true);
  });
}

IV7_S05

tests/iv7/IV7_S05.spec.ts Β· 33쀄 Β· ν•΄μ‹œ 858f38fc Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S05: [Infra] Monitoring: Monitoring Agent + Log Agent λ‘˜ λ‹€ μ„€μΉ˜ [정상, CSP별]
// λŒ€μƒ: Infra node (K8s μ•„λ‹˜)
// ν™”λ©΄: Monitorings > Config(Monitor Setting, iframe) β†’ λŒ€μƒ node 의 Monitoring Agent μ—΄ Install, Log Agent μ—΄ Install
// μ „μ œ: admin(mcmp) 둜그인 μƒνƒœ, Workspace=ws01 / Project=default. IV3 Infra mc{n} Running, 두 μ—μ΄μ „νŠΈκ°€ λͺ¨λ‘ Not installed 인 node ν•„μš”(AWS: g1-2)
// 절차(AWS): 1.Monitorings > Config μ§„μž… 2.Workload mc1 의 두 μ—μ΄μ „νŠΈκ°€ λͺ¨λ‘ Not installed 인 node(g1-2) 확인 3.Monitoring Agent 'Install' β†’ 확인 4.Log Agent 'Install' β†’ 확인
//   5.두 μ—μ΄μ „νŠΈ λͺ¨λ‘ Installing β†’ Installed(Uninstall λ²„νŠΌ) 확인(μ•½ 3λΆ„)
// κΈ°λŒ€κ²°κ³Ό: 두 μ—μ΄μ „νŠΈ μ„€μΉ˜ μš”μ²­μ΄ 각각 μ ‘μˆ˜λ˜κ³  λͺ¨λ‘ Installed κ°€ λœλ‹€
// λŒ€μƒ node λŠ” MON_NODE ν™˜κ²½λ³€μˆ˜λ‘œ μ§€μ •(κΈ°λ³Έ {ng}-2). 두 μ—μ΄μ „νŠΈκ°€ 이미 μ„€μΉ˜/일뢀 μ„€μΉ˜λœ node λ©΄ BLOCKED 둜 skip
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, infraName, infraNodegroups, DEFAULT_PROJECT } from '../../lib/data';
import { openConfig, waitNodeRunning, agentAction, cellText, classify, settleAgent } from './monitoring';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV7_S05", c)} ` + "[Infra] Monitoring: Monitoring Agent + Log Agent λ‘˜ λ‹€ μ„€μΉ˜", async ({ page, dialogs }) => {
    test.setTimeout(30 * 60 * 1000);
    const infra = infraName(c);
    const node = process.env.MON_NODE ||`${infraNodegroups(c)[0]}-2`;
    test.info().annotations.push({ type: 'target', description: `Infra node (K8s μ•„λ‹˜): infra=${infra} node=${node}` });
    const first = await openConfig(page, DEFAULT_PROJECT);
    test.skip(!first.text.includes(infra), `BLOCKED: μ„ ν–‰ Infra ${infra} κ°€ Monitor Setting 화면에 μ—†μŒ (IV3 Infra Running ν•„μš”)`);
    const fr = await waitNodeRunning(page, DEFAULT_PROJECT, node);
    const mon0 = await settleAgent(page, fr, node, 'Monitoring');
    const log0 = await settleAgent(page, fr, node, 'Log');
    test.info().annotations.push({ type: 'before', description: `Monitoring: ${mon0} / Log: ${log0}` });
    test.skip(!(classify(mon0) === 'not-installed' && classify(log0) === 'not-installed'), `BLOCKED: ${node} 의 두 μ—μ΄μ „νŠΈκ°€ λͺ¨λ‘ Not installed κ°€ μ•„λ‹˜`);
    const m = await agentAction(page, fr, dialogs, node, 'Monitoring', 'Install');
    const l = await agentAction(page, fr, dialogs, node, 'Log', 'Install');
    test.info().annotations.push({ type: 'after', description: `Monitoring: ${await cellText(fr, node, 'Monitoring')} / Log: ${await cellText(fr, node, 'Log')}` });
    expect.soft(m.done, `Monitoring Agent μ„€μΉ˜ μ™„λ£Œ β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${m.state}`).toBe(true);
    expect.soft(l.done, `Log Agent μ„€μΉ˜ μ™„λ£Œ β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${l.state}`).toBe(true);
  });
}

IV7_S06

tests/iv7/IV7_S06.spec.ts Β· 57쀄 Β· ν•΄μ‹œ ec132281 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S06: [K8s] Monitoring: Monitoring Agent + Log Agent λ‘˜ λ‹€ μ„€μΉ˜ [정상, CSP별]
// λŒ€μƒ: K8s cluster / K8s node (Infra μ•„λ‹˜)
// ν™”λ©΄: Monitorings > Config(Monitor Setting, iframe, Namespace=Project) β†’ K8s {cluster} 의 node β†’ Monitoring Agent μ—΄ Install, Log Agent μ—΄ Install
// μ „μ œ: admin 둜그인 μƒνƒœ, Workspace=ws01 / Project=p{n}. IV3 K8s Active. AWS: Project p1 / k1d / NodeGroup g1s(μ‚¬μš©μž μ§€μ‹œ 2026-10-08)
// 절차(AWS): 1.K8s Workloads μ—μ„œ k1d NodeGroup g1s 의 node 확인 2.Monitorings > Config(p1) 의 K8s k1d μ—μ„œ κ·Έ node ν–‰ 확인
//   3.Monitoring Agent κ°€ 이미 μ„€μΉ˜λΌ 있으면(IV7_S02) λ¨Όμ € Uninstall 둜 Not installed μƒνƒœλ₯Ό λ§Œλ“ λ‹€(μ „μ œ μ€€λΉ„) 4.Monitoring Agent 'Install' β†’ 확인 5.Log Agent 'Install' β†’ 확인
//   6.두 μ—μ΄μ „νŠΈ λͺ¨λ‘ Installing β†’ Installed 확인
// κΈ°λŒ€κ²°κ³Ό: 두 μ—μ΄μ „νŠΈ μ„€μΉ˜ μš”μ²­μ΄ 각각 μ ‘μˆ˜λ˜κ³  λͺ¨λ‘ Installed κ°€ λœλ‹€
// λŒ€μƒ ν΄λŸ¬μŠ€ν„°/NodeGroup: K8S_CLUSTER(κΈ°λ³Έ k{n}), K8S_NG(κΈ°λ³Έ g{n}s) β€” v07 AWS: k1d / g1s
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, k8sName, k8sProject } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { gotoK8s, selectNodeGroup } from '../../lib/iv3-k8s';
import { openConfig, waitNodeRunning, agentAction, cellText, classify, settleAgent } from './monitoring';

for (const c of activeCsps(["AWS","Azure","Alibaba","GCP","Tencent","NHN Cloud","NCP","IBM","KT"])) {
  test(`${tid("IV7_S06", c)} ` + "[K8s] Monitoring: Monitoring Agent + Log Agent λ‘˜ λ‹€ μ„€μΉ˜", async ({ page, dialogs }) => {
    test.setTimeout(40 * 60 * 1000);
    const prj = k8sProject(c);
    const cluster = process.env.K8S_CLUSTER || k8sName(c);
    const ngName = process.env.K8S_NG || `g${c.n}s`;
    await loginAsAdmin(page);
    await gotoK8s(page, prj);
    const st = await selectNodeGroup(page, cluster, ngName);
    const node = st.nodes[0];
    test.info().annotations.push({ type: 'target', description: `K8s cluster/node (Infra μ•„λ‹˜): ${prj} / ${cluster} / NodeGroup ${ngName} / node ${node ?? '(μ—†μŒ)'}` });
    test.skip(!node, `BLOCKED: NodeGroup ${ngName} 에 node μ—†μŒ`);
    // K8s λͺ©λ‘μ€ ν™”λ©΄ 제λͺ©λ³΄λ‹€ 늦게 μ±„μ›Œμ§„λ‹€(2026-10-08: 첫 λ‘œλ“œμ—μ„œ 'Monitor Setting Namespace: p1 Refresh' 만 λ³΄μž„) β†’ μ΅œλŒ€ 3회 λ‹€μ‹œ μ—΄μ–΄ 확인
    let seen = false;
    for (let i = 0; i < 3 && !seen; i++) {
      const o = await openConfig(page, prj);
      seen = o.text.includes(cluster) && o.text.includes(node);
      if (!seen) await page.waitForTimeout(15_000);
    }
    test.skip(!seen, `BLOCKED: ${cluster} / ${node} κ°€ Monitor Setting 화면에 μ—†μŒ`);
    const fr = await waitNodeRunning(page, prj, node);
    const mon = await settleAgent(page, fr, node, 'Monitoring');
    const log = await settleAgent(page, fr, node, 'Log');
    test.info().annotations.push({ type: 'before', description: `Monitoring: ${mon} / Log: ${log}` });
    // μ „μ œ μ€€λΉ„: 이미 μ„€μΉ˜λœ μ—μ΄μ „νŠΈλŠ” Uninstall ν•΄μ„œ λ‘˜ λ‹€ Not installed 둜 λ§žμΆ˜λ‹€
    for (const kind of ['Monitoring', 'Log'] as const) {
      const t = kind === 'Monitoring' ? mon : log;
      // 'Failed Install'(K8s μ„€μΉ˜ μ‹€νŒ¨, 2026-10-15 GKE)처럼 Install λ²„νŠΌλ§Œ 있으면 Uninstall 없이 λ°”λ‘œ Install λ‹¨κ³„λ‘œ
      if (classify(t) !== 'not-installed' && /Uninstall/.test(t)) {
        const u = await agentAction(page, fr, dialogs, node, kind, 'Uninstall');
        test.info().annotations.push({ type: 'precondition', description: `${kind} Agent Uninstall β†’ ${u.state}` });
        expect(u.done, `${kind} Agent Uninstall ν›„ Not installed β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${u.state}`).toBe(true);
      }
    }
    const m = await agentAction(page, fr, dialogs, node, 'Monitoring', 'Install');
    const l = await agentAction(page, fr, dialogs, node, 'Log', 'Install');
    test.info().annotations.push({ type: 'after', description: `Monitoring: ${await cellText(fr, node, 'Monitoring')} / Log: ${await cellText(fr, node, 'Log')}` });
    expect.soft(m.done, `Monitoring Agent μ„€μΉ˜ μ™„λ£Œ β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${m.state}`).toBe(true);
    expect.soft(l.done, `Log Agent μ„€μΉ˜ μ™„λ£Œ β€” λ§ˆμ§€λ§‰ μƒνƒœ: ${l.state}`).toBe(true);
  });
}

IV7_S07

tests/iv7/IV7_S07.spec.ts Β· 65쀄 Β· ν•΄μ‹œ b044fbb8 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S07: TC-OBS-007 λͺ¨λ‹ˆν„°λ§ ν”ŒλŸ¬κ·ΈμΈ(item) 등둝 (Monitoring Agent) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-AGENT-04
// ν™”λ©΄: Monitorings > Config β†’ Agent μ„€μΉ˜ μ™„λ£Œ node β†’ λ©”νŠΈλ¦­(ν”ŒλŸ¬κ·ΈμΈ) ν† κΈ€ ν‘œ
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ node Monitoring Agent SUCCESS(TC-OBS-AGENT-03), node Running
// 절차: 1.μ„€μΉ˜ μ™„λ£Œ node 선택 2.등둝 item(GET …/node/{nodeId}/item)Β·μ‚¬μš© κ°€λŠ₯ ν”ŒλŸ¬κ·ΈμΈ(GET /api/o11y/monitoring/plugins) 3.λΉ„ν™œμ„± ν”ŒλŸ¬κ·ΈμΈ ON 4.등둝(POST …/node/{nodeId}/item) 5.item λͺ©λ‘ κ°±μ‹  6.(원볡) μΌ  ν”ŒλŸ¬κ·ΈμΈ OFF(DELETE …/item/{itemSeq})
// κΈ°λŒ€κ²°κ³Ό: ν”ŒλŸ¬κ·ΈμΈ 등둝 2xx, item λͺ©λ‘(seq, pluginName, state)
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰.
// 2026-10-16 μˆ˜μ •(rf-obs2 탐색, μ‹€ν–‰ μ „): ν™”λ©΄ 확인 κ²°κ³Ό node ν–‰(<td>) 클릭 β†’ ν‘œ μ•„λž˜ 'Monitoring Metrics β€” {node}' νŒ¨λ„(ν”ŒλŸ¬κ·ΈμΈ 11개 μ²΄ν¬λ°•μŠ€ cpu…system + 'Apply',
//   μ•ˆλ‚΄ 'Enables the selected metrics and disables the rest.'). 행별 토글이 μ•„λ‹ˆλΌ Apply ν•œ λ²ˆμ— 선택 집합을 반영 β†’ 이전 선택 μƒνƒœλ₯Ό κΈ°λ‘ν•˜κ³  ν•˜λ‚˜λ§Œ μΆ”κ°€ν•΄ Apply, 원볡은 이전 μ§‘ν•©μœΌλ‘œ λ‹€μ‹œ Apply.
// 2026-10-16 보완(μ‚¬μš©μž μ§€μ‹œ): λΉ„ν™œμ„± ν”ŒλŸ¬κ·ΈμΈμ΄ μ—†μœΌλ©΄ ν•˜λ‚˜λ₯Ό 끄고 Apply β†’ λ‹€μ‹œ μΌœμ„œ Apply β†’ item λͺ©λ‘μ— 등둝 확인(끝 μƒνƒœ = μ›λž˜λŒ€λ‘œ λͺ¨λ‘ 켜짐)
//   node κ°€ Suspended 이면 item μ‘°νšŒκ°€ μ—†κ³  체크가 λͺ¨λ‘ λΉ„μ–΄ 보여(Apply μ‹œ μ „λΆ€ λ„κ²Œ 됨) μ‹€ν–‰ν•˜μ§€ μ•ŠλŠ”λ‹€ β€” Running node(OBS_WRITE_NODE) ν•„μš”. Apply 이후 API(POST/DELETE item) λŠ” 미검증
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, clean, OBS_NS, OBS_INFRA } from '../../lib/iv7-obs';

test('IV7_S07 TC-OBS-007 λͺ¨λ‹ˆν„°λ§ ν”ŒλŸ¬κ·ΈμΈ(item) 등둝', async ({ page }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const node = process.env.OBS_WRITE_NODE ?? '';
  test.skip(!node, 'OBS_WRITE_NODE λ―Έμ§€μ •(곡용 node 보호)');
  const items = collectResponses(page, new RegExp(`/api/o11y/monitoring/${OBS_NS}/${OBS_INFRA}/node/${node}/item`));
  await loginAsAdmin(page);
  const f = await openObs(page, 'config');
  const row = f.locator('tr').filter({ has: f.locator('td', { hasText: new RegExp(`^${node}$`) }) }).first();
  const rowText = clean(await row.innerText());
  test.info().annotations.push({ type: 'row', description: rowText });
  test.skip(!/Running/.test(rowText), `BLOCKED: ${node} κ°€ Running μ•„λ‹˜(${rowText}) β€” ν”ŒλŸ¬κ·ΈμΈ λ°˜μ˜μ— VM 접속 ν•„μš”`);
  await row.locator('td').first().click();
  await expect(f.getByRole('button', { name: 'Apply', exact: true })).toBeVisible({ timeout: 30_000 });
  await expect.poll(() => items.filter((i) => i.method === 'GET').length, { message: 'item λͺ©λ‘ GET', timeout: 30_000 }).toBeGreaterThan(0);
  const before: any[] = items.filter((i) => i.method === 'GET').at(-1)!.json?.data ?? [];
  test.info().annotations.push({ type: 'items-before', description: JSON.stringify(before).slice(0, 600) });
  const boxes = f.locator('label').filter({ has: f.locator('input[type="checkbox"]') });
  const names = (await boxes.allInnerTexts()).map((s) => s.trim());
  const checked = await boxes.locator('input[type="checkbox"]').evaluateAll((els) => els.map((e) => (e as HTMLInputElement).checked));
  const apply = async (label: string) => {
    const n0 = items.length;
    await f.getByRole('button', { name: 'Apply', exact: true }).click();
    await expect.poll(() => items.slice(n0).some((i) => i.method !== 'GET' && i.status < 300), { message: `${label} 2xx`, timeout: 60_000 }).toBe(true);
    test.info().annotations.push({ type: label, description: items.slice(n0).filter((i) => i.method !== 'GET').map((i) => `${i.status} ${i.method} ${i.url.split('/api/')[1]}`).join(' | ').slice(0, 600) });
  };
  let offIdx = checked.findIndex((c, i) => !c && !/nvidia/.test(names[i]));
  // 2026-10-16 보완(μ‚¬μš©μž μ§€μ‹œ): λͺ¨λ“  ν”ŒλŸ¬κ·ΈμΈμ΄ 켜져 있으면(λΉ„ν™œμ„± μ—†μŒ) ν•˜λ‚˜λ₯Ό λ¨Όμ € 끄고 Apply β†’ κ·Έ ν”ŒλŸ¬κ·ΈμΈμ„ λ‹€μ‹œ μΌœμ„œ λ“±λ‘λ˜λŠ”μ§€ ν™•μΈν•œλ‹€.
  //   끈 ν”ŒλŸ¬κ·ΈμΈμ€ λ§ˆμ§€λ§‰μ— μΌ  μƒνƒœλ‘œ λλ‚˜λ―€λ‘œ μ›λž˜ μƒνƒœ(λͺ¨λ‘ 켜짐)둜 λŒμ•„κ°„λ‹€.
  let restoreOff = true; // μ›λž˜ κΊΌμ Έ 있던 것을 켰으면 λ§ˆμ§€λ§‰μ— λ‹€μ‹œ λˆλ‹€
  if (offIdx < 0) {
    offIdx = names.findIndex((n) => !/nvidia/.test(n) && !/^cpu$/i.test(n)); // cpu λŠ” κΈ°λ³Έ μˆ˜μ§‘μ΄λΌ 피함
    expect(offIdx, '끄고 λ‹€μ‹œ μΌ€ ν”ŒλŸ¬κ·ΈμΈ').toBeGreaterThanOrEqual(0);
    await boxes.nth(offIdx).locator('input').uncheck();
    await apply('pre-off');
    restoreOff = false;
  }
  const name = names[offIdx];
  await boxes.nth(offIdx).locator('input').check();
  await apply('register');
  // 등둝 확인: item λͺ©λ‘μ„ λ‹€μ‹œ 읽어 κ·Έ ν”ŒλŸ¬κ·ΈμΈμ΄ μžˆλŠ”μ§€
  await f.locator('tr').filter({ has: f.locator('td', { hasText: new RegExp(`^${node}$`) }) }).first().locator('td').first().click();
  await expect.poll(() => (items.filter((i) => i.method === 'GET').at(-1)?.json?.data ?? []).some((x: any) => new RegExp(name, 'i').test(x.pluginName ?? x.name ?? JSON.stringify(x))),
    { message: `item λͺ©λ‘μ— ${name}`, timeout: 60_000 }).toBe(true);
  test.info().annotations.push({ type: 'items-after', description: JSON.stringify(items.filter((i) => i.method === 'GET').at(-1)?.json?.data ?? []).slice(0, 600) });
  if (restoreOff) { // (원볡) μ›λž˜ κΊΌμ Έ 있던 ν”ŒλŸ¬κ·ΈμΈμ€ λ‹€μ‹œ 끔
    await boxes.nth(offIdx).locator('input').uncheck();
    await apply('restore-off');
  }
});

IV7_S08

tests/iv7/IV7_S08.spec.ts Β· 30쀄 Β· ν•΄μ‹œ a6b7ce95 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S08: TC-OBS-008 Monitoring Agent 제거 (Uninstall) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-AGENT-05
// ν™”λ©΄: Monitorings > Config β†’ node ν–‰ Monitoring Agent 의 Uninstall β†’ 확인
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. μ„€μΉ˜λœ node(OBS_WRITE_NODE β€” λ‹€λ₯Έ μ„Έμ…˜μ΄ μ“°λŠ” node λŠ” μ§€μ • κΈˆμ§€)
// 절차: 1.node ν–‰ Monitoring Agent Uninstall β†’ 확인 2.제거 호좜(DELETE /api/o11y/monitoring/{ns}/{infra}/node/{node}) 3.미등둝 μƒνƒœ μ „ν™˜ 4.(원볡) μž¬μ„€μΉ˜λŠ” IV7_S01 절차
// κΈ°λŒ€κ²°κ³Ό: 제거 응닡 2xx, node 미등둝 μƒνƒœ
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰. μ…€λ ‰ν„°λŠ” IV7_S03 κ³Ό 같은 monitoring.ts agentAction(검증됨)
// 2026-10-16 μˆ˜μ •(rf-obs2): node κ°€ Running 이 μ•„λ‹ˆλ©΄ BLOCKED skip μΆ”κ°€ β€” Suspended node μ—μ„œ Uninstall ν•˜λ©΄ VM 접속 μ‹€νŒ¨λ‘œ FAILED κ°€ λ‚¨λŠ”λ‹€(mc9 g9-1 사둀).
//   2026-10-16 μ‹œμ  λŒ€μƒ 후보 mc1/g1-2(두 μ—μ΄μ „νŠΈ SERVICE_INACTIVE=μ„€μΉ˜λ¨, Suspended)λŠ” Resume 이 ν•„μš”ν•΄ λ―Έμ‹€ν–‰
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_NS, OBS_INFRA } from '../../lib/iv7-obs';
import { agentAction, cellText, rowStatus } from './monitoring';

test('IV7_S08 TC-OBS-008 Monitoring Agent 제거 (Uninstall)', async ({ page, dialogs }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const node = process.env.OBS_WRITE_NODE ?? '';
  test.skip(!node, 'OBS_WRITE_NODE λ―Έμ§€μ •(곡용 node 보호)');
  const del = collectResponses(page, new RegExp(`/api/o11y/monitoring/${OBS_NS}/${OBS_INFRA}/node/${node}`));
  await loginAsAdmin(page);
  const f = page.frameLocator('iframe').first();
  await openObs(page, 'config');
  const st = await rowStatus(f, node);
  test.info().annotations.push({ type: 'node-status', description: `${node}:${st || 'ν–‰ μ—†μŒ'} / Monitoring: ${await cellText(f, node, 'Monitoring')} / Log: ${await cellText(f, node, 'Log')}` });
  test.skip(st !== 'Running', `BLOCKED: ${node} κ°€ Running μ•„λ‹˜(${st || 'ν–‰ μ—†μŒ'}) β€” μ—μ΄μ „νŠΈ μ œκ±°λŠ” VM 접속 ν•„μš”('Cannot connect to the VM β€” it must be running')`);
  await agentAction(page, f, dialogs, node, 'Monitoring', 'Uninstall');
  test.info().annotations.push({ type: 'api', description: del.map((d) => `${d.status} ${d.method} ${d.url}`).join(' | ') });
  expect(await cellText(f, node, 'Monitoring')).toMatch(/NOT[_ ]INSTALLED|Install/i);
});

IV7_S09

tests/iv7/IV7_S09.spec.ts Β· 28쀄 Β· ν•΄μ‹œ e4e51c4a Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S09: TC-OBS-009 Log Agent 제거 (Uninstall) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-AGENT-05(Log)
// ν™”λ©΄: Monitorings > Config β†’ node ν–‰ Log Agent 의 Uninstall β†’ 확인
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. Log Agent μ„€μΉ˜ node(OBS_WRITE_NODE)
// 절차: 1.node ν–‰ Log Agent Uninstall β†’ 확인 2.제거 호좜 3.미등둝 μƒνƒœ 4.(원볡) μž¬μ„€μΉ˜λŠ” IV7_S03 절차
// κΈ°λŒ€κ²°κ³Ό: 제거 응닡 2xx, node 미등둝 μƒνƒœ
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰. μ…€λ ‰ν„°λŠ” IV7_S03 κ³Ό 같은 monitoring.ts agentAction(검증됨)
// 2026-10-16 μˆ˜μ •(rf-obs2): node κ°€ Running 이 μ•„λ‹ˆλ©΄ BLOCKED skip μΆ”κ°€ β€” Suspended node μ—μ„œ Uninstall ν•˜λ©΄ VM 접속 μ‹€νŒ¨λ‘œ FAILED κ°€ λ‚¨λŠ”λ‹€(mc9 g9-1 사둀).
//   2026-10-16 μ‹œμ  λŒ€μƒ 후보 mc1/g1-2(두 μ—μ΄μ „νŠΈ SERVICE_INACTIVE=μ„€μΉ˜λ¨, Suspended)λŠ” Resume 이 ν•„μš”ν•΄ λ―Έμ‹€ν–‰
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs } from '../../lib/iv7-obs';
import { agentAction, cellText, rowStatus } from './monitoring';

test('IV7_S09 TC-OBS-009 Log Agent 제거 (Uninstall)', async ({ page, dialogs }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const node = process.env.OBS_WRITE_NODE ?? '';
  test.skip(!node, 'OBS_WRITE_NODE λ―Έμ§€μ •(곡용 node 보호)');
  await loginAsAdmin(page);
  const f = page.frameLocator('iframe').first();
  await openObs(page, 'config');
  const st = await rowStatus(f, node);
  test.info().annotations.push({ type: 'node-status', description: `${node}:${st || 'ν–‰ μ—†μŒ'} / Monitoring: ${await cellText(f, node, 'Monitoring')} / Log: ${await cellText(f, node, 'Log')}` });
  test.skip(st !== 'Running', `BLOCKED: ${node} κ°€ Running μ•„λ‹˜(${st || 'ν–‰ μ—†μŒ'}) β€” μ—μ΄μ „νŠΈ μ œκ±°λŠ” VM 접속 ν•„μš”('Cannot connect to the VM β€” it must be running')`);
  await agentAction(page, f, dialogs, node, 'Log', 'Uninstall');
  expect(await cellText(f, node, 'Log')).toMatch(/NOT[_ ]INSTALLED|Install/i);
});

IV7_S10

tests/iv7/IV7_S10.spec.ts Β· 46쀄 Β· ν•΄μ‹œ 98f7c330 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S10: TC-OBS-010 Trigger Policy 생성/λͺ©λ‘/μ‚­μ œ [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRIG-01
// ν™”λ©΄: Monitorings > Alert β†’ Policies β†’ + New Policy
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. mc-o11y-manager / Grafana(μ•Œλ¦Ό) 기동
// 절차: 1.Policies νƒ­ 2.λͺ©λ‘(GET /api/o11y/trigger/policy) 3.+ New Policy: title/description/resourceType CPU/aggregation AVG/holdDuration 5m/repeatInterval 1h/threshold infoΒ·warningΒ·critical β†’ 생성(POST) 4.λͺ©λ‘ 반영 5.μ‚­μ œ(202) β†’ λͺ©λ‘μ—μ„œ 제거
// κΈ°λŒ€κ²°κ³Ό: 생성 201(id), λͺ©λ‘(content[], totalElements), μ‚­μ œ 202
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰.
// 2026-10-16 μˆ˜μ •(첫 μ‹€ν–‰, rf-obs2): 2026-10-14 μΆ”μ • μ…€λ ‰ν„°(getByLabel)κ°€ ν™”λ©΄κ³Ό 달라 ꡐ체 β€” 폼 라벨이 <label> 이 μ•„λ‹Œ <div> λΌμ„œ
//   lib/iv7-obs.ts policyField/createPolicy/deletePolicy μ‚¬μš©. μ‚­μ œ 확인은 JS confirm('Delete this policy?')이라 fixtures dialogs κ°€ 수락. μ‹€νŒ¨ν•΄λ„ finally μ—μ„œ μ •μ±… μ‚­μ œ(정리)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, createPolicy, deletePolicy, policyRow, clean } from '../../lib/iv7-obs';

export const POLICY = 'iv7-s10-cpu-policy';
test('IV7_S10 TC-OBS-010 Trigger Policy 생성/λͺ©λ‘/μ‚­μ œ', async ({ page }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const list = collectResponses(page, /\/api\/o11y\/trigger\/policy\?/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'alerts');
  try {
    await test.step('1~2. Policies λͺ©λ‘', async () => {
      await expect.poll(() => list.length, { timeout: 30_000 }).toBeGreaterThan(0);
      const d = list.at(-1)!.json?.data;
      test.info().annotations.push({ type: 'list-before', description: `${list.at(-1)!.status} totalElements=${d?.totalElements} content=${(d?.content ?? []).map((p: any) => p.title).join(',')}` });
      expect(Array.isArray(d?.content)).toBe(true);
    });
    const c = await test.step('3. + New Policy β†’ Create', async () =>
      createPolicy(page, f, { title: POLICY, description: 'IV7_S10 test policy', resource: 'CPU', aggregation: 'AVG', hold: '5m', repeat: '1h', info: '50', warning: '70', critical: '90' }));
    expect(c.status, '생성 201').toBe(201);
    expect(c.id, '생성 id').toBeTruthy();
    await test.step('4. λͺ©λ‘ 반영', async () => {
      const row = clean(await policyRow(f, POLICY).innerText());
      test.info().annotations.push({ type: 'row', description: row });
      expect(row).toMatch(/cpu/i);
      expect(row).toContain('I:50 W:70 C:90');
      const d = list.at(-1)!.json?.data;
      expect((d?.content ?? []).some((p: any) => p.title === POLICY && p.holdDuration === '5m' && p.repeatInterval === '1h' && /avg/i.test(p.aggregationType))).toBe(true);
    });
    await test.step('5. μ‚­μ œ 202 β†’ λͺ©λ‘μ—μ„œ 제거', async () => {
      expect(await deletePolicy(page, f, POLICY)).toBe(202);
    });
  } finally {
    await deletePolicy(page, f, POLICY).catch(() => 0); // 정리(이미 μ§€μ› μœΌλ©΄ 아무것도 μ•ˆ 함)
  }
});

IV7_S11

tests/iv7/IV7_S11.spec.ts Β· 56쀄 Β· ν•΄μ‹œ a4126fd6 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S11: TC-OBS-011 μ •μ±… ↔ Node/Infra μ—°κ²° [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRIG-02
// ν™”λ©΄: Monitorings > Alert β†’ μ •μ±… ν–‰ Manage β†’ Alarm targets
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. μ •μ±… 쑴재(IV7_S10 절차둜 생성), λŒ€μƒ node 쑴재
// 절차: 1.Manage β†’ Alarm targets 2.Infra 선택 β†’ 레벨 Node(단일 VM) 3.Add targets(POST /api/o11y/trigger/policy/{id}/node) 4.μ •μ±… vms λͺ©λ‘ κ°±μ‹  5.(정리) λŒ€μƒ 제거 Β· μ •μ±… μ‚­μ œ
// κΈ°λŒ€κ²°κ³Ό: 202 ACCEPTED, μ •μ±… vms λͺ©λ‘ κ°±μ‹ 
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰.
// 2026-10-16 μˆ˜μ •(첫 μ‹€ν–‰, rf-obs2): μΆ”μ • μ…€λ ‰ν„° ꡐ체 β€” 정책이 μ—†μœΌλ©΄ Manage κ°€ μ—†μœΌλ―€λ‘œ 이 spec 이 정책을 λ§Œλ“€κ³ (IV7_S10 절차) 끝에 μ§€μš΄λ‹€.
//   Alarm targets νŒ¨λ„μ€ 'Infra / Cluster' select + node μ²΄ν¬λ°•μŠ€(label 'g1-2 (g1-2)') + 'Add targets'. 레벨(Infra/Node) 선택 UI λŠ” μ—†κ³  node 체크가 단일 VM μ—°κ²°.
//   λŒ€μƒ node λŠ” OBS_TARGET_NODE(κΈ°λ³Έ OBS_NODE) β€” μ—°κ²°λ§Œ ν•˜κ³  node μƒνƒœλŠ” λ°”κΎΈμ§€ μ•ŠμŒ. μ•Œλ¦Ό 채널은 λΉ„μ›Œ 두어 λ°œμ†‘ μ—†μŒ
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, createPolicy, deletePolicy, policyRow, clean, OBS_INFRA, OBS_NODE } from '../../lib/iv7-obs';

const POLICY = 'iv7-s11-target-policy';
const NODE = process.env.OBS_TARGET_NODE ?? OBS_NODE;
test('IV7_S11 TC-OBS-011 μ •μ±… ↔ Node/Infra μ—°κ²°', async ({ page }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const api = collectResponses(page, /\/api\/o11y\/trigger\/policy\/\d+\/node/);
  const list = collectResponses(page, /\/api\/o11y\/trigger\/policy\?/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'alerts');
  try {
    await test.step('0. 사전: μ •μ±… 생성(IV7_S10 절차)', async () => { await createPolicy(page, f, { title: POLICY, description: 'IV7_S11 target test' }); });
    await test.step('1~3. Manage β†’ Alarm targets β†’ InfraΒ·Node β†’ Add targets', async () => {
      await policyRow(f, POLICY).getByRole('button', { name: 'Manage', exact: true }).click();
      await expect(f.getByRole('button', { name: 'Add targets' })).toBeVisible();
      const sel = f.locator('select').filter({ has: f.locator('option', { hasText: new RegExp(`^${OBS_INFRA}$`) }) }).first();
      await sel.selectOption({ label: OBS_INFRA });
      await page.waitForTimeout(2000);
      await f.locator('label').filter({ hasText: new RegExp(`^\\s*${NODE} \\(`) }).first().locator('input[type="checkbox"]').check()
        .catch(async () => f.getByLabel(new RegExp(`^${NODE} \\(`)).first().check());
      await f.getByRole('button', { name: 'Add targets' }).click();
      await expect.poll(() => api.find((a) => a.method === 'POST')?.status ?? 0, { message: 'Add targets POST', timeout: 30_000 }).toBeGreaterThan(0);
      const p = api.find((a) => a.method === 'POST')!;
      test.info().annotations.push({ type: 'add-targets', description: `${p.status} POST ${p.url} ${JSON.stringify(p.json ?? '').slice(0, 300)}` });
      expect(p.status, '202 ACCEPTED').toBe(202);
    });
    await test.step('4. μ •μ±… vms λͺ©λ‘ κ°±μ‹ ', async () => {
      await expect.poll(() => (list.at(-1)?.json?.data?.content ?? []).find((x: any) => x.title === POLICY)?.vms?.length ?? 0, { timeout: 30_000 }).toBeGreaterThan(0);
      const vms = (list.at(-1)!.json.data.content as any[]).find((x) => x.title === POLICY).vms;
      test.info().annotations.push({ type: 'vms', description: JSON.stringify(vms).slice(0, 500) });
      expect(JSON.stringify(vms)).toContain(NODE);
      const row = clean(await policyRow(f, POLICY).innerText());
      test.info().annotations.push({ type: 'row', description: row });
      test.info().annotations.push({ type: 'panel', description: clean(await f.locator('body').innerText()).slice(0, 1200) });
    });
  } finally {
    await test.step('5. (정리) μ •μ±… μ‚­μ œ β€” μ—°κ²°λœ λŒ€μƒλ„ ν•¨κ»˜ 제거', async () => {
      await page.keyboard.press('Escape').catch(() => {});
      const st = await deletePolicy(page, f, POLICY).catch((e) => { test.info().annotations.push({ type: 'cleanup-error', description: String(e).slice(0, 200) }); return -1; });
      test.info().annotations.push({ type: 'cleanup', description: `μ •μ±… μ‚­μ œ ${st}` });
    });
  }
});

IV7_S12

tests/iv7/IV7_S12.spec.ts Β· 67쀄 Β· ν•΄μ‹œ aa108960 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S12: TC-OBS-012 μ•Œλ¦Ό 채널 μ„€μ • [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRIG-03
// ν™”λ©΄: Monitorings > Alert β†’ μ •μ±… Manage β†’ Notification channels
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. μ •μ±… 쑴재, Grafana contact point(SMTP λ“±)·채널별 자격 ꡬ성
// 절차: 1.Notification channels 2.지원 채널(GET /api/o11y/trigger/noti/channel) 3.Email 체크 Β· μˆ˜μ‹ μž μž…λ ₯ 4.Test(POST /api/o11y/trigger/noti/test) 5.Save channels(PUT /api/o11y/trigger/policy/{id}/channel)
// κΈ°λŒ€κ²°κ³Ό: 202, notiChannels κ°±μ‹ , (ν…ŒμŠ€νŠΈ μ‹œ) λ°œμ†‘ κ²°κ³Ό
// λΉ„κ³ : 채널 자격(SMTPΒ·Slack 토큰 λ“±)이 μ—†μœΌλ©΄ SKIP(μ§€μΉ¨ 4-0). μˆ˜μ‹ μžλŠ” OBS_NOTI_EMAIL
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰.
// 2026-10-16 μˆ˜μ •(첫 μ‹€ν–‰, rf-obs2): μΆ”μ • μ…€λ ‰ν„° ꡐ체 β€” 이 spec 이 정책을 λ§Œλ“€κ³ (IV7_S10 절차, λŒ€μƒ node μ—†μŒ) Manage β†’ Notification channels β†’ Email μ²΄ν¬Β·μˆ˜μ‹ μž β†’ Save channels ν›„ μ •μ±… μ‚­μ œ.
//   μˆ˜μ‹ μž 기본값은 λ°œμ†‘λ˜μ§€ μ•ŠλŠ” μ˜ˆμ•½ 도메인 [email protected](OBS_NOTI_EMAIL 둜 λ³€κ²½). 'Test' λŠ” μ‹€μ œ λ©”μ‹œμ§€λ₯Ό λ³΄λ‚΄λ―€λ‘œ('Test delivers a real message through RabbitMQ')
//   μˆ˜μ‹ μžΒ·λ°œμ†‘ 승인이 μžˆμ„ λ•Œλ§Œ(OBS_NOTI_TEST=1) λˆ„λ₯Έλ‹€. 정책에 λŒ€μƒ node κ°€ μ—†μ–΄ μ €μž₯λ§ŒμœΌλ‘œλŠ” μ•Œλ¦Όμ΄ λ°œμ†‘λ˜μ§€ μ•ŠλŠ”λ‹€
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, createPolicy, deletePolicy, policyRow, clean } from '../../lib/iv7-obs';

const POLICY = 'iv7-s12-channel-policy';
test('IV7_S12 TC-OBS-012 μ•Œλ¦Ό 채널 μ„€μ •', async ({ page }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const to = process.env.OBS_NOTI_EMAIL || '[email protected]';
  const api = collectResponses(page, /\/api\/o11y\/trigger\/(noti|policy\/\d+\/channel)/);
  const list = collectResponses(page, /\/api\/o11y\/trigger\/policy\?/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'alerts');
  try {
    await test.step('2. 지원 채널 λͺ©λ‘', async () => {
      await expect.poll(() => api.filter((a) => /noti\/channel/.test(a.url)).length, { timeout: 30_000 }).toBeGreaterThan(0);
      const ch = api.filter((a) => /noti\/channel/.test(a.url)).at(-1)!.json?.data?.notiChannels ?? [];
      test.info().annotations.push({ type: 'channels', description: ch.map((c: any) => `${c.id}:${c.name}(${c.type}/${c.provider}, active=${c.isActive})`).join(', ') });
      expect(ch.some((c: any) => c.type === 'email' && c.isActive), 'email 채널 ν™œμ„±').toBe(true);
    });
    await test.step('0. 사전: μ •μ±… 생성(IV7_S10 절차, λŒ€μƒ μ—†μŒ)', async () => { await createPolicy(page, f, { title: POLICY, description: 'IV7_S12 channel test' }); });
    await test.step('1Β·3. Manage β†’ Notification channels β†’ Email 체크 Β· μˆ˜μ‹ μž', async () => {
      await policyRow(f, POLICY).getByRole('button', { name: 'Manage', exact: true }).click();
      await expect(f.getByRole('button', { name: 'Save channels' })).toBeVisible();
      const email = f.locator('label').filter({ hasText: /^\s*Email\s*$/ }).first();
      const cb = email.locator('input[type="checkbox"]');
      if (await cb.count()) await cb.check(); else await email.click();
      await f.getByPlaceholder(/Email addresses/).first().fill(to);
      test.info().annotations.push({ type: 'recipient', description: to });
    });
    await test.step('4. Test λ°œμ†‘(OBS_NOTI_TEST=1 일 λ•Œλ§Œ)', async () => {
      if (process.env.OBS_NOTI_TEST !== '1') { test.info().annotations.push({ type: 'test-send', description: 'λ―Έμ‹€ν–‰ β€” μ‹€μ œ λ©”μ‹œμ§€ λ°œμ†‘(μˆ˜μ‹ μžΒ·λ°œμ†‘ 승인 ν•„μš”)' }); return; }
      const row = f.locator('div').filter({ has: f.getByPlaceholder(/Email addresses/) }).filter({ has: f.getByRole('button', { name: 'Test', exact: true }) }).last();
      await row.getByRole('button', { name: 'Test', exact: true }).click();
      await expect.poll(() => api.some((a) => /noti\/test/.test(a.url)), { timeout: 60_000 }).toBe(true);
      const t = api.find((a) => /noti\/test/.test(a.url))!;
      test.info().annotations.push({ type: 'test-send', description: `${t.status} ${t.method} ${t.url} ${JSON.stringify(t.json ?? '').slice(0, 300)}` });
    });
    await test.step('5. Save channels β†’ 202, notiChannels κ°±μ‹ ', async () => {
      await f.getByRole('button', { name: 'Save channels' }).click();
      await expect.poll(() => api.find((a) => a.method === 'PUT')?.status ?? 0, { message: 'Save channels PUT', timeout: 30_000 }).toBeGreaterThan(0);
      const p = api.find((a) => a.method === 'PUT')!;
      test.info().annotations.push({ type: 'save-channels', description: `${p.status} PUT ${p.url} ${JSON.stringify(p.json ?? '').slice(0, 300)}` });
      expect(p.status, 'Save channels 202').toBe(202);
      await expect.poll(() => JSON.stringify((list.at(-1)?.json?.data?.content ?? []).find((x: any) => x.title === POLICY)?.notiChannels ?? []), { timeout: 30_000 }).toMatch(/email/i);
      const nc = (list.at(-1)!.json.data.content as any[]).find((x) => x.title === POLICY).notiChannels;
      test.info().annotations.push({ type: 'notiChannels', description: JSON.stringify(nc).slice(0, 500) });
      test.info().annotations.push({ type: 'row', description: clean(await policyRow(f, POLICY).innerText()) });
    });
  } finally {
    await test.step('(정리) μ •μ±… μ‚­μ œ', async () => {
      const st = await deletePolicy(page, f, POLICY).catch((e) => { test.info().annotations.push({ type: 'cleanup-error', description: String(e).slice(0, 200) }); return -1; });
      test.info().annotations.push({ type: 'cleanup', description: `μ •μ±… μ‚­μ œ ${st}` });
    });
  }
});

IV7_S13

tests/iv7/IV7_S13.spec.ts Β· 31쀄 Β· ν•΄μ‹œ f0125ce5 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S13: TC-OBS-013 Trigger / Notification History [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRIG-04 β€” κΈ°μ‘΄ spec(mcmp-e2e deploy/scenarios/shared/step-handlers/obs/trigger.ts runObsTrig04)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Alert (iframe /embed/alerts/{ns}) β†’ Policies(트리거 이λ ₯) Β· Notification History νƒ­
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. TC-OBS-011Β·012 μ™„λ£Œ, μž„κ³„μΉ˜ 초과 트리거 λ°œμƒ
// 절차: 1.트리거 λ°œμƒ ν›„ History 확인 2.트리거 이λ ₯(GET /api/o11y/trigger/history) severity/value/threshold 3.(μ˜΅μ…˜) μ½”λ©˜νŠΈ μˆ˜μ • 4.μ•Œλ¦Ό λ°œμ†‘ 이λ ₯(GET /api/o11y/trigger/noti/history) 5.Notification History νƒ­ λ°œμ†‘ 둜그
// κΈ°λŒ€κ²°κ³Ό: 트리거 이λ ₯ content[](severity, value, threshold), μ•Œλ¦Ό 이λ ₯ content[](channelName, isSucceeded)
// λΉ„κ³ : 읽기 TC 둜 λ¨Όμ € μ§„ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14). μ •μ±…Β·νŠΈλ¦¬κ±°κ°€ μ—†μœΌλ©΄ SKIP(μ„ ν–‰ TC-OBS-010~012 λ―Έμ‹€ν–‰)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, clean } from '../../lib/iv7-obs';

test('IV7_S13 TC-OBS-013 Trigger / Notification History', async ({ page }) => {
  const policy = collectResponses(page, /\/api\/o11y\/trigger\/policy(\?|$)/);
  const notiHist = collectResponses(page, /\/api\/o11y\/trigger\/noti\/history/);
  const trigHist = collectResponses(page, /\/api\/o11y\/trigger\/history/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'alerts');
  await expect.poll(() => policy.length, { timeout: 30_000 }).toBeGreaterThan(0);
  const policies = policy.at(-1)!.json?.data?.totalElements ?? 0;
  await test.step('4~5. Notification History νƒ­', async () => {
    await f.getByRole('button', { name: 'Notification History', exact: true }).click();
    await page.waitForTimeout(4000);
    test.info().annotations.push({ type: 'noti-history', description: `${notiHist.map((n) => `${n.status} ${n.url.slice(0, 120)} total=${n.json?.data?.totalElements}`).join(' | ') || '(호좜 μ—†μŒ)'} / ${clean(await f.locator('body').innerText()).slice(0, 300)}` });
  });
  const trig = trigHist.at(-1)?.json?.data?.totalElements ?? 0;
  const noti = notiHist.at(-1)?.json?.data?.totalElements ?? 0;
  test.info().annotations.push({ type: 'counts', description: `μ •μ±… ${policies} / 트리거 이λ ₯ ${trig} / μ•Œλ¦Ό 이λ ₯ ${noti}` });
  test.skip(!policies || (!trig && !noti), 'μ •μ±…Β·νŠΈλ¦¬κ±° 이λ ₯ μ—†μŒ β€” μ„ ν–‰ TC-OBS-010~012(μ •μ±… μƒμ„±Β·λŒ€μƒ 연결·채널) λ―Έμ‹€ν–‰');
  expect(noti + trig).toBeGreaterThan(0);
});

IV7_S14

tests/iv7/IV7_S14.spec.ts Β· 30쀄 Β· ν•΄μ‹œ c1c4f52b Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S14: TC-OBS-014 NS λͺ©λ‘ 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-NS-01 β€” κΈ°μ‘΄ spec μ—†μŒ(λ ˆκ±°μ‹œλŠ” ꡬ UI DOM 쑴재만 확인). 2026-10-14 μ‹ κ·œ μž‘μ„±
// ν™”λ©΄: Monitorings > Monitoring (iframe :18081/embed/monitoring/{ns})
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. mc-o11y-manager Β· CB-Tumblebug 기동, NS 1개 이상
// 절차: 1.Monitoring μ§„μž… 2.화면이 λΆ€λ₯΄λŠ” NS λͺ©λ‘(GET /tumblebug/ns) 3.μ½˜μ†” Project(=NS) λͺ©λ‘μ΄ NS λͺ©λ‘μ— 포함 4.iframe 상단 'Namespace β€” {선택 NS}'
// κΈ°λŒ€κ²°κ³Ό: ns λ°°μ—΄(id, name), NS λͺ©λ‘ ν‘œμ‹œ
// λΉ„κ³ : μ½˜μ†” μž„λ² λ“œ ν™”λ©΄μ—λŠ” NS μ…€λ ‰ν„°κ°€ μ—†κ³  μ½˜μ†” 상단 Project 선택이 NS 둜 μ „λ‹¬λœλ‹€(2026-10-14) β†’ Project 선택칸 = NS λͺ©λ‘μœΌλ‘œ νŒμ •
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_NS, clean } from '../../lib/iv7-obs';

test('IV7_S14 TC-OBS-014 NS λͺ©λ‘ 쑰회', async ({ page }) => {
  const res = collectResponses(page, /\/tumblebug\/ns(\?|$)/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'monitoring');
  await test.step('1~2. NS λͺ©λ‘ 응닡(GET /tumblebug/ns)', async () => {
    await expect.poll(() => res.length, { timeout: 30_000 }).toBeGreaterThan(0);
    const ns: any[] = res.at(-1)!.json?.ns ?? [];
    test.info().annotations.push({ type: 'ns', description: ns.map((n) => `${n.id}(${n.name})`).join(', ') });
    expect(ns.length).toBeGreaterThan(0);
    expect(ns.every((n) => n.id && n.name)).toBe(true);
    const projects = (await page.locator('#select-current-project option').allInnerTexts()).map(clean).filter((p) => p && !/select/i.test(p));
    test.info().annotations.push({ type: 'console-projects', description: projects.join(', ') });
    for (const p of projects) expect(ns.map((n) => n.id), `Project ${p} κ°€ NS λͺ©λ‘μ—`).toContain(p);
  });
  await test.step('3. iframe 상단 Namespace ν‘œμ‹œ', async () => {
    await expect(f.getByText(new RegExp(`Namespace\\s*β€”\\s*${OBS_NS}`)).first()).toBeVisible();
  });
});

IV7_S15

tests/iv7/IV7_S15.spec.ts Β· 34쀄 Β· ν•΄μ‹œ db2d0c54 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S15: TC-OBS-015 NS 선택 β†’ Infra λͺ©λ‘ [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-NS-02 β€” 2026-10-14 μ‹ κ·œ μž‘μ„±(λ ˆκ±°μ‹œλŠ” λ“œλ‘­λ‹€μš΄ DOM 쑴재만 확인)
// ν™”λ©΄: Monitorings > Monitoring (Node νƒ­) / Logs 의 Workload(Infra/Cluster) μ…€λ ‰ν„°
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ NS 에 Infra 1개 이상
// 절차: 1.Project(=NS) default 선택 2.Infra λͺ©λ‘(GET /tumblebug/ns/{ns}/infra) 3.Monitoring ν™”λ©΄μ˜ Infra λͺ©λ‘Β·'N Nodes / M Infras' 4.Logs Workload μ…€λ ‰ν„° λͺ©λ‘
// κΈ°λŒ€κ²°κ³Ό: infra λ°°μ—΄(id, name, status, node[]), Infra μ…€λ ‰ν„° ν‘œμ‹œ
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_NS, clean } from '../../lib/iv7-obs';

test('IV7_S15 TC-OBS-015 NS 선택 β†’ Infra λͺ©λ‘', async ({ page }) => {
  const res = collectResponses(page, new RegExp(`/tumblebug/ns/${OBS_NS}/infra(\\?|$)`));
  await loginAsAdmin(page);
  const f = await openObs(page, 'monitoring');
  let ids: string[] = [];
  await test.step('1~3. Infra λͺ©λ‘ 응닡 = Monitoring ν™”λ©΄', async () => {
    await expect.poll(() => res.length, { timeout: 30_000 }).toBeGreaterThan(0);
    const infra: any[] = res.at(-1)!.json?.infra ?? [];
    ids = infra.map((i) => i.id);
    const body = clean(await f.locator('body').innerText());
    test.info().annotations.push({ type: 'infra', description: infra.map((i) => `${i.id}(${i.status}, node ${(i.node ?? []).length})`).join(', ') });
    test.info().annotations.push({ type: 'summary', description: body.match(/\d+ Nodes \/ \d+ Infras/)?.[0] ?? '' });
    expect(ids.length).toBeGreaterThan(0);
    expect(Number(body.match(/(\d+) Infras/)?.[1])).toBe(ids.length);
    for (const id of ids) expect(body, `Infra ${id} ν‘œμ‹œ`).toContain(id);
  });
  await test.step('4. Logs 의 Workload(Infra/Cluster) μ…€λ ‰ν„°', async () => {
    const g = await openObs(page, 'logs');
    const opts = (await g.locator('select').first().locator('option').allInnerTexts()).map(clean).filter((o) => !/^Select/.test(o));
    test.info().annotations.push({ type: 'logs-workload', description: opts.join(', ') });
    expect(opts.sort()).toEqual([...ids].sort());
  });
});

IV7_S16

tests/iv7/IV7_S16.spec.ts Β· 30쀄 Β· ν•΄μ‹œ e1851c70 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S16: TC-OBS-016 Infra 선택 β†’ Node λͺ©λ‘ [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-NS-03 β€” 2026-10-14 μ‹ κ·œ μž‘μ„±(λ ˆκ±°μ‹œλŠ” DOM 쑴재만 확인)
// ν™”λ©΄: Monitorings > Config (iframe /embed/config/{ns}) β€” Infra 별 Node ν‘œ(Name/Node ID/Status/Monitoring Agent/Log Agent)
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ Infra(OBS_INFRA, κΈ°λ³Έ mc1) 쑴재
// 절차: 1.Config μ§„μž… 2.Infra 상세(GET /tumblebug/ns/{ns}/infra/{infra}) λ˜λŠ” λͺ©λ‘μ˜ node 3.λ§€λ‹ˆμ € 등둝 node μƒνƒœ(GET /api/o11y/monitoring/{ns}/{infra}/node) 4.ν™”λ©΄μ˜ node ν–‰κ³Ό Agent μƒνƒœκ°€ 응닡과 일치
// κΈ°λŒ€κ²°κ³Ό: node λ°°μ—΄, λ…Έλ“œλ³„ monitoring_agent_status / log_agent_status ν‘œμ‹œ
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_NS, OBS_INFRA, clean } from '../../lib/iv7-obs';

test('IV7_S16 TC-OBS-016 Infra 선택 β†’ Node λͺ©λ‘', async ({ page }) => {
  const res = collectResponses(page, new RegExp(`/api/o11y/monitoring/${OBS_NS}/${OBS_INFRA}/node$`));
  await loginAsAdmin(page);
  const f = await openObs(page, 'config');
  await test.step(`1~4. ${OBS_INFRA} node ν–‰ Β· Agent μƒνƒœ = 응닡`, async () => {
    await expect.poll(() => res.length, { timeout: 30_000 }).toBeGreaterThan(0);
    const nodes: any[] = res.at(-1)!.json?.data ?? [];
    test.info().annotations.push({ type: 'nodes', description: nodes.map((n) => `${n.node_id}: mon=${n.monitoring_agent_status} log=${n.log_agent_status}`).join(', ') });
    expect(nodes.length).toBeGreaterThan(0);
    for (const n of nodes) {
      const row = f.locator('tr').filter({ has: f.locator('td', { hasText: new RegExp(`^${n.node_id}$`) }) }).first();
      const t = clean(await row.innerText());
      test.info().annotations.push({ type: `row-${n.node_id}`, description: t });
      expect(t).toContain(n.node_id);
      expect(t.toUpperCase()).toContain(String(n.monitoring_agent_status).toUpperCase());
      expect(t.toUpperCase()).toContain(String(n.log_agent_status).toUpperCase());
    }
  });
});

IV7_S17

tests/iv7/IV7_S17.spec.ts Β· 30쀄 Β· ν•΄μ‹œ 2f0355ad Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S17: TC-OBS-017 λͺ¨λ‹ˆν„°λ§ ν™”λ©΄ μ§„μž… [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-MON-DATA-01 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-MON-DATA-01-monitoring-data-screen.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Monitoring β†’ node λŒ€μ‹œλ³΄λ“œ(iframe /embed/monitoring/{ns}/{infra}/{node})
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ node(OBS_INFRA/OBS_NODE, κΈ°λ³Έ mc1/g1-1)
// 절차: 1.Monitoring μ§„μž… β†’ node λŒ€μ‹œλ³΄λ“œ 경둜 2.μΈ‘μ •ν•­λͺ© 메타(GET /api/o11y/monitoring/influxdb/measurement) 3.쒌츑 컨트둀(Agent/API, Workload/Server/Measurement/Metric/Aggregation/Range/Period, Start Monitoring)κ³Ό 차트 μ˜μ—­
// κΈ°λŒ€κ²°κ³Ό: λͺ¨λ‹ˆν„°λ§ λŒ€μ‹œλ³΄λ“œ ν™”λ©΄, μΈ‘μ •ν•­λͺ© λͺ©λ‘Β·μ°¨νŠΈ 컨트둀
// λΉ„κ³ : Infra κ°œμš”μ˜ node μΉ΄λ“œ ν΄λ¦­μœΌλ‘œλŠ” λŒ€μ‹œλ³΄λ“œκ°€ 열리지 μ•Šμ•„ μ‹œνŠΈ 경둜둜 iframe 이동(2026-10-14)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, obsGoto, collectResponses, OBS_NS, OBS_INFRA, OBS_NODE } from '../../lib/iv7-obs';

test('IV7_S17 TC-OBS-017 λͺ¨λ‹ˆν„°λ§ ν™”λ©΄ μ§„μž…', async ({ page }) => {
  const meas = collectResponses(page, /\/api\/o11y\/monitoring\/influxdb\/measurement$/);
  await loginAsAdmin(page);
  let f = await openObs(page, 'monitoring');
  f = await test.step(`1. node λŒ€μ‹œλ³΄λ“œ /embed/monitoring/${OBS_NS}/${OBS_INFRA}/${OBS_NODE}`, () => obsGoto(page, f, `/embed/monitoring/${OBS_NS}/${OBS_INFRA}/${OBS_NODE}`));
  await test.step('2. μΈ‘μ •ν•­λͺ© 메타 응닡', async () => {
    await expect.poll(() => meas.length, { timeout: 30_000 }).toBeGreaterThan(0);
    const m: any[] = meas.at(-1)!.json?.data ?? [];
    test.info().annotations.push({ type: 'measurements', description: m.map((x) => x.measurement).join(', ') });
    expect(m.map((x) => x.measurement)).toEqual(expect.arrayContaining(['cpu', 'mem', 'disk']));
  });
  await test.step('3. 컨트둀 Β· 차트 μ˜μ—­', async () => {
    await expect(f.getByText('Monitoring Trend').first()).toBeVisible();
    for (const l of ['Workload', 'Server', 'Measurement', 'Metric', 'Aggregation', 'Range', 'Period']) await expect(f.getByText(l, { exact: true }).first()).toBeVisible();
    await expect(f.getByRole('button', { name: 'Start Monitoring' })).toBeVisible();
    await expect(f.getByText(`Node: ${OBS_NODE}`).first()).toBeVisible();
  });
});

IV7_S18

tests/iv7/IV7_S18.spec.ts Β· 29쀄 Β· ν•΄μ‹œ 10c50ab9 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S18: TC-OBS-018 μ½˜μ†” iframe λͺ¨λ‹ˆν„°λ§ μ§„μž… [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-IFRAME-01 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-IFRAME-01-console-iframe.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: mc-web-console Monitorings 메뉴 6개(Monitoring/Logs/Config/Insight/Alert/Tracing) β†’ iframe :18081/embed/...
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. mc-web-console 연동 μ„€μ •
// 절차: 1.각 메뉴 μ§„μž… β†’ iframe src κ°€ /embed/{monitoring|logs|config|insight|alerts|trace}/{ns} 2.EmbedLayout(헀더/μ‚¬μ΄λ“œλ°” μ—†λŠ” ν™”λ©΄)으둜 λ‘œλ“œ 3.화면별 핡심 μš”μ†Œ λ™μž‘
// κΈ°λŒ€κ²°κ³Ό: λ„€λΉ„κ²Œμ΄μ…˜ 크둬 μ—†λŠ” μž„λ² λ“œ ν™”λ©΄, iframe λ‚΄ κΈ°λŠ₯ 정상
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, OBS_NS, clean, type ObsMenu } from '../../lib/iv7-obs';

const EXPECT: Record<ObsMenu, RegExp> = {
  monitoring: /Namespace\s*β€”/, logs: /Log Manage/, config: /Monitor Setting/, insight: /Anomaly Detection/, alerts: /Trigger Policies|Policies/, trace: /Trace Manage/,
};
test('IV7_S18 TC-OBS-018 μ½˜μ†” iframe λͺ¨λ‹ˆν„°λ§ μ§„μž…', async ({ page }) => {
  await loginAsAdmin(page);
  for (const menu of Object.keys(EXPECT) as ObsMenu[]) {
    await test.step(`${menu}: /embed/${menu}/${OBS_NS} Β· μž„λ² λ“œ λ ˆμ΄μ•„μ›ƒ Β· 핡심 μš”μ†Œ`, async () => {
      const f = await openObs(page, menu);
      const path = new URL(f.url()).pathname;
      const body = clean(await f.locator('body').innerText());
      const chrome = await f.locator('nav, aside, header .navbar, .sidebar').count();
      test.info().annotations.push({ type: menu, description: `${path} / navΒ·sidebar μš”μ†Œ ${chrome} / ${body.slice(0, 120)}` });
      expect(path).toBe(`/embed/${menu}/${OBS_NS}`);
      expect(body).toMatch(EXPECT[menu]);
      expect(body, '독립 μ‹€ν–‰ ν™”λ©΄μ˜ 상단 λ„€λΉ„ 메뉴 μ—†μŒ').not.toMatch(/MC-Observability\s+(Monitoring\s+Logs|Dashboard)/);
    });
  }
});

IV7_S19

tests/iv7/IV7_S19.spec.ts Β· 37쀄 Β· ν•΄μ‹œ 8df7af22 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S19: TC-OBS-019 Agent 기반 μ‹œκ³„μ—΄ λ©”νŠΈλ¦­ (InfluxDB) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-METRIC-01 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-METRIC-01-agent-timeseries.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Monitoring β†’ node λŒ€μ‹œλ³΄λ“œ(/embed/monitoring/{ns}/{infra}/{node}, λ°μ΄ν„°μ†ŒμŠ€ Agent)
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ node 에 Monitoring Agent SUCCESS Β· μˆ˜μ§‘ 데이터 쑴재
// 절차: 1.node λŒ€μ‹œλ³΄λ“œ(Agent) 2.Measurement cpu Β· Metric usage_idle Β· Aggregation Mean Β· Range 1H Β· Period 1m 3.Start Monitoring 4.λ…Έλ“œ λ©”νŠΈλ¦­(POST /api/o11y/monitoring/influxdb/metric/{ns}/{infra}/{node}) 5.μ‹œκ³„μ—΄ 차트
// κΈ°λŒ€κ²°κ³Ό: MetricDTO[](name, columns, tags, values[[time,value]]), μ‹œκ³„μ—΄ 차트 ν‘œμ‹œ
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, obsGoto, collectResponses, selectNear, OBS_NS, OBS_INFRA, OBS_NODE } from '../../lib/iv7-obs';

test('IV7_S19 TC-OBS-019 Agent 기반 μ‹œκ³„μ—΄ λ©”νŠΈλ¦­ (InfluxDB)', async ({ page }) => {
  const metric = collectResponses(page, new RegExp(`/influxdb/metric/${OBS_NS}/${OBS_INFRA}/${OBS_NODE}$`));
  await loginAsAdmin(page);
  let f = await openObs(page, 'monitoring');
  f = await obsGoto(page, f, `/embed/monitoring/${OBS_NS}/${OBS_INFRA}/${OBS_NODE}`);
  await test.step('2. cpu Β· usage_idle Β· Mean Β· 1H Β· 1m', async () => {
    await selectNear(f, 'Measurement', 'cpu');
    await page.waitForTimeout(1500);
    await selectNear(f, 'Metric', 'usage_idle');
    await selectNear(f, 'Aggregation', 'Mean');
    await selectNear(f, 'Range', '1H');
    await selectNear(f, 'Period', '1m');
  });
  await test.step('3~5. Start Monitoring β†’ λ©”νŠΈλ¦­ 응닡 Β· 차트', async () => {
    const n0 = metric.length;
    await f.getByRole('button', { name: 'Start Monitoring' }).click();
    await expect.poll(() => metric.slice(n0).find((m) => m.json?.data?.[0]?.columns?.includes('usage_idle')), { timeout: 30_000 }).toBeTruthy();
    const d = metric.slice(n0).find((m) => m.json?.data?.[0]?.columns?.includes('usage_idle'))!.json.data[0];
    const vals = (d.values ?? []).filter((v: any[]) => v[1] !== null);
    test.info().annotations.push({ type: 'metric', description: `${d.name} ${JSON.stringify(d.tags)} columns=${d.columns} κ°’ ${vals.length}개 예: ${JSON.stringify(vals.slice(0, 2))}` });
    expect(vals.length, 'μˆ˜μ§‘λœ μ‹œκ³„μ—΄ κ°’').toBeGreaterThan(0);
    await expect(f.locator('svg:visible, canvas:visible').first()).toBeVisible();
    await expect(f.getByText(/Download CSV/).first(), '차트 μ˜μ—­(λ‹€μš΄λ‘œλ“œ 메뉴)').toBeVisible();
    test.info().annotations.push({ type: 'chart', description: (await f.locator('body').innerText()).replace(/\s+/g, ' ').match(/Node: [^\n]{0,200}/)?.[0] ?? '' });
  });
});

IV7_S20

tests/iv7/IV7_S20.spec.ts Β· 33쀄 Β· ν•΄μ‹œ 9e224647 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S20: TC-OBS-020 CSP API λ©”νŠΈλ¦­ (cb-spider) [정상, AWS]
// v0.6 TC-ID: TC-OBS-METRIC-02 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-METRIC-02-csp-api-metrics.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Monitoring β†’ node λŒ€μ‹œλ³΄λ“œ β†’ 'API' λ°μ΄ν„°μ†ŒμŠ€(?source=csp)
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ node Running Β· CSP Credential 유효(Agent λΆˆν•„μš”)
// 절차: 1.node λŒ€μ‹œλ³΄λ“œμ—μ„œ API(CSP) 선택 2.connectionNameΒ·cspResourceName 3.API Metric CPU Usage Percent 4.CSP λ©”νŠΈλ¦­(GET /api/o11y/monitoring/csp/node/{cspResourceName}/{metricType}?ConnectionName=…) 5.CSP μ‹œκ³„μ—΄ 차트
// κΈ°λŒ€κ²°κ³Ό: SpiderMonitoringInfo.Data(metricName, metricUnit, timestampValues[]), CSP λ©”νŠΈλ¦­ 차트
// λΉ„κ³ : AWS λŠ” memory_usage 미지원(unsupported:true, 2026-10-14) β€” νŒμ •μ€ CPU 둜
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, obsGoto, collectResponses, OBS_NS, OBS_INFRA, OBS_NODE } from '../../lib/iv7-obs';

test('IV7_S20_AWS TC-OBS-020 CSP API λ©”νŠΈλ¦­ (cb-spider)', async ({ page }) => {
  const csp = collectResponses(page, /\/api\/o11y\/monitoring\/csp\/node\//);
  await loginAsAdmin(page);
  let f = await openObs(page, 'monitoring');
  f = await obsGoto(page, f, `/embed/monitoring/${OBS_NS}/${OBS_INFRA}/${OBS_NODE}`);
  await test.step('1. λ°μ΄ν„°μ†ŒμŠ€ API(CSP) 선택', async () => {
    await f.getByRole('button', { name: 'API', exact: true }).click();
    await page.waitForTimeout(4000);
    await expect(f.getByText('API Metric').first()).toBeVisible();
  });
  await test.step('3~5. CPU Usage Percent β†’ CSP λ©”νŠΈλ¦­ 응닡 Β· 차트', async () => {
    await f.getByRole('button', { name: 'CPU Usage Percent' }).click();
    await expect.poll(() => csp.find((c) => /cpu_usage/.test(c.url)), { timeout: 30_000 }).toBeTruthy();
    const r = csp.filter((c) => /cpu_usage/.test(c.url)).at(-1)!;
    const unsupported = csp.filter((c) => c.json?.unsupported).map((c) => c.url.match(/node\/[^/]+\/(\w+)/)?.[1]);
    test.info().annotations.push({ type: 'csp', description: `${r.url} β†’ ${r.json?.metricName} (${r.json?.metricUnit}) κ°’ ${(r.json?.timestampValues ?? []).length}개 / unsupported: ${unsupported.join(', ') || 'μ—†μŒ'}` });
    expect(r.url).toMatch(/ConnectionName=[\w-]+/);
    expect((r.json?.timestampValues ?? []).length).toBeGreaterThan(0);
    await expect(f.getByText(/CPU Usage Percent \(Percent\)/).first()).toBeVisible();
  });
});

IV7_S21

tests/iv7/IV7_S21.spec.ts Β· 44쀄 Β· ν•΄μ‹œ a51ea5a9 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S21: TC-OBS-021 Infra / NS 레벨 μ˜€λ²„λ·° [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-METRIC-04 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-METRIC-04-infra-ns-overview.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Monitoring (NS μ˜€λ²„λ·° /embed/monitoring/{ns}, Infra μ˜€λ²„λ·° /embed/monitoring/{ns}/{infra})
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. μˆ˜μ§‘ 데이터 쑴재(TC-OBS-019 λ˜λŠ” 020)
// 절차: 1.NS μ˜€λ²„λ·° 2.λ…Έλ“œλ³„ λ©”νŠΈλ¦­ 병렬 쑰회(agent: influxdb/metric) 3.Node νƒ­ λ…Έλ“œ μΉ΄λ“œ(CPU/Memory/Disk) 4.K8s νƒ­ 5.λ°μ΄ν„°μ†ŒμŠ€ Agent/API ν† κΈ€ β†’ csp/node 쑰회
// κΈ°λŒ€κ²°κ³Ό: λ…Έλ“œ/ν΄λŸ¬μŠ€ν„° λ©”νŠΈλ¦­ μš”μ•½ μΉ΄λ“œ(CPU/Memory/Disk)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, obsGoto, collectResponses, OBS_NS, OBS_INFRA, clean } from '../../lib/iv7-obs';

test('IV7_S21 TC-OBS-021 Infra / NS 레벨 μ˜€λ²„λ·°', async ({ page }) => {
  const agent = collectResponses(page, /\/influxdb\/metric\//);
  const csp = collectResponses(page, /\/api\/o11y\/monitoring\/csp\/node\//);
  await loginAsAdmin(page);
  let f = await openObs(page, 'monitoring');
  await test.step('1~3. NS μ˜€λ²„λ·° Β· λ…Έλ“œ μΉ΄λ“œ CPU/Memory/Disk', async () => {
    await expect.poll(() => agent.length, { timeout: 30_000 }).toBeGreaterThan(0);
    const body = clean(await f.locator('body').innerText());
    test.info().annotations.push({ type: 'ns-overview', description: `agent 쑰회 ${agent.length}건 / ${body.slice(0, 300)}` });
    // 2026-10-14 κ΄€μ°°: mc1/g1-1 μΉ΄λ“œκ°€ 'No data' β€” 화면은 mc1/g1-1 λŒ€μ‹  같은 node μ΄λ¦„μ˜ mt1/g1-1 을 쑰회(이름 좩돌 μ˜μ‹¬). 기둝만 ν•œλ‹€
    const asked = [...new Set(agent.map((a) => a.url.replace(/.*influxdb\/metric\//, '')))];
    test.info().annotations.push({ type: 'requested-nodes', description: asked.join(', ') });
    for (const k of ['CPU Used', 'Memory Used', 'Disk Used']) expect(body).toContain(k);
  });
  await test.step(`Infra μ˜€λ²„λ·° /embed/monitoring/${OBS_NS}/${OBS_INFRA}: κ°’ μžˆλŠ” μΉ΄λ“œ`, async () => {
    f = await obsGoto(page, f, `/embed/monitoring/${OBS_NS}/${OBS_INFRA}`);
    await expect.poll(async () => /CPU Used [\d.]+%/.test(clean(await f.locator('body').innerText())), { timeout: 60_000 }).toBe(true);
    const body = clean(await f.locator('body').innerText());
    test.info().annotations.push({ type: 'infra-overview', description: body.slice(0, 500) });
    expect(body).toContain(`Infra Overview β€” ${OBS_INFRA}`);
  });
  await test.step('4. K8s νƒ­', async () => {
    await f.getByRole('button', { name: 'K8s', exact: true }).click();
    await page.waitForTimeout(3000);
    test.info().annotations.push({ type: 'k8s', description: clean(await f.locator('body').innerText()).match(/\d+ Nodes \/ \d+ Clusters[^.]*/)?.[0] ?? '' });
    await f.getByRole('button', { name: 'Node', exact: true }).click();
  });
  await test.step('5. λ°μ΄ν„°μ†ŒμŠ€ API ν† κΈ€ β†’ csp/node 쑰회', async () => {
    await f.getByRole('button', { name: 'API', exact: true }).click();
    await expect.poll(() => csp.length, { timeout: 60_000 }).toBeGreaterThan(0);
    test.info().annotations.push({ type: 'csp', description: `csp/node 쑰회 ${csp.length}건: ${csp.slice(0, 3).map((c) => c.url.split('?')[0]).join(', ')}` });
  });
});

IV7_S22

tests/iv7/IV7_S22.spec.ts Β· 33쀄 Β· ν•΄μ‹œ 40bcbd88 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S22: TC-OBS-022 K8s Cluster λ…Έλ“œ λ©”νŠΈλ¦­ [정상, AWS]
// v0.6 TC-ID: TC-OBS-METRIC-03 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-METRIC-03-k8s-node-metrics.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Monitoring β†’ K8s νƒ­ β†’ ν΄λŸ¬μŠ€ν„° β†’ NodeGroup β†’ Node λŒ€μ‹œλ³΄λ“œ(/embed/monitoring/{ns}/k8s/{connection}/{cluster}/{nodeGroup}/{n})
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/{K8s κ°€ μžˆλŠ” Project}. mc-infra-manager 둜 K8s ν΄λŸ¬μŠ€ν„° 생성, CSP Credential 유효
// 절차: 1.K8s νƒ­(λ°μ΄ν„°μ†ŒμŠ€ API) 2.ν΄λŸ¬μŠ€ν„° λͺ©λ‘(GET /tumblebug/ns/{ns}/k8sCluster, /api/o11y/monitoring/csp/clusters?ConnectionName=…) 3.Cluster β†’ NodeGroup β†’ Node 4.λ…Έλ“œ λ©”νŠΈλ¦­(GET /api/o11y/monitoring/csp/cluster/{cluster}/{ng}/{n}/{metricType}) 5.차트
// κΈ°λŒ€κ²°κ³Ό: SpiderMonitoringInfo.Data, ν΄λŸ¬μŠ€ν„° λ…Έλ“œ λ©”νŠΈλ¦­ 차트
// λΉ„κ³ : ν΄λŸ¬μŠ€ν„°κ°€ μ—†μœΌλ©΄ SKIP(μ„ ν–‰ λ―ΈμΆ©μ‘±). λŒ€μƒ Project λŠ” OBS_K8S_NS(κΈ°λ³Έ OBS_NS)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_NS, clean } from '../../lib/iv7-obs';

const NS = process.env.OBS_K8S_NS ?? OBS_NS;
test('IV7_S22_AWS TC-OBS-022 K8s Cluster λ…Έλ“œ λ©”νŠΈλ¦­', async ({ page }) => {
  const clusters = collectResponses(page, /\/k8sCluster$|\/csp\/clusters\?/);
  const metrics = collectResponses(page, /\/api\/o11y\/monitoring\/csp\/cluster\//);
  await loginAsAdmin(page);
  const f = await openObs(page, 'monitoring', NS);
  await f.getByRole('button', { name: 'K8s', exact: true }).click();
  await page.waitForTimeout(6000);
  const body = clean(await f.locator('body').innerText());
  const tb = clusters.find((c) => /k8sCluster$/.test(c.url))?.json?.K8sClusterInfo ?? [];
  test.info().annotations.push({ type: 'k8s', description: `${body.match(/\d+ Nodes \/ \d+ Clusters[^.]*/)?.[0] ?? ''} / tumblebug ${tb.length}건 / csp ${clusters.filter((c) => /csp\/clusters/.test(c.url)).map((c) => (c.json ?? []).length).join(',')}` });
  test.skip(!tb.length, `NS ${NS} 에 K8s ν΄λŸ¬μŠ€ν„° μ—†μŒ(μ„ ν–‰ λ―ΈμΆ©μ‘±)`);
  // λ°μ΄ν„°μ†ŒμŠ€ 기본값은 Agent(λ…Έλ“œ μ—μ΄μ „νŠΈ μˆ˜μ§‘κ°’) β€” CSP λ…Έλ“œ λ©”νŠΈλ¦­μ€ 'API' ν† κΈ€μ—μ„œ μ‘°νšŒλœλ‹€(2026-10-15 p1/k1s 확인: Agent λŠ” '0/1 node(s) reporting', API λŠ” csp/cluster/{cspId}/{ng}/{n}/{metric} 7μ’…)
  await test.step('3~5. λ°μ΄ν„°μ†ŒμŠ€ API β†’ ν΄λŸ¬μŠ€ν„° NodeGroup λ…Έλ“œ λ©”νŠΈλ¦­', async () => {
    await f.getByRole('button', { name: 'API', exact: true }).click();
    await expect.poll(() => metrics.length, { timeout: 60_000 }).toBeGreaterThan(0);
    const m = metrics.find((x) => (x.json?.timestampValues ?? []).length > 0);
    test.info().annotations.push({ type: 'metric', description: metrics.map((x) => `${x.url.split('?')[0]} ${(x.json?.timestampValues ?? []).length}`).join(', ') });
    expect(m, '값이 μžˆλŠ” ν΄λŸ¬μŠ€ν„° λ…Έλ“œ λ©”νŠΈλ¦­').toBeTruthy();
  });
});

IV7_S23

tests/iv7/IV7_S23.spec.ts Β· 91쀄 Β· ν•΄μ‹œ 6c91f225 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S23: TC-OBS-023 Anomaly Detection μ„€μ • [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-INSIGHT-01
// ν™”λ©΄: Monitorings > Insight β†’ Anomaly Detection β†’ + New Setting
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. μˆ˜μ§‘ 데이터(TC-OBS-AGENT-04), insight μ—”μ§„ 기동
// 절차: 1.Anomaly Detection νƒ­ 2.μ˜΅μ…˜Β·μΈ‘μ •ν•­λͺ©(GET …/anomaly-detection/options, /measurement) 3.μΈ‘μ •ν•­λͺ© mem Β· λŒ€μƒ node Β· μ£ΌκΈ° 5m β†’ 생성(POST …/settings) 4.μ„€μ • λͺ©λ‘ 반영 5.(정리) μ‚­μ œ(DELETE …/settings/{seq})
// κΈ°λŒ€κ²°κ³Ό: μƒμ„±λœ μ„€μ •(settingSeq), μ„€μ • λͺ©λ‘ ν‘œμ‹œ
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰.
// 2026-10-16 μˆ˜μ •(첫 μ‹€ν–‰, rf-obs2): μΆ”μ • μ…€λ ‰ν„° ꡐ체 β€” '+ New Setting' 폼의 select λŠ” <label>(Scope β€” Infra / Cluster *, Scope β€” Node *, Measurement *, Execution Interval *) λ‹€μŒ select.
//   Node select λŠ” Infra 선택 μ „ disabled. λͺ©λ‘ ν–‰ 'default/{infra}/{node} | mem | 5m | Last Run | Delete'. λŒ€μƒ node λŠ” OBS_TARGET_NODE(κΈ°λ³Έ OBS_NODE) β€”
//   IV7_S24(이λ ₯ 쑰회)κ°€ μ“°λŠ” κΈ°μ‘΄ μ„€μ •(mc1/g1-1 cpu)κ³Ό κ²ΉμΉ˜μ§€ μ•Šκ²Œ λ‹€λ₯Έ node λ₯Ό μ§€μ •ν•œλ‹€. μ‹€νŒ¨ν•΄λ„ finally μ—μ„œ λ§Œλ“  μ„€μ • μ‚­μ œ
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, clean, OBS_NS, OBS_INFRA, OBS_NODE } from '../../lib/iv7-obs';

const NODE = process.env.OBS_TARGET_NODE ?? OBS_NODE;
test('IV7_S23 TC-OBS-023 Anomaly Detection μ„€μ •', async ({ page }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const api = collectResponses(page, /\/anomaly-detection\/(settings|options|measurement)/);
  await loginAsAdmin(page);
  let f0 = await openObs(page, 'insight');
  // iframe 이 ν”„λ‘œμ νŠΈ 선택 λ’€ λ‹€μ‹œ λ‘œλ“œλ˜μ–΄ Frame 이 detach λ˜λŠ” κ²½μš°κ°€ μžˆμ–΄(2026-10-16 2μ°¨ μ‹€ν–‰) 맀번 ν˜„μž¬ insight frame 을 μ°ΎλŠ”λ‹€
  const cur = () => page.frames().filter((x) => !x.isDetached() && x.url().includes(':18081') && x.url().includes('/embed/insight')).at(-1) ?? f0;
  const sel = (label: string) => cur().locator(`xpath=//label[starts-with(normalize-space(),"${label}")]/following::select[1]`).first();
  const rowOf = () => cur().locator('tr').filter({ hasText: `${OBS_NS}/${OBS_INFRA}/${NODE}` }).filter({ has: cur().locator('td', { hasText: /^\s*mem\s*$/ }) }).filter({ has: cur().locator('td', { hasText: /^\s*5m\s*$/ }) }); // μ…€ λ‹¨μœ„λ‘œ 비ꡐ(textContent λŠ” μ…€ 사이 곡백이 μ—†μ–΄ \b μ •κ·œμ‹μ΄ μ•ˆ 맞음)
  const settingsList = () => (api.filter((a) => a.method === 'GET' && /settings$/.test(a.url)).at(-1)?.json?.data ?? []) as any[];
  let created = false;
  try {
    await test.step('1~2. Anomaly Detection νƒ­ Β· μ˜΅μ…˜/μΈ‘μ •ν•­λͺ©', async () => {
      await cur().getByRole('button', { name: 'Anomaly Detection', exact: true }).click();
      await expect.poll(() => api.filter((a) => /options|measurement/.test(a.url)).length, { timeout: 30_000 }).toBeGreaterThan(1);
      await expect.poll(() => api.filter((a) => a.method === 'GET' && /settings$/.test(a.url)).length, { timeout: 30_000 }).toBeGreaterThan(0);
      for (const a of api.filter((x) => /options|measurement/.test(x.url))) test.info().annotations.push({ type: 'api', description: `${a.status} ${a.url} ${JSON.stringify(a.json?.data ?? '').slice(0, 300)}` });
      test.info().annotations.push({ type: 'settings-before', description: settingsList().map((s) => `${s.seq}:${s.ns_id}/${s.infra_id}/${s.node_id} ${s.measurement} ${s.execution_interval}`).join(', ') });
      // 이전 싀행이 남긴 같은 μ„€μ •(이 spec μ „μš© λŒ€μƒ)은 λ¨Όμ € μ§€μš΄λ‹€ β€” ν‘œκ°€ 응닡보닀 늦게 κ·Έλ €μ§€λ―€λ‘œ API λͺ©λ‘μœΌλ‘œ νŒλ‹¨ ν›„ 행을 κΈ°λ‹€λ¦°λ‹€
      if (settingsList().some((s) => s.infra_id === OBS_INFRA && s.node_id === NODE && s.measurement === 'mem')) {
        test.info().annotations.push({ type: 'frames', description: page.frames().map((x) => x.url().replace(/^https?:\/\/[^/]+/, '')).join(' , ') + ' / rows: ' + (await cur().locator('tr').allInnerTexts()).map(clean).join(' || ') });
        await expect(rowOf().first()).toBeVisible({ timeout: 30_000 });
        await rowOf().first().getByRole('button', { name: 'Delete' }).click();
        await expect(rowOf()).toHaveCount(0, { timeout: 30_000 });
        test.info().annotations.push({ type: 'precondition', description: `이전 싀행이 남긴 ${OBS_INFRA}/${NODE} mem 5m μ„€μ • μ‚­μ œ` });
      }
    });
    await test.step('3. + New Setting β†’ mem Β· node Β· 5m β†’ Create', async () => {
      await cur().getByRole('button', { name: '+ New Setting' }).click();
      await sel('Scope β€” Infra').selectOption(OBS_INFRA);
      await expect(sel('Scope β€” Node')).toBeEnabled({ timeout: 30_000 });
      await expect.poll(async () => (await sel('Scope β€” Node').locator('option').allInnerTexts()).join('|'), { timeout: 30_000 }).toContain(NODE);
      const opt = (await sel('Scope β€” Node').locator('option').allInnerTexts()).map((s) => s.trim()).find((s) => s === NODE || s.startsWith(`${NODE} `))!;
      await sel('Scope β€” Node').selectOption({ label: opt });
      await sel('Measurement').selectOption('mem');
      await sel('Execution Interval').selectOption('5m');
      await cur().getByRole('button', { name: 'Create', exact: true }).click();
      await expect.poll(() => api.find((a) => a.method === 'POST')?.status ?? 0, { message: 'μ„€μ • 생성 POST', timeout: 30_000 }).toBeGreaterThan(0);
      const p = api.find((a) => a.method === 'POST')!;
      created = p.status < 300;
      test.info().annotations.push({ type: 'create', description: `${p.status} POST ${p.url} ${JSON.stringify(p.json ?? '').slice(0, 300)}` });
      expect(p.status, '생성 2xx').toBeLessThan(300);
    });
    await test.step('4. μ„€μ • λͺ©λ‘ 반영', async () => {
      if (!(await rowOf().first().isVisible({ timeout: 15_000 }).catch(() => false))) {
        // 2026-10-16 1μ°¨ μ‹€ν–‰: 생성 200('Target Registered Successfully') ν›„ λͺ©λ‘μ΄ λ°”λ‘œ κ°±μ‹ λ˜μ§€ μ•ŠμŒ β†’ 화면을 λ‹€μ‹œ μ—΄μ–΄ 확인
        test.info().annotations.push({ type: 'list-refresh', description: `생성 ν›„ 15초 μ•ˆμ— λͺ©λ‘ 미반영(ν™”λ©΄: ${clean(await cur().locator('table').first().innerText().catch(() => '')).slice(0, 200)}) β†’ ν™”λ©΄ λ‹€μ‹œ μ—΄κΈ°` });
        f0 = await openObs(page, 'insight');
      }
      await expect(rowOf().first()).toBeVisible({ timeout: 30_000 });
      test.info().annotations.push({ type: 'row', description: clean(await rowOf().first().innerText()) });
      const s = settingsList().find((x) => x.infra_id === OBS_INFRA && x.node_id === NODE && x.measurement === 'mem');
      test.info().annotations.push({ type: 'setting', description: JSON.stringify(s ?? null) });
      expect(s?.seq, 'settingSeq').toBeTruthy();
    });
    await test.step('5. (정리) μ‚­μ œ', async () => {
      const seq = settingsList().find((x) => x.infra_id === OBS_INFRA && x.node_id === NODE && x.measurement === 'mem')?.seq;
      await rowOf().first().getByRole('button', { name: 'Delete' }).click();
      const mine = () => api.find((a) => a.method === 'DELETE' && a.url.endsWith(`/settings/${seq}`));
      await expect.poll(() => mine()?.status ?? 0, { message: `μ„€μ • #${seq} μ‚­μ œ DELETE`, timeout: 30_000 }).toBeGreaterThan(0);
      const d = mine()!;
      test.info().annotations.push({ type: 'delete', description: `${d.status} DELETE ${d.url}` });
      expect(d.status).toBeLessThan(300);
      await expect(rowOf()).toHaveCount(0, { timeout: 30_000 });
      created = false;
    });
  } finally {
    if (created && !(await rowOf().first().isVisible().catch(() => false))) f0 = await openObs(page, 'insight').catch(() => f0);
    if (created && await rowOf().first().isVisible().catch(() => false)) { // μ‹€νŒ¨ μ‹œ 정리
      await rowOf().first().getByRole('button', { name: 'Delete' }).click().catch(() => {});
      await page.waitForTimeout(5000);
      test.info().annotations.push({ type: 'cleanup', description: `finally μ‚­μ œ μ‹œλ„ β†’ 남은 ν–‰ ${await rowOf().count()}` });
    }
  }
});

IV7_S24

tests/iv7/IV7_S24.spec.ts Β· 38쀄 Β· ν•΄μ‹œ eb5eb930 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S24: TC-OBS-024 Anomaly History 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-INSIGHT-02 β€” κΈ°μ‘΄ spec(mcmp-e2e deploy/scenarios/shared/step-handlers/obs/insight.ts runObsInsight02)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Insight β†’ Anomaly Detection νƒ­ β†’ Anomaly Detection History
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. Anomaly Detection μ„€μ • 쑴재(μ„€μ • ν›„ 데이터 λˆ„μ ) β€” 2026-10-14 κΈ°μ‘΄ μ„€μ • default/mc1/g1-1 cpu 30m
// 절차: 1.Insight μ§„μž… 2.History μ—μ„œ Infra Β· Node Β· Measurement 선택 3.Load History 4.이λ ₯ 쑰회(GET /api/o11y/insight/anomaly-detection/ns/{ns}/infra/{infra}/node/{node}/history?measurement=…) 5.anomaly_score 차트
// κΈ°λŒ€κ²°κ³Ό: {values:[{timestamp, anomaly_score}]}, μ΄μƒμΉ˜ 점수 차트
// λΉ„κ³ : 섀정을 μƒˆλ‘œ λ§Œλ“€μ§€ μ•Šκ³  κΈ°μ‘΄ μ„€μ •μ˜ 이λ ₯을 μ½λŠ”λ‹€(읽기 μ „μš©)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, selectNear, OBS_INFRA, OBS_NODE, clean } from '../../lib/iv7-obs';

test('IV7_S24 TC-OBS-024 Anomaly History 쑰회', async ({ page }) => {
  const settings = collectResponses(page, /\/anomaly-detection\/settings$/);
  const hist = collectResponses(page, /\/anomaly-detection\/ns\/.+\/history/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'insight');
  await expect.poll(() => settings.length, { timeout: 30_000 }).toBeGreaterThan(0);
  const s = (settings.at(-1)!.json?.data ?? []).find((x: any) => x.infra_id === OBS_INFRA && x.node_id === OBS_NODE);
  test.info().annotations.push({ type: 'setting', description: JSON.stringify(s ?? null) });
  test.skip(!s, `${OBS_INFRA}/${OBS_NODE} Anomaly Detection μ„€μ • μ—†μŒ(μ„ ν–‰ TC-OBS-023)`);
  await test.step(`2~4. ${OBS_INFRA} / ${OBS_NODE} / ${s.measurement} β†’ Load History`, async () => {
    await f.locator('select').filter({ has: f.locator('option', { hasText: 'Select Infra / Cluster' }) }).first().selectOption({ label: OBS_INFRA });
    await page.waitForTimeout(2000);
    await f.locator('select').filter({ has: f.locator('option', { hasText: 'Select Node' }) }).first().selectOption({ label: OBS_NODE });
    await f.locator('select').filter({ has: f.locator('option', { hasText: 'Select Measurement' }) }).first().selectOption({ label: s.measurement });
    await f.getByRole('button', { name: 'Load History' }).click();
    await expect.poll(() => hist.length, { timeout: 30_000 }).toBeGreaterThan(0);
  });
  await test.step('5. anomaly_score κ°’ Β· 차트', async () => {
    const vals = (hist.at(-1)!.json?.data?.values ?? hist.at(-1)!.json?.values ?? []).filter((v: any) => v.anomaly_score !== null && !Number.isNaN(Number(v.anomaly_score)));
    test.info().annotations.push({ type: 'history', description: `${hist.at(-1)!.status} ${hist.at(-1)!.url.slice(0, 160)} / κ°’ ${vals.length}개 예: ${JSON.stringify(vals.slice(0, 2))}` });
    expect(hist.at(-1)!.status).toBe(200);
    expect(vals.length, 'anomaly_score κ°’').toBeGreaterThan(0);
    await expect(f.locator('svg:visible, canvas:visible').first()).toBeVisible();
    test.info().annotations.push({ type: 'screen', description: clean(await f.locator('body').innerText()).match(/Anomaly Detection History.{0,300}/)?.[0] ?? '' });
  });
});

IV7_S25

tests/iv7/IV7_S25.spec.ts Β· 63쀄 Β· ν•΄μ‹œ 16450ba8 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S25: TC-OBS-025 Prediction (예츑) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-INSIGHT-03
// ν™”λ©΄: Monitorings > Insight β†’ Prediction νƒ­
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. μˆ˜μ§‘ 데이터, insight μ—”μ§„ 기동
// 절차: 1.Prediction νƒ­ 2.μ˜΅μ…˜(GET …/predictions/options) 3.InfraΒ·NodeΒ·μΈ‘μ •ν•­λͺ© cpu 선택 β†’ 예츑 μ‹€ν–‰(POST …/predictions/ns/{ns}/infra/{infra}/node/{node}) 4.예츑 이λ ₯(GET …/history) 5.predicted_value 차트(κ³Όκ±°+미래, CPU λŠ” 100-usage_idle)
// κΈ°λŒ€κ²°κ³Ό: {values:[{timestamp, predicted_value}]}, 예츑 차트
// μ‹€ν–‰ 보λ₯˜(μ‚¬μš©μž κ²°μ • 2026-10-14: μƒνƒœλ₯Ό λ°”κΎΈλŠ” OBS TC λŠ” spec 만 μž‘μ„±). OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰.
// 2026-10-16 μˆ˜μ •(첫 μ‹€ν–‰, rf-obs2): μΆ”μ • μ…€λ ‰ν„° ꡐ체 β€” Prediction νƒ­ 폼은 <label>(Scope β€” Infra / Cluster *, Scope β€” Node *, Measurement *, Prediction Range *) λ‹€μŒ select,
//   λ²„νŠΌ 'Run Prediction' / 'Load Saved'. ν™”λ©΄ μ•ˆλ‚΄: 'Predictions become available about one day after the monitoring agent is installed'. 예츑 λ²”μœ„λŠ” OBS_PRED_RANGE(κΈ°λ³Έ '1 day').
//   예츑 POST 응닡·이λ ₯ 응닡을 λͺ¨λ‘ 기둝(였λ₯˜ 응닡이면 κ·Έ λ‚΄μš©μœΌλ‘œ νŒμ •). μ‹€μ œ 응닡 κ°’ ν•„λ“œλŠ” 'value'(μ‹œνŠΈ κΈ°λŒ€ 'predicted_value') β€” λ‘˜ λ‹€ ν—ˆμš©
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, clean, OBS_INFRA, OBS_NODE } from '../../lib/iv7-obs';

const NODE = process.env.OBS_TARGET_NODE ?? OBS_NODE;
test('IV7_S25 TC-OBS-025 Prediction (예츑)', async ({ page }) => {
  test.skip(process.env.OBS_WRITE !== '1', 'μƒνƒœ λ³€κ²½ TC β€” OBS_WRITE=1 일 λ•Œλ§Œ μ‹€ν–‰(μ‚¬μš©μž κ²°μ • 2026-10-14)');
  const api = collectResponses(page, /\/insight\/predictions/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'insight');
  const sel = (label: string) => f.locator(`xpath=//label[starts-with(normalize-space(),"${label}")]/following::select[1]`).first();
  await test.step('1~2. Prediction νƒ­ Β· μ˜΅μ…˜', async () => {
    await f.getByRole('button', { name: 'Prediction', exact: true }).click();
    await expect.poll(() => api.filter((a) => /options/.test(a.url)).length, { timeout: 30_000 }).toBeGreaterThan(0);
    const o = api.find((a) => /options/.test(a.url))!;
    test.info().annotations.push({ type: 'options', description: `${o.status} ${JSON.stringify(o.json?.data ?? '').slice(0, 300)}` });
    test.info().annotations.push({ type: 'notice', description: clean(await f.locator('body').innerText()).match(/Predictions become available[^.]*\./)?.[0] ?? '' });
  });
  await test.step('3. InfraΒ·NodeΒ·cpu 선택 β†’ Run Prediction', async () => {
    await sel('Scope β€” Infra').selectOption(OBS_INFRA);
    await expect(sel('Scope β€” Node')).toBeEnabled({ timeout: 30_000 });
    await expect.poll(async () => (await sel('Scope β€” Node').locator('option').allInnerTexts()).join('|'), { timeout: 30_000 }).toContain(NODE);
    const opt = (await sel('Scope β€” Node').locator('option').allInnerTexts()).map((s) => s.trim()).find((s) => s === NODE || s.startsWith(`${NODE} `))!;
    await sel('Scope β€” Node').selectOption({ label: opt });
    await sel('Measurement').selectOption('cpu');
    await sel('Prediction Range').selectOption({ label: process.env.OBS_PRED_RANGE ?? '1 day' });
    await f.getByRole('button', { name: 'Run Prediction' }).click();
    await expect.poll(() => api.find((a) => a.method === 'POST')?.status ?? 0, { message: '예츑 POST', timeout: 180_000 }).toBeGreaterThan(0);
    const p = api.find((a) => a.method === 'POST')!;
    test.info().annotations.push({ type: 'predict', description: `${p.status} POST ${p.url} ${JSON.stringify(p.json ?? '').slice(0, 600)}` });
    await page.waitForTimeout(5000);
    test.info().annotations.push({ type: 'screen', description: clean(await f.locator('body').innerText()).slice(0, 800) });
    expect(p.status, '예츑 μ‹€ν–‰ 2xx').toBeLessThan(300);
  });
  await test.step('4~5. 예츑 κ°’ Β· 차트', async () => {
    const p = api.find((a) => a.method === 'POST')!;
    let vals: any[] = p.json?.data?.values ?? [];
    if (!vals.length) { // 응닡에 값이 μ—†μœΌλ©΄ μ €μž₯된 예츑(이λ ₯) 쑰회
      await f.getByRole('button', { name: 'Load Saved' }).click();
      await expect.poll(() => api.filter((a) => /history/.test(a.url)).length, { timeout: 60_000 }).toBeGreaterThan(0);
      const h = api.filter((a) => /history/.test(a.url)).at(-1)!;
      test.info().annotations.push({ type: 'history', description: `${h.status} GET ${h.url} ${JSON.stringify(h.json ?? '').slice(0, 600)}` });
      vals = h.json?.data?.values ?? [];
    }
    test.info().annotations.push({ type: 'values', description: `${vals.length}건 ${JSON.stringify(vals.slice(0, 3))}` });
    // 2026-10-16: μ‹€μ œ 응닡 ν•„λ“œλŠ” predicted_value κ°€ μ•„λ‹ˆλΌ value({timestamp, value}) β€” λ‘˜ λ‹€ ν—ˆμš©ν•˜κ³  μ°¨μ΄λŠ” 비고에 기둝
    const pv = (v: any) => v.predicted_value ?? v.value;
    test.info().annotations.push({ type: 'field', description: `κ°’ ν•„λ“œ: ${Object.keys(vals[0] ?? {}).join(',')}` });
    expect(vals.filter((v) => pv(v) !== null && pv(v) !== undefined && !Number.isNaN(Number(pv(v)))).length, '예츑 κ°’(predicted_value/value)').toBeGreaterThan(0);
    await expect(f.locator('svg:visible, canvas:visible').first()).toBeVisible();
  });
});

IV7_S26

tests/iv7/IV7_S26.spec.ts Β· 28쀄 Β· ν•΄μ‹œ 0a66fffe Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S26: TC-OBS-026 Loki 라벨 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-LOG-01 β€” κΈ°μ‘΄ spec(mcmp-e2e deploy/scenarios/shared/step-handlers/obs/log.ts runObsLog01)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Logs (iframe /embed/logs/{ns}) β€” Workload(Infra/Cluster)Β·Server μ…€λ ‰ν„°
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. Loki/fluent-bit κΈ°λ™Β·λ‘œκ·Έ μˆ˜μ§‘(λŒ€μƒ node Log Agent SUCCESS)
// 절차: 1.Logs μ§„μž… 2.라벨 ν‚€Β·κ°’(GET /api/o11y/log/labels, /labels/{label}/values β€” 화면이 λΆ€λ₯΄λŠ” 경우) 3.Workload μ—μ„œ λŒ€μƒ Infra 선택 β†’ Server 셀렉터에 ν•΄λ‹Ή Infra 의 node 반영
// κΈ°λŒ€κ²°κ³Ό: {labels:[NS_ID, INFRA_ID, NODE_ID, level, service ...]}, 셀렉터에 라벨/κ°’ 반영
// λΉ„κ³ : 2026-10-14 v0.7 화면은 Workload λͺ©λ‘μ„ Tumblebug Infra λͺ©λ‘μœΌλ‘œ μ±„μš°κ³  labels API λ₯Ό λΆ€λ₯΄μ§€ μ•Šμ„ 수 μžˆλ‹€ β†’ 호좜 μ—¬λΆ€λŠ” κΈ°λ‘ν•˜κ³ , μ…€λ ‰ν„° κ°’ 반영으둜 νŒμ •
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_INFRA, clean } from '../../lib/iv7-obs';

test('IV7_S26 TC-OBS-026 Loki 라벨 쑰회', async ({ page }) => {
  const labels = collectResponses(page, /\/api\/o11y\/log\/labels/);
  const infra = collectResponses(page, new RegExp(`/tumblebug/ns/[^/]+/infra/${OBS_INFRA}$`));
  await loginAsAdmin(page);
  const f = await openObs(page, 'logs');
  await test.step(`1~3. Workload ${OBS_INFRA} β†’ Server μ…€λ ‰ν„°`, async () => {
    await f.locator('select').first().selectOption({ label: OBS_INFRA });
    await page.waitForTimeout(3000);
    const servers = (await f.locator('select').nth(1).locator('option').allInnerTexts()).map(clean).filter((o) => o !== 'All');
    const nodes = (infra.at(-1)?.json?.node ?? []).map((n: any) => n.id);
    test.info().annotations.push({ type: 'servers', description: `ν™”λ©΄ ${servers.join(', ')} / Infra node ${nodes.join(', ')}` });
    test.info().annotations.push({ type: 'labels-api', description: labels.map((l) => `${l.status} ${l.url} ${JSON.stringify(l.json?.data ?? l.json).slice(0, 200)}`).join(' | ') || '(호좜 μ—†μŒ)' });
    expect(servers.length).toBeGreaterThan(0);
    if (nodes.length) expect(servers.sort()).toEqual([...nodes].sort());
  });
});

IV7_S27

tests/iv7/IV7_S27.spec.ts Β· 50쀄 Β· ν•΄μ‹œ 255e0125 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S27: TC-OBS-027 Loki LogQL ν‚€μ›Œλ“œ 검색 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-LOG-02 β€” κΈ°μ‘΄ spec(mcmp-e2e deploy/scenarios/shared/step-handlers/obs/log.ts runObsLog02)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Logs β†’ Workload Β· Server Β· Keyword β†’ Search β†’ List of Log
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. TC-OBS-026(IV7_S26)
// 절차: 1.Workload(OBS_INFRA)Β·Server All 선택 ν›„ Keyword μž…λ ₯ β†’ Search 2.LogQL {NS_ID="…",INFRA_ID="…"} |~ "(?i)keyword" 3.λ²”μœ„ 쑰회(GET /api/o11y/log/query_range?query=…) 4.둜그 ν‘œ(Timestamp/Level/Node/Service/Message, μ‹œκ°„μ€ 둜컬)
// μž…λ ₯κ°’(μ‹œνŠΈ): Keyword error (κ²°κ³Όκ°€ μ—†μœΌλ©΄ ν‚€μ›Œλ“œ 없이 검색해 ν‘œΒ·μ‹œκ°„ ν‘œμ‹œλ₯Ό 확인)
// κΈ°λŒ€κ²°κ³Ό: LogSummaryDto.ResultDto(data[], stats), 둜그 ν…Œμ΄λΈ”(μ‹œκ°„μ€ 둜컬)
// λΉ„κ³ : 2026-10-14 κ΄€μ°° β€” ν‘œ Timestamp 연도가 2001 둜 ν‘œμ‹œλ¨(응닡 μ‹œκ°κ³Ό 비ꡐ해 νŒμ •)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, OBS_INFRA, clean } from '../../lib/iv7-obs';

test('IV7_S27 TC-OBS-027 Loki LogQL ν‚€μ›Œλ“œ 검색', async ({ page }) => {
  const q = collectResponses(page, /\/api\/o11y\/log\/query_range/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'logs');
  await f.locator('select').first().selectOption({ label: OBS_INFRA });
  await page.waitForTimeout(2000);
  const search = async (kw: string) => {
    const n0 = q.length;
    const box = f.getByPlaceholder(/keyword/i).first().or(f.locator('input[type="text"], input[type="search"]').first());
    await box.fill(kw);
    await f.getByRole('button', { name: 'Search', exact: true }).click();
    await expect.poll(() => q.length, { timeout: 30_000 }).toBeGreaterThan(n0);
    await page.waitForTimeout(2000);
    return q.at(-1)!;
  };
  let r = await test.step('1~3. Keyword error β†’ Search β†’ LogQL query_range', () => search('error'));
  const query = decodeURIComponent(r.url.match(/query=([^&]+)/)?.[1] ?? '').replace(/\+/g, ' ');
  test.info().annotations.push({ type: 'logql', description: `${query} / 결과 ${(r.json?.data?.data ?? []).length}건` });
  expect(query).toContain(`INFRA_ID="${OBS_INFRA}"`);
  expect(query).toMatch(/\|~ "\(\?i\)error"/);
  if (!(r.json?.data?.data ?? []).length) {
    r = await test.step('(error κ²°κ³Ό μ—†μŒ) ν‚€μ›Œλ“œ 없이 검색 β†’ ν‘œ 확인', () => search(''));
  }
  await test.step('4. 둜그 ν‘œ Β· Timestamp(둜컬)', async () => {
    const rows: any[] = r.json?.data?.data ?? [];
    test.info().annotations.push({ type: 'rows', description: `${rows.length}건 예: ${JSON.stringify(rows[0] ?? {}).slice(0, 300)}` });
    expect(rows.length).toBeGreaterThan(0);
    const body = clean(await f.locator('body').innerText());
    for (const h of ['Timestamp', 'Level', 'Node', 'Service', 'Message']) expect(body).toContain(h);
    const shown = body.match(/List of Log.*?Message (\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})/);
    const first = rows[0] ?? {};
    const apiTs = first.timestamp ?? first.time ?? first.ts ?? first.values?.[0]?.[0];
    const apiDate = apiTs ? new Date(/^\d{16,19}$/.test(String(apiTs)) ? Number(String(apiTs).slice(0, 13)) : apiTs) : undefined;
    test.info().annotations.push({ type: 'timestamp', description: `ν™”λ©΄ 첫 ν–‰ ${shown ? shown.slice(1, 5).join('-') : '(μ—†μŒ)'} / 응닡 첫 ν–‰ ${apiTs} β†’ ${apiDate?.toISOString?.() ?? '?'}` });
    expect(Number(shown?.[1]), 'ν‘œμ‹œ 연도 = 응닡 μ‹œκ°μ˜ 연도').toBe(apiDate ? apiDate.getFullYear() : new Date().getFullYear());
  });
});

IV7_S28

tests/iv7/IV7_S28.spec.ts Β· 44쀄 Β· ν•΄μ‹œ ee5cfc00 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S28: TC-OBS-028 manager trace 쑰회 (Tempo) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRACE-01 β€” κΈ°μ‘΄ spec(mcmp-e2e deploy/scenarios/shared/step-handlers/obs/trace.ts runObsTrace01)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Tracing (iframe /embed/trace/{ns}) β€” Scope Framework / VM, Service, Keyword, Range β†’ Search β†’ List of Trace
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. Tempo κΈ°λ™Β·νŠΈλ ˆμ΄μŠ€ μˆ˜μ§‘(Framework λŠ” o11y manager/insight 자체 계츑)
// 절차: 1.Tracing μ§„μž… 2.Scope Framework 3.Service λͺ©λ‘(GET /api/o11y/trace/services?scope=framework) Β· Service mc-observability-manager Β· Range 1H β†’ Search 4.트레이슀 검색(GET /api/o11y/trace/search?scope=…&service=…) 5.List of Trace ν‘œ(Start Time/Root Service/Root Name/Duration/Trace ID)
// κΈ°λŒ€κ²°κ³Ό: TraceSummary[](traceId, rootService, rootName, startTimeMs, durationMs), List of Trace ν‘œΒ·νŽ˜μ΄μ €
// λΉ„κ³ : VM scope λŠ” λŒ€μƒ VM 에 Beyla/OTel(trace agent) 이 μžˆμ–΄μ•Ό 함(2026-10-14 μ „ node trace_agent NOT_INSTALLED) β†’ Framework 둜 νŒμ •, VM 은 기둝
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, clean } from '../../lib/iv7-obs';

test('IV7_S28 TC-OBS-028 manager trace 쑰회 (Tempo)', async ({ page }) => {
  const svc = collectResponses(page, /\/api\/o11y\/trace\/services/);
  const search = collectResponses(page, /\/api\/o11y\/trace\/search/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'trace');
  await test.step('2~3. Framework Β· Service λͺ©λ‘ Β· mc-observability-manager Β· 1H β†’ Search', async () => {
    await f.getByRole('button', { name: 'Framework', exact: true }).click();
    await expect.poll(() => svc.length, { timeout: 30_000 }).toBeGreaterThan(0);
    test.info().annotations.push({ type: 'services', description: JSON.stringify(svc.at(-1)!.json?.data) });
    await f.locator('select').first().selectOption({ label: 'mc-observability-manager' });
    await f.locator('select').nth(1).selectOption({ label: '1H' });
    await f.getByRole('button', { name: 'Search', exact: true }).click();
    await expect.poll(() => search.length, { timeout: 30_000 }).toBeGreaterThan(0);
  });
  await test.step('4~5. 검색 응닡 Β· List of Trace ν‘œ', async () => {
    const r = search.at(-1)!;
    const list: any[] = r.json?.data ?? [];
    test.info().annotations.push({ type: 'search', description: `${r.url.slice(0, 180)} β†’ ${list.length}건 예: ${JSON.stringify(list[0] ?? {}).slice(0, 250)}` });
    expect(r.url).toMatch(/scope=framework/);
    expect(r.url).toMatch(/service=mc-observability-manager/);
    expect(list.length).toBeGreaterThan(0);
    expect(list.every((t) => t.rootService === 'mc-observability-manager')).toBe(true);
    const body = clean(await f.locator('body').innerText());
    for (const h of ['Start Time', 'Root Service', 'Root Name', 'Duration', 'Trace ID']) expect(body).toContain(h);
    expect(body).toMatch(/List of Trace Β· \d+ traces/);
  });
  await test.step('(기둝) VM scope', async () => {
    await f.getByRole('button', { name: 'VM', exact: true }).click();
    await page.waitForTimeout(3000);
    test.info().annotations.push({ type: 'vm-scope', description: clean(await f.locator('body').innerText()).slice(0, 300) });
  });
});

IV7_S29

tests/iv7/IV7_S29.spec.ts Β· 35쀄 Β· ν•΄μ‹œ 1067e8f8 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S29: TC-OBS-029 Grafana Explore νŠΈλ ˆμ΄μ‹± (트레이슀 상세 Call Sequence) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRACE-02 β€” κΈ°μ‘΄ spec(mcmp-e2e deploy/scenarios/shared/step-handlers/obs/trace.ts runObsTrace02)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: Monitorings > Tracing β†’ List of Trace ν–‰ 클릭 β†’ 같은 ν–‰ μ•„λž˜ API Call Sequence 인라인 펼침
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. TC-OBS-028(IV7_S28) β€” λͺ©λ‘μ—μ„œ 선택
// 절차: 1.Framework 검색 2.λͺ©λ‘ 첫 ν–‰ 클릭 3.단일 트레이슀(GET /api/o11y/trace/{traceId}) β†’ Call Sequence(Span/Service/Kind/Offset/Duration/Timeline) 4.ν–‰ λ‹€μ‹œ 클릭 β†’ μ ‘νž˜
// κΈ°λŒ€κ²°κ³Ό: TraceDetail(spans[]), 인라인 Call Sequence
// λΉ„κ³ : TC 이름은 'Grafana Explore' μ΄λ‚˜ μ‹œνŠΈ μ ˆμ°¨λŠ” o11y ν™”λ©΄μ˜ 인라인 Call Sequence(2026-10-14 μ‹œνŠΈ κΈ°μ€€)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, collectResponses, clean } from '../../lib/iv7-obs';

test('IV7_S29 TC-OBS-029 Grafana Explore νŠΈλ ˆμ΄μ‹± (Call Sequence)', async ({ page }) => {
  const detail = collectResponses(page, /\/api\/o11y\/trace\/[0-9a-f]{16,}$/);
  await loginAsAdmin(page);
  const f = await openObs(page, 'trace');
  await f.getByRole('button', { name: 'Search', exact: true }).click();
  const row = f.locator('tbody tr').first();
  await expect(row).toBeVisible({ timeout: 30_000 });
  const traceIdShort = (clean(await row.innerText()).match(/([0-9a-f]{16})…?$/) ?? [])[1] ?? '';
  await test.step('2~3. 첫 ν–‰ 클릭 β†’ 단일 트레이슀 Β· Call Sequence', async () => {
    await row.click();
    await expect.poll(() => detail.length, { timeout: 30_000 }).toBeGreaterThan(0);
    const d = detail.at(-1)!.json?.data;
    test.info().annotations.push({ type: 'detail', description: `${d?.traceId} spans ${(d?.spans ?? []).length}: ${(d?.spans ?? []).slice(0, 4).map((s: any) => `${s.service}/${s.name}/${s.kind}`).join(' | ')}` });
    expect(d?.traceId?.startsWith(traceIdShort)).toBe(true);
    expect((d?.spans ?? []).length).toBeGreaterThan(0);
    await expect(f.getByText('API Call Sequence').first()).toBeVisible();
    for (const h of ['Span (call order)', 'Service', 'Kind', 'Offset', 'Duration', 'Timeline']) await expect(f.getByText(h, { exact: true }).first()).toBeVisible();
  });
  await test.step('4. λ‹€μ‹œ 클릭 β†’ μ ‘νž˜', async () => {
    await row.click();
    await expect(f.getByText('API Call Sequence')).toHaveCount(0, { timeout: 10_000 });
  });
});

IV7_S30

tests/iv7/IV7_S30.spec.ts Β· 32쀄 Β· ν•΄μ‹œ 211db6a2 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S30: TC-OBS-030 Grafana μž„λ² λ“œ (Trace μž„λ² λ“œ ν™”λ©΄) [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-TRACE-03 β€” κΈ°μ‘΄ spec(mcmp-e2e mc-web-console/specs/obs/TC-OBS-TRACE-03-trace-iframe.spec.ts)은 절차만 μ°Έκ³ (μ‚¬μš©μž κ²°μ • 2026-10-14)
// ν™”λ©΄: mc-web-console Monitorings > Tracing β†’ iframe /embed/trace/{ns}
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. TC-OBS-028, mc-web-console 연동 μ„€μ •
// 절차: 1./embed/trace/{ns} λ‘œλ“œ(μ½˜μ†” iframe) 2.EmbedLayout(λ„€λΉ„/μ‚¬μ΄λ“œλ°” μ—†λŠ” 전체화면) 3.μž„λ² λ“œ ν™”λ©΄μ—μ„œ Scope/Service/검색 Β· Call Sequence λ™μž‘
// κΈ°λŒ€κ²°κ³Ό: λ„€λΉ„ μ—†λŠ” μž„λ² λ“œ Trace ν™”λ©΄, iframe λ‚΄ 검색/상세 정상 λ™μž‘
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, OBS_NS, clean } from '../../lib/iv7-obs';

test('IV7_S30 TC-OBS-030 Grafana μž„λ² λ“œ (Trace μž„λ² λ“œ ν™”λ©΄)', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await openObs(page, 'trace');
  await test.step('1~2. /embed/trace 경둜 Β· μž„λ² λ“œ λ ˆμ΄μ•„μ›ƒ', async () => {
    const src = await page.locator('iframe').first().getAttribute('src');
    test.info().annotations.push({ type: 'iframe', description: `src=${src} / frame=${f.url()} / navΒ·sidebar ${await f.locator('nav, aside, .sidebar').count()}` });
    expect(new URL(f.url()).pathname).toBe(`/embed/trace/${OBS_NS}`);
    expect(await f.locator('nav, aside, .sidebar').count()).toBe(0);
  });
  await test.step('3. Scope μ „ν™˜ Β· 검색 Β· Call Sequence', async () => {
    await f.getByRole('button', { name: 'VM', exact: true }).click();
    await page.waitForTimeout(1500);
    await f.getByRole('button', { name: 'Framework', exact: true }).click();
    await f.getByRole('button', { name: 'Search', exact: true }).click();
    const row = f.locator('tbody tr').first();
    await expect(row).toBeVisible({ timeout: 30_000 });
    await row.click();
    await expect(f.getByText('API Call Sequence').first()).toBeVisible({ timeout: 30_000 });
    test.info().annotations.push({ type: 'screen', description: clean(await f.locator('body').innerText()).match(/List of Trace.{0,250}/)?.[0] ?? '' });
  });
});

IV7_S31

tests/iv7/IV7_S31.spec.ts Β· 25쀄 Β· ν•΄μ‹œ 146ac1b8 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S31: TC-OBS-031 Infra / NS 레벨 ν‚€μ›Œλ“œ 없이 전체 둜그 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-LOG-01 β€” 'ν…ŒμŠ€νŠΈ μ‹œλ‚˜λ¦¬μ˜€' μ‹œνŠΈμ˜ 둜그 TC(μ›λž˜ TC-OBS-026). Framework μ‹œνŠΈ TC-OBS-026(Loki 라벨 쑰회)κ³Ό μ •μ˜κ°€ 달라 TC-OBS-031 둜 별도 λΆ€μ—¬(μ‚¬μš©μž κ²°μ • 2026-10-15)
// ν™”λ©΄: Monitorings > Logs (iframe /embed/logs/{ns}) β†’ Workload(Infra) Β· Server All Β· Keyword 비움 β†’ Search
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ Infra(OBS_INFRA, κΈ°λ³Έ mc1) Log Agent μˆ˜μ§‘
// 절차: 1.Logs μ§„μž… 2.Workload mc1 Β· Server All Β· Keyword 없이 Search 3.LogQL {NS_ID,INFRA_ID} (ν‚€μ›Œλ“œ ν•„ν„° μ—†μŒ) 4.둜그 ν‘œ(Timestamp/Level/Node/Service/Message) Β· μ—¬λŸ¬ node 둜그
// κΈ°λŒ€κ²°κ³Ό: Infra λ²”μœ„μ˜ 전체 λ‘œκ·Έκ°€ ν‚€μ›Œλ“œ ν•„ν„° 없이 ν‘œμ‹œλœλ‹€
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, OBS_NS, OBS_INFRA } from '../../lib/iv7-obs';
import { logSearch } from '../../lib/iv7-obs-logs';

test('IV7_S31 TC-OBS-031 Infra / NS 레벨 ν‚€μ›Œλ“œ 없이 전체 둜그 쑰회', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await openObs(page, 'logs');
  const { query, rows, body } = await logSearch(page, f, OBS_INFRA, '');
  expect(query).toContain(`NS_ID="${OBS_NS}"`);
  expect(query).toContain(`INFRA_ID="${OBS_INFRA}"`);
  expect(query, 'ν‚€μ›Œλ“œ ν•„ν„° μ—†μŒ').not.toMatch(/\|~/);
  expect(rows.length).toBeGreaterThan(0);
  for (const h of ['Timestamp', 'Level', 'Node', 'Service', 'Message']) expect(body).toContain(h);
  const nodes = [...new Set(rows.map((x) => x.labels?.NODE_ID).filter(Boolean))];
  test.info().annotations.push({ type: 'nodes', description: nodes.join(', ') });
  expect(rows.every((x) => x.labels?.INFRA_ID === OBS_INFRA), 'λͺ¨λ‘ λŒ€μƒ Infra 둜그').toBe(true);
});

IV7_S32

tests/iv7/IV7_S32.spec.ts Β· 23쀄 Β· ν•΄μ‹œ 6e53aa33 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S32: TC-OBS-032 Infra / NS 레벨 ν‚€μ›Œλ“œ 기반 둜그 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-LOG-02 β€” 'ν…ŒμŠ€νŠΈ μ‹œλ‚˜λ¦¬μ˜€' μ‹œνŠΈμ˜ 둜그 TC(μ›λž˜ TC-OBS-027). Framework μ‹œνŠΈ TC-OBS-027(Loki LogQL ν‚€μ›Œλ“œ 검색)κ³Ό μ •μ˜κ°€ 달라 TC-OBS-032 둜 별도 λΆ€μ—¬(μ‚¬μš©μž κ²°μ • 2026-10-15)
// ν™”λ©΄: Monitorings > Logs β†’ Workload(Infra) Β· Server All Β· Keyword β†’ Search
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/default. λŒ€μƒ Infra Log Agent μˆ˜μ§‘
// 절차: 1.Workload mc1 Β· Server All Β· Keyword error β†’ Search 2.LogQL |~ "(?i)error" 3.κ²°κ³Ό λ‘œκ·Έκ°€ λͺ¨λ‘ ν‚€μ›Œλ“œλ₯Ό 포함
// κΈ°λŒ€κ²°κ³Ό: Infra λ²”μœ„ 둜그 쀑 ν‚€μ›Œλ“œλ₯Ό ν¬ν•¨ν•œ 둜그만 ν‘œμ‹œλœλ‹€
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, OBS_INFRA } from '../../lib/iv7-obs';
import { logSearch } from '../../lib/iv7-obs-logs';

const KW = process.env.OBS_LOG_KEYWORD ?? 'error';
test('IV7_S32 TC-OBS-032 Infra / NS 레벨 ν‚€μ›Œλ“œ 기반 둜그 쑰회', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await openObs(page, 'logs');
  const { query, rows } = await logSearch(page, f, OBS_INFRA, KW);
  expect(query).toMatch(new RegExp(`\\|~ "\\(\\?i\\)${KW}"`));
  expect(rows.length, `ν‚€μ›Œλ“œ ${KW} κ²°κ³Ό`).toBeGreaterThan(0);
  const miss = rows.filter((x) => !new RegExp(KW, 'i').test(JSON.stringify(x.value ?? x.line ?? x)));
  test.info().annotations.push({ type: 'keyword', description: `${rows.length}건 쀑 ν‚€μ›Œλ“œ 미포함 ${miss.length}건` });
  expect(miss.length, 'λͺ¨λ“  κ²°κ³Όκ°€ ν‚€μ›Œλ“œ 포함').toBe(0);
});

IV7_S33

tests/iv7/IV7_S33.spec.ts Β· 28쀄 Β· ν•΄μ‹œ d211f549 Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S33: TC-OBS-033 K8s Cluster ν‚€μ›Œλ“œ 없이 전체 둜그 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-LOG-03 β€” 'ν…ŒμŠ€νŠΈ μ‹œλ‚˜λ¦¬μ˜€' μ‹œνŠΈμ˜ 둜그 TC. Framework μ‹œνŠΈμ™€ λ²ˆν˜Έκ°€ 겹쳐 TC-OBS-033 으둜 별도 λΆ€μ—¬(μ‚¬μš©μž κ²°μ • 2026-10-15)
// ν™”λ©΄: Monitorings > Logs β†’ Workload μ—μ„œ K8s Cluster 선택 Β· Keyword β†’ Search
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/{K8s Project}. K8s ν΄λŸ¬μŠ€ν„° 쑴재 Β· 둜그 μˆ˜μ§‘(K8s Log Agent, TC-OBS-005)
// 절차: 1.Logs μ§„μž… 2.Workload 에 K8s Cluster(OBS_K8S_CLUSTER) 선택 β†’ Search 3.LogQL Β· 둜그 ν‘œ
// κΈ°λŒ€κ²°κ³Ό: ν΄λŸ¬μŠ€ν„° λ²”μœ„ λ‘œκ·Έκ°€ ν‘œμ‹œλœλ‹€
// λΉ„κ³ : K8s ν΄λŸ¬μŠ€ν„°κ°€ μ—†μœΌλ©΄ SKIP(μ„ ν–‰ λ―ΈμΆ©μ‘±, 2026-10-15 stage ν΄λŸ¬μŠ€ν„° 0개)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, clean } from '../../lib/iv7-obs';
import { logSearch } from '../../lib/iv7-obs-logs';

const NS = process.env.OBS_K8S_NS ?? 'p1';
const CLUSTER = process.env.OBS_K8S_CLUSTER ?? '';
test('IV7_S33 TC-OBS-033 K8s Cluster ν‚€μ›Œλ“œ 없이 전체 둜그 쑰회', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await openObs(page, 'logs', NS);
  const opts = (await f.locator('select').first().locator('option').allInnerTexts()).map(clean).filter((o) => !/^Select/.test(o));
  test.info().annotations.push({ type: 'workloads', description: `${NS}: ${opts.join(', ') || '(μ—†μŒ)'}` });
  const target = CLUSTER || opts[0];
  test.skip(!target, `${NS} 에 K8s ν΄λŸ¬μŠ€ν„° μ—†μŒ(μ„ ν–‰ λ―ΈμΆ©μ‘±)`);
  const { query, rows } = await logSearch(page, f, target, '');
  // K8s ν΄λŸ¬μŠ€ν„°λ„ Infra 와 같은 라벨 체계: {NS_ID, INFRA_ID=<cluster>, NODE_ID=<node>} (2026-10-15 AWS k1s 확인)
  expect(query).toContain(`INFRA_ID="${target}"`);
  expect(rows.length).toBeGreaterThan(0);
  expect(JSON.stringify(rows[0])).toContain(`"INFRA_ID":"${target}"`);
});

IV7_S34

tests/iv7/IV7_S34.spec.ts Β· 36쀄 Β· ν•΄μ‹œ 2b6c55dc Β· μ‹œλ‚˜λ¦¬μ˜€ 보기

// IV7_S34: TC-OBS-034 K8s Cluster ν‚€μ›Œλ“œ 기반 둜그 쑰회 [정상, 곡톡]
// v0.6 TC-ID: TC-OBS-LOG-04 β€” 'ν…ŒμŠ€νŠΈ μ‹œλ‚˜λ¦¬μ˜€' μ‹œνŠΈμ˜ 둜그 TC. Framework μ‹œνŠΈμ™€ λ²ˆν˜Έκ°€ 겹쳐 TC-OBS-034 으둜 별도 λΆ€μ—¬(μ‚¬μš©μž κ²°μ • 2026-10-15)
// ν™”λ©΄: Monitorings > Logs β†’ Workload μ—μ„œ K8s Cluster 선택 Β· Keyword error β†’ Search
// μ‹œμž‘ μƒνƒœ: admin(mcmp) 둜그인, ws01/{K8s Project}. K8s ν΄λŸ¬μŠ€ν„° 쑴재 Β· 둜그 μˆ˜μ§‘(K8s Log Agent, TC-OBS-005)
// 절차: 1.Logs μ§„μž… 2.Workload 에 K8s Cluster(OBS_K8S_CLUSTER) 선택 Β· Keyword error β†’ Search 3.LogQL Β· 둜그 ν‘œ
// κΈ°λŒ€κ²°κ³Ό: ν΄λŸ¬μŠ€ν„° λ²”μœ„ λ‘œκ·Έκ°€ ν‚€μ›Œλ“œ ν•„ν„°λ‘œ ν‘œμ‹œλœλ‹€
// λΉ„κ³ : K8s ν΄λŸ¬μŠ€ν„°κ°€ μ—†μœΌλ©΄ SKIP(μ„ ν–‰ λ―ΈμΆ©μ‘±, 2026-10-15 stage ν΄λŸ¬μŠ€ν„° 0개)
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { openObs, clean } from '../../lib/iv7-obs';
import { logSearch } from '../../lib/iv7-obs-logs';

const NS = process.env.OBS_K8S_NS ?? 'p1';
const CLUSTER = process.env.OBS_K8S_CLUSTER ?? '';
test('IV7_S34 TC-OBS-034 K8s Cluster ν‚€μ›Œλ“œ 기반 둜그 쑰회', async ({ page }) => {
  await loginAsAdmin(page);
  const f = await openObs(page, 'logs', NS);
  const opts = (await f.locator('select').first().locator('option').allInnerTexts()).map(clean).filter((o) => !/^Select/.test(o));
  test.info().annotations.push({ type: 'workloads', description: `${NS}: ${opts.join(', ') || '(μ—†μŒ)'}` });
  const target = CLUSTER || opts[0];
  test.skip(!target, `${NS} 에 K8s ν΄λŸ¬μŠ€ν„° μ—†μŒ(μ„ ν–‰ λ―ΈμΆ©μ‘±)`);
  // ν‚€μ›Œλ“œ κΈ°λ³Έ error(OBS_LOG_KEYWORD 둜 λ³€κ²½). ν΄λŸ¬μŠ€ν„° λ‘œκ·Έμ— error κ°€ μ—†μœΌλ©΄(2026-10-15 GKE k4s 0건) ν‘œλ³Έ λ‘œκ·Έμ— μžˆλŠ” λ‹¨μ–΄λ‘œ λ‹€μ‹œ 검색
  let kw = process.env.OBS_LOG_KEYWORD ?? 'error';
  let { query, rows } = await logSearch(page, f, target, kw);
  if (!rows.length && !process.env.OBS_LOG_KEYWORD) {
    const sample = await logSearch(page, f, target, '');
    kw = JSON.stringify(sample.rows[0] ?? {}).match(/\b(stderr|stdout|warn\w*|info)\b/i)?.[1] ?? '';
    test.info().annotations.push({ type: 'keyword-fallback', description: `error 0건 β†’ ν‘œλ³Έ 둜그 단어 '${kw}' 둜 μž¬κ²€μƒ‰` });
    if (kw) ({ query, rows } = await logSearch(page, f, target, kw));
  }
  // K8s ν΄λŸ¬μŠ€ν„°λ„ Infra 와 같은 라벨 체계: {NS_ID, INFRA_ID=<cluster>, NODE_ID=<node>} (2026-10-15 AWS k1s 확인)
  expect(query).toContain(`INFRA_ID="${target}"`);
  expect(rows.length).toBeGreaterThan(0);
  expect(JSON.stringify(rows[0])).toContain(`"INFRA_ID":"${target}"`);
});
⚠️ **GitHub.com Fallback** ⚠️