Newer
Older
TelosDB / tests / e2e / helpers / e2e-cleanup.mjs
/**
 * E2E で作成したゴミを必ず削除する。スキップしない。
 *
 * 削除対象パターン:
 * - 文書: path に e2e-test-search-doc / E2E_FOLDER_MONITOR / e2e-watched-file / telosdb-e2e-watch を含むもの、本文に E2E検索テスト用の文書 を含むもの
 * - ファイル: tests/e2e/screenshots/docs-edit-modal.png
 * - フォルダ: os.tmpdir() 内の telosdb-e2e-watch-*
 * - 設定: run_on_login をオフ、monitor_paths からテスト用パス(C:\Test\Watch, D:\Documents\Notes, E:\ToRemove, telosdb-e2e-watch-*)を除去
 */

import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const E2E_SEARCH_PHRASE = 'E2E検索テスト用の文書';
const E2E_TEST_PATHS = ['C:\\Test\\Watch', 'D:\\Documents\\Notes', 'E:\\ToRemove'];
const E2E_DOC_PATH_PATTERNS = [
  'e2e-test-search-doc',
  'E2E_FOLDER_MONITOR',
  'e2e-watched-file',
  'telosdb-e2e-watch',
];
const SCREENSHOT_PATH = path.resolve(__dirname, '../screenshots/docs-edit-modal.png');
const SCREENSHOT_DIR = path.dirname(SCREENSHOT_PATH);

const DEFAULT_API = 'http://127.0.0.1:3001';
const MCP_RETRIES = 3;
const MCP_RETRY_MS = 800;

async function callMcpTool(apiBase, name, args = {}) {
  const res = await fetch(`${apiBase}/messages`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',
      method: 'tools/call',
      params: { name, arguments: args },
      id: Date.now(),
    }),
  });
  const data = await res.json();
  if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
  return data.result;
}

async function withRetry(fn) {
  let lastErr;
  for (let i = 0; i < MCP_RETRIES; i++) {
    try {
      return await fn();
    } catch (e) {
      lastErr = e;
      if (i < MCP_RETRIES - 1) await new Promise((r) => setTimeout(r, MCP_RETRY_MS));
    }
  }
  throw lastErr;
}

function isE2ETestDocPath(pathStr) {
  if (!pathStr || typeof pathStr !== 'string') return false;
  const p = pathStr.replace(/\\/g, '/');
  return E2E_DOC_PATH_PATTERNS.some((pat) => p.includes(pat));
}

function parseToolResultText(raw) {
  if (raw?.items) return raw;
  const text = raw?.content?.[0]?.text;
  if (typeof text === 'string') {
    try {
      return JSON.parse(text);
    } catch (_) {
      return { items: [] };
    }
  }
  return { items: [] };
}

function parseSettingsGetResult(raw) {
  if (raw?.monitor_paths !== undefined) return raw;
  const text = raw?.content?.[0]?.text;
  if (typeof text === 'string') {
    try {
      return JSON.parse(text);
    } catch (_) {
      return {};
    }
  }
  return {};
}

/** テストで追加した文書をすべて削除。search_text と list_documents の両方で対象を探す。スキップしない。 */
export async function removeE2ETestDocumentViaMcp(apiBase = DEFAULT_API) {
  await withRetry(async () => {
    const seen = new Set();

    const bySearchRaw = await callMcpTool(apiBase, 'search_text', {
      content: E2E_SEARCH_PHRASE,
      limit: 20,
    });
    const bySearch = parseToolResultText(bySearchRaw);
    for (const it of bySearch?.items ?? []) {
      const id = it.document_id ?? it.id;
      if (id != null && !seen.has(id)) {
        seen.add(id);
        await callMcpTool(apiBase, 'delete_document', { document_id: id });
      }
    }

    let page = 1;
    const limit = 100;
    for (;;) {
      const raw = await callMcpTool(apiBase, 'list_documents', { page, limit });
      const { items } = parseToolResultText(raw);
      if (!items?.length) break;
      for (const doc of items) {
        const docPath = doc.path ?? '';
        if (!isE2ETestDocPath(docPath)) continue;
        const id = doc.id ?? doc.document_id;
        if (id != null && !seen.has(id)) {
          seen.add(id);
          await callMcpTool(apiBase, 'delete_document', { document_id: id });
        }
      }
      if (items.length < limit) break;
      page++;
    }
  });
}

/** run_on_login をオフに戻す。スキップしない。 */
export async function resetRunOnLoginSetting(apiBase = DEFAULT_API) {
  await withRetry(async () => {
    const raw = await callMcpTool(apiBase, 'settings_get');
    const cur = parseSettingsGetResult(raw);
    if (!cur || cur.run_on_login !== true) return;
    await callMcpTool(apiBase, 'settings_update', {
      min_score: cur.min_score ?? 0.3,
      limit: cur.limit ?? 5,
      run_on_login: false,
      standard_folders_enabled: cur.standard_folders_enabled ?? false,
      monitor_paths: cur.monitor_paths ?? [],
      watch_extensions: cur.watch_extensions ?? ['txt', 'md', 'json', 'html', 'css', 'js', 'mjs', 'ts', 'rs'],
    });
  });
}

/** テストで追加した監視パスを設定から削除。スキップしない。 */
export async function removeE2EMonitorPathsFromSettings(apiBase = DEFAULT_API) {
  await withRetry(async () => {
    const raw = await callMcpTool(apiBase, 'settings_get');
    const cur = parseSettingsGetResult(raw);
    const paths = cur?.monitor_paths ?? [];
    const filtered = paths.filter((p) => {
      const pathStr = typeof p === 'string' ? p : p?.path ?? '';
      if (E2E_TEST_PATHS.includes(pathStr)) return false;
      if (pathStr.includes('telosdb-e2e-watch-')) return false;
      return true;
    });
    if (filtered.length === paths.length) return;
    await callMcpTool(apiBase, 'settings_update', {
      min_score: cur.min_score ?? 0.3,
      limit: cur.limit ?? 5,
      run_on_login: cur.run_on_login ?? false,
      standard_folders_enabled: cur.standard_folders_enabled ?? false,
      monitor_paths: filtered,
      watch_extensions: cur.watch_extensions ?? ['txt', 'md', 'json', 'html', 'css', 'js', 'mjs', 'ts', 'rs'],
    });
  });
}

function rmSyncForce(dirPath) {
  if (!fs.existsSync(dirPath)) return;
  for (let r = 0; r < 3; r++) {
    try {
      fs.rmSync(dirPath, { recursive: true, maxRetries: 2 });
      return;
    } catch (e) {
      if (r === 2) {
        console.error('[e2e-cleanup] 削除失敗:', dirPath, e?.message);
        throw e;
      }
    }
  }
}

export function safeRemoveTempDir(dirPath) {
  if (!dirPath || !fs.existsSync(dirPath)) return;
  try {
    fs.rmSync(dirPath, { recursive: true, maxRetries: 3 });
  } catch (e) {
    console.error('[e2e-cleanup] 一時ディレクトリ削除失敗:', dirPath, e?.message);
    rmSyncForce(dirPath);
  }
}

/** os.tmpdir() 内の telosdb-e2e-watch-* をすべて削除。スキップしない。 */
export function removeOrphanedE2ETempDirs() {
  const tmp = os.tmpdir();
  let entries;
  try {
    entries = fs.readdirSync(tmp, { withFileTypes: true });
  } catch (err) {
    console.error('[e2e-cleanup] tmpdir 読取失敗:', err?.message);
    throw err;
  }
  for (const e of entries) {
    if (!e.isDirectory() || !e.name.startsWith('telosdb-e2e-watch-')) continue;
    const full = path.join(tmp, e.name);
    try {
      fs.rmSync(full, { recursive: true, maxRetries: 3 });
    } catch (err) {
      console.error('[e2e-cleanup] 残存一時ディレクトリ削除失敗:', full, err?.message);
      rmSyncForce(full);
    }
  }
}

/** スクリーンショットファイルと空の screenshots ディレクトリを削除。スキップしない。 */
export function removeLeftoverScreenshot() {
  try {
    if (fs.existsSync(SCREENSHOT_PATH)) {
      fs.unlinkSync(SCREENSHOT_PATH);
    }
    if (fs.existsSync(SCREENSHOT_DIR)) {
      const names = fs.readdirSync(SCREENSHOT_DIR);
      if (names.length === 0) fs.rmdirSync(SCREENSHOT_DIR);
    }
  } catch (e) {
    console.error('[e2e-cleanup] スクリーンショット削除失敗:', SCREENSHOT_PATH, e?.message);
    throw e;
  }
}

/**
 * 全クリーンアップを実行。スキップせず実行する。
 * ファイルシステム → MCP の順。MCP はリトライ付き。
 */
export async function runAllCleanups(apiBase = DEFAULT_API) {
  removeOrphanedE2ETempDirs();
  removeLeftoverScreenshot();
  await removeE2ETestDocumentViaMcp(apiBase);
  await resetRunOnLoginSetting(apiBase);
  await removeE2EMonitorPathsFromSettings(apiBase);
}