diff --git a/.agent/rules/distribution-build.md b/.agent/rules/distribution-build.md new file mode 100644 index 0000000..e312c4a --- /dev/null +++ b/.agent/rules/distribution-build.md @@ -0,0 +1,17 @@ +--- +trigger: always_on +glob: +description: 配布ビルド前に全テスト(Community・Pro)を実行するルール +--- + +1. **配布ビルド**(`npm run build:community` または `npm run build:pro`)を実行する前に、**Community 版と Pro 版の全テストが成功していること**を必須とする。 + +2. **必須テストの内容**(いずれも exit 0 であること): + - `npm run test:rust` … Community 単体 + - `npm run test:rust:pro` … Pro 単体 + - `npm run test:e2e` … Community E2E + - `npm run test:e2e:pro` … Pro E2E(embedding モデル要) + +3. **一括実行**: `npm run test:all` で上記 4 つを順に実行する。いずれかが失敗したら配布ビルドを行わず、失敗を解消してから再度 `test:all` を実行する。 + +4. 仕様の詳細は `docs/specification/05_development_guide.md` の「本番ビルド」を参照する。 diff --git a/docs/plans/auto_start/auto_start_04_phases.md b/docs/plans/auto_start/auto_start_04_phases.md index 9be7e1c..411e39f 100644 --- a/docs/plans/auto_start/auto_start_04_phases.md +++ b/docs/plans/auto_start/auto_start_04_phases.md @@ -23,6 +23,6 @@ |------|------| | **単体** | Tauri コマンドまたはフロントから `enable()` / `disable()` / `isEnabled()` を呼び、戻り値やレジストリ・LaunchAgents の書き換えが期待どおりか検証する。プラグインをモックに差し替えて「設定保存と API 呼び出しの対応」をテストするのも可。 | | **結合** | 起動時に保存済み設定を読み、`isEnabled()` と不一致なら `enable`/`disable` で同期する流れをテスト。設定をオフ→オン→オフと変更したときに毎回正しく API が呼ばれるか。 | -| **UI / E2E** | 既存の [E2E(WebdriverIO)](https://github.com/tauri-apps/webdriver-example) や `e2e-tests/` を使い、設定パネルを開いて「ログイン時自動起動」トグルを操作し、表示が変わること・保存されることを検証する([12 UI テスト方針](../../specification/12_ui_testing_options.md) 参照)。 | +| **UI / E2E** | 既存の [E2E(WebdriverIO)](https://github.com/tauri-apps/webdriver-example) や `tests/e2e/` を使い、設定パネルを開いて「ログイン時自動起動」トグルを操作し、表示が変わること・保存されることを検証する([12 UI テスト方針](../../specification/12_ui_testing_options.md) 参照)。 | | **手動** | 自動起動をオンにし、OS をログアウト→ログイン(または再起動)して TelosDB が起動することを確認。オフにした場合は起動しないことを確認。インストーラでインストールしたバイナリでも同様に確認する。 | | **リリース前** | 上記に加え、アンインストール後に Run キー(または LaunchAgents / autostart)にエントリが残っていないことを確認するチェックを入れる。 | diff --git a/docs/specification/05_development_guide.md b/docs/specification/05_development_guide.md index 313f7ca..337aadf 100644 --- a/docs/specification/05_development_guide.md +++ b/docs/specification/05_development_guide.md @@ -6,6 +6,7 @@ |------|------| | `src/frontend/` | UI(Vanilla JS / CSS)。React/Vite は未使用。 | | `src/backend/` | Rust / Tauri / Axum。MCP サーバー・LSA・埋め込みエンジン。 | +| `tests/` | テスト一式(原則ルート直下に集約)。API/MCP は `test_mcp_client.mjs`、E2E は `tests/e2e/`。Rust の結合テスト**ファイル**だけは Cargo の都合で `src/backend/tests/` に配置。詳細は `tests/README.md`。 | | `docs/specification/` | 本仕様書群。 | | `.agent/rules/` | AI エージェント用プロジェクト運用ルール。 | @@ -18,6 +19,15 @@ ### 本番ビルド(2 エディション) +**ルール: 配布ビルド(`build:community` / `build:pro`)の前に、Community 版・Pro 版の全テストを実行し、すべて成功していること。** + +- **必須テスト**(順に実行し、いずれも exit 0 であること): + 1. `npm run test:rust`(Community 単体) + 2. `npm run test:rust:pro`(Pro 単体) + 3. `npm run test:e2e`(Community E2E) + 4. `npm run test:e2e:pro`(Pro E2E。embedding モデル要) +- **一括実行**: `npm run test:all` で上記 1〜4 を順に実行する。失敗したら配布ビルドを行わない。 + - **Community**: `npm run build:community` - 設定: `src/backend/tauri.community.conf.json`(productName: TelosDB-Community、vec0.dll のみ同梱) - **Pro**: `npm run build:pro` diff --git a/docs/specification/12_ui_testing_options.md b/docs/specification/12_ui_testing_options.md index ad741a6..137b55a 100644 --- a/docs/specification/12_ui_testing_options.md +++ b/docs/specification/12_ui_testing_options.md @@ -36,8 +36,8 @@ - **WebdriverIO** を devDependency に追加。ルートに `wdio.conf.js` を配置。 - **npm スクリプト**: `npm run test:e2e`(ビルド + tauri-driver 起動 + E2E 実行)、`npm run build:e2e`(E2E 用 debug ビルドのみ)。 -- **スペック**: `e2e-tests/specs/app.spec.js` でタイトル・ヘッダー・検索 UI を検証。 -- **前提**: `cargo install tauri-driver --locked` と、Windows では Edge Driver(msedgedriver)を PATH に通す。詳細は `e2e-tests/README.md` を参照。 +- **スペック**: `tests/e2e/specs/app.spec.js` でタイトル・ヘッダー・検索 UI を検証。 +- **前提**: `cargo install tauri-driver --locked` と、Windows では Edge Driver(msedgedriver)を PATH に通す。詳細は `tests/e2e/README.md` を参照。 --- diff --git a/e2e-tests/README.md b/e2e-tests/README.md deleted file mode 100644 index 762d732..0000000 --- a/e2e-tests/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# E2E テスト(WebDriver + tauri-driver) - -TelosDB の UI を WebDriver で操作し、表示・動作を検証します。 - -## 前提 - -1. **tauri-driver のインストール** - ```bash - cargo install tauri-driver --locked - ``` - -2. **プラットフォーム別ドライバ** - - **Windows**: `edgedriver` が devDependency に含まれており、初回実行時に Edge のバージョンに合わせて Edge Driver を自動ダウンロードする。手動で用意する場合は [Microsoft Edge Driver](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/) を PATH に通すか、`TELOS_EDGE_DRIVER` にバイナリのパスを指定する。 - - **Linux**: `WebKitWebDriver` をインストール(例: Debian 系では `sudo apt install webkit2gtk-driver`)。CI では仮想ディスプレイ用に `xvfb` が必要な場合あり。 - - **macOS**: デスクトップ向け WebDriver は非対応のため、この E2E は Windows / Linux で実行すること。 - -## 実行 - -プロジェクトルートで: - -```bash -# 依存のインストール(未実施なら) -npm install - -# E2E 用にビルドしてからテスト実行(初回はビルドに時間がかかります) -npm run test:e2e -``` - -事前にビルドだけしたい場合: - -```bash -npm run build:e2e -``` - -その後、バイナリのパスを環境変数で渡してテストのみ実行する例: - -```bash -# Windows でルートに target がある場合の例 -set TELOS_E2E_APP=D:\develop\TelosDB\target\debug\app.exe -npm run test:e2e -``` - -## スペック - -- `e2e-tests/specs/app.spec.js` … ウィンドウタイトル、ヘッダー、検索 UI の表示と検索実行の確認。 - -## 検索の可否をログで確認する - -バックエンドは検索実行時に次のログを出す(`RUST_LOG=info` で表示)。 - -- `[search] query="..." limit=N min_score=X` … 検索が開始された -- `[search] FTS hits=N` … FTS5 のヒット件数 -- `[search] Pro vector hits=N` / `[search] Community LSA vector hits=N` … ベクトル検索のヒット件数(該当する方のみ) -- `[search] result_count=N ok=true|false` … 最終結果件数と成否 - -手動でアプリを起動して検索するときは、ターミナルの stderr に上記が出る。E2E でアプリを tauri-driver 経由で起動している場合は、アプリの stderr がそのまま見えないことがあるため、検索の可否は E2E の「検索がヒットする」spec の成否と、必要なら `TELOS_LOG_FILE=log.txt` などでログをファイルに出して確認する。 - -## 注意 - -- テスト実行中は TelosDB のウィンドウが開きます。操作は WebDriver が行うため、手で触らないでください。 -- MCP サーバ(3001)がアプリ起動時に立ち上がるため、ポートが他プロセスで占有されていないことを確認してください。 diff --git a/e2e-tests/specs/app.spec.js b/e2e-tests/specs/app.spec.js deleted file mode 100644 index bea15ef..0000000 --- a/e2e-tests/specs/app.spec.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * TelosDB の E2E スペック(WebDriver + tauri-driver)。 - * ウィンドウが開き、ヘッダー・検索 UI が表示され、検索が実際にヒットすることを検証する。 - */ - -const API_BASE = 'http://127.0.0.1:3001'; -const E2E_SEARCH_PHRASE = 'E2E検索テスト用の文書'; -const E2E_DOC_CONTENT = `${E2E_SEARCH_PHRASE}です。この文字列でヒットすることを確認する。`; - -async function waitForMcp(maxAttempts = 15, intervalMs = 500) { - for (let i = 0; i < maxAttempts; i++) { - try { - const res = await fetch(`${API_BASE}/edition`); - if (res.ok) return; - } catch (_) {} - await new Promise((r) => setTimeout(r, intervalMs)); - } - throw new Error('MCP (3001) did not become ready in time'); -} - -async function addDocumentViaMcp(content, path) { - const res = await fetch(`${API_BASE}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'add_item_text', - arguments: { content, path }, - }, - id: Date.now(), - }), - }); - const data = await res.json(); - if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); - return data; -} - -describe('TelosDB', () => { - it('ウィンドウのタイトルが TelosDB である', async () => { - const title = await browser.getTitle(); - expect(title).toMatch(/TelosDB/i); - }); - - it('ヘッダーに TelosDB のロゴ・テキストが表示される', async () => { - const logo = await $('.site-header .logo-mark'); - await expect(logo).toBeDisplayed(); - await expect(logo).toHaveText('TelosDB'); - }); - - it('検索入力と検索ボタンが表示される', async () => { - const searchInput = await $('#query'); - const searchBtn = await $('.search-btn'); - await expect(searchInput).toBeDisplayed(); - await expect(searchBtn).toBeDisplayed(); - await expect(searchBtn).toHaveText('検索'); - }); - - it('検索がヒットする(テスト用文書を登録し、その文言で検索して結果が返る)', async () => { - await waitForMcp(); - await addDocumentViaMcp(E2E_DOC_CONTENT, 'e2e-test-search-doc.txt'); - await browser.pause(2500); - - const searchInput = await $('#query'); - const searchBtn = await $('.search-btn'); - const resultPanel = await $('#result'); - await searchInput.setValue(E2E_SEARCH_PHRASE); - await searchBtn.click(); - await browser.pause(2500); - - await expect(resultPanel).toBeDisplayed(); - const resultCards = await $$('.result-card'); - expect(resultCards.length).toBeGreaterThanOrEqual(1); - - const firstCardText = await resultCards[0].getText(); - expect(firstCardText).toContain(E2E_SEARCH_PHRASE); - }); -}); diff --git "a/journals/202603-013-\346\226\207\346\233\270\347\267\250\351\233\206\343\203\242\343\203\274\343\203\200\343\203\253UI\346\224\271\345\226\204\343\201\250\343\203\201\343\203\243\343\203\263\343\202\257\345\210\206\345\211\262\347\242\272\350\252\215.md" "b/journals/202603-013-\346\226\207\346\233\270\347\267\250\351\233\206\343\203\242\343\203\274\343\203\200\343\203\253UI\346\224\271\345\226\204\343\201\250\343\203\201\343\203\243\343\203\263\343\202\257\345\210\206\345\211\262\347\242\272\350\252\215.md" new file mode 100644 index 0000000..4294bb5 --- /dev/null +++ "b/journals/202603-013-\346\226\207\346\233\270\347\267\250\351\233\206\343\203\242\343\203\274\343\203\200\343\203\253UI\346\224\271\345\226\204\343\201\250\343\203\201\343\203\243\343\203\263\343\202\257\345\210\206\345\211\262\347\242\272\350\252\215.md" @@ -0,0 +1,36 @@ +# 2026年 文書編集モーダルUI改善とチャンク分割確認 + +## サマリー + +- **文書編集モーダルUI**: 保存・キャンセルボタンの視認性向上、Toast UI ツールバーアイコン非表示の修正、モーダル・エディタ枠のスタイル調整を行った。 +- **ツールバーアイコン**: `.panel button` に `background` を当てていたため Toast UI のスプライト(background-image)が消えていた。ツールバーアイコン用ボタンを `:not(.toastui-editor-toolbar-icons):not([class*="toolbar-icons"])` で除外し、アイコンが表示されるようにした。 +- **チャンク分割**: モーダル保存時も `add_item_text` 経由で 800 文字固定長のチャンク分割が行われていることを確認。文・段落単位の分割にはなっていない。 + +--- + +## UI 変更内容 + +| 対象 | 内容 | +|------|------| +| 保存・キャンセル | `.docs-edit-modal .setting-actions .secondary-btn` および `#docs-save-btn` の背景・枠・色を調整し、ダークテーマで視認しやすくした。 | +| ツールバーアイコン | `styles.css` の `.panel button` を「Toast UI ツールバーアイコン用」に当たらないよう除外。`background` ショートハンドで `background-image` が上書きされないようにした。 | +| 枠・モーダル | `.docs-edit-modal-box` の border / border-radius / box-shadow、`.docs-editor-container .toastui-editor-defaultUI` の枠、設定アクション区切りの線を控えめに統一。 | +| 文書パネルツールバー | 「新規登録」「一覧更新」の `.docs-toolbar .secondary-btn` のスタイルを調整。 | + +--- + +## 主な変更ファイル + +| 種別 | パス | +|------|------| +| スタイル | src/frontend/styles.css(.panel button の除外、モーダル・エディタ・ボタンまわり) | +| E2E | tests/e2e/specs/screenshot-docs-modal.spec.js(編集モーダル開いてスクショ保存)、docs-edit-drawing.spec.js(描画・ボタン表示の検証) | +| スクリプト | package.json(test:e2e:screenshot 追加) | + +--- + +## チャンク分割の仕様(確認結果) + +- **処理**: `add_item_text`(`src/backend/src/mcp/tools/items.rs`)で本文を `chars.chunks(800)` により **800 文字ごと** に分割。 +- **モーダル保存**: フロントの「保存」で `callMcp('add_item_text', { path, content })` が呼ばれ、上記と同じチャンク分割が適用される。 +- **注意**: 文の長さや段落には依存しておらず、固定長 800 文字で切りているだけ。文・段落単位にしたい場合は別ロジックの検討が必要。 diff --git "a/journals/202603-014-\343\203\220\343\203\274\343\202\270\343\203\247\343\203\2630.3.2\343\203\252\343\203\223\343\203\253\343\203\211\343\201\250\345\217\226\345\276\227\344\273\266\346\225\260\343\203\273\343\203\220\343\203\274\343\202\270\343\203\247\343\203\263\350\241\250\347\244\272\343\201\276\343\202\217\343\202\212.md" "b/journals/202603-014-\343\203\220\343\203\274\343\202\270\343\203\247\343\203\2630.3.2\343\203\252\343\203\223\343\203\253\343\203\211\343\201\250\345\217\226\345\276\227\344\273\266\346\225\260\343\203\273\343\203\220\343\203\274\343\202\270\343\203\247\343\203\263\350\241\250\347\244\272\343\201\276\343\202\217\343\202\212.md" new file mode 100644 index 0000000..64a8377 --- /dev/null +++ "b/journals/202603-014-\343\203\220\343\203\274\343\202\270\343\203\247\343\203\2630.3.2\343\203\252\343\203\223\343\203\253\343\203\211\343\201\250\345\217\226\345\276\227\344\273\266\346\225\260\343\203\273\343\203\220\343\203\274\343\202\270\343\203\247\343\203\263\350\241\250\347\244\272\343\201\276\343\202\217\343\202\212.md" @@ -0,0 +1,39 @@ +# 2026年 バージョン 0.3.2 リビルドと取得件数・バージョン表示まわり + +## サマリー + +- **バージョン 0.3.2 への一時戻し**: package.json / Cargo.toml / tauri.conf.json を 0.3.2 に統一し、test:all 成功後に Community・Pro の配布ビルドを完了した。 +- **検索の既定取得件数**: 10 件 → 5 件に変更(バックエンド・フロント・設定の既定値・registry スキーマをすべて 5 に統一)。 +- **フッターのバージョン表示**: API 取得失敗時は別バージョンを出さないようフォールバックを「v?」に変更。E2E で MCP 準備が遅れても取得できるようリトライを 35 回に延長。 +- **バージョン一致の検証**: ビルドバージョンと GUI/API の一致を、E2E(フッター vs package.json)と結合テスト(/version vs package.json)で検証するテストを追加。 + +--- + +## 変更内容 + +| 対象 | 内容 | +|------|------| +| バージョン | package.json, src/backend/Cargo.toml, src/backend/tauri.conf.json を 0.3.2 に設定(のちに 0.3.3 へ上げ予定)。 | +| 既定取得件数 | handlers.rs, lib.rs, registry.rs, search.rs, main-panel.js, index.html の limit 既定値を 10 → 5 に変更。 | +| フッター | フォールバックを「0.3.2」→「?」に変更(誤ったバージョン表示を防止)。_fetchVersion のリトライを 5 回→35 回に延長。 | +| E2E | app.spec.js に「フッターのバージョンが package.json と一致する」テストを追加。package.json を読み期待バージョンと比較。 | +| 結合 | test_mcp_client.mjs で /version が package.json の version と一致することをアサート。 | +| ドキュメント | tests/README.md に上記バージョン検証の記載を追記。 | + +--- + +## 主な変更ファイル + +| 種別 | パス | +|------|------| +| バージョン | package.json, src/backend/Cargo.toml, src/backend/tauri.conf.json | +| バックエンド | src/backend/src/mcp/handlers.rs, lib.rs, mcp/tools/registry.rs, mcp/tools/search.rs | +| フロント | src/frontend/components/site-footer.js, main-panel.js, index.html | +| テスト | tests/e2e/specs/app.spec.js, tests/test_mcp_client.mjs, tests/README.md | + +--- + +## ビルド結果(0.3.2) + +- **test:all**: test:rust, test:rust:pro, test:e2e, test:e2e:pro すべて成功。 +- **配布**: TelosDB-Community_0.3.2_x64-setup.exe, TelosDB-Pro_0.3.2_x64-setup.exe を target/release/bundle/nsis に出力。 diff --git a/package.json b/package.json index 6ea22d4..1c4d673 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "telos-db", "private": true, - "version": "0.3.3", + "version": "0.3.2", "type": "module", "scripts": { "tauri": "tauri", @@ -12,6 +12,9 @@ "build:community": "tauri build -c src/backend/tauri.community.conf.json", "build:pro": "node tools/ensure-embedding-model.mjs && tauri build -c src/backend/tauri.pro.conf.json -- --no-default-features --features pro", "test:rust": "cd src/backend && cargo test", + "test:rust:pro": "cd src/backend && cargo test --no-default-features --features pro", + "test:all": "npm run test:rust && npm run test:rust:pro && npm run test:e2e && npm run test:e2e:pro", + "test:integration:rust": "cd src/backend && cargo test --test search_api -- --ignored", "test:mcp": "node tools/run-test.mjs", "test:mcp:pro": "node tools/run-test.mjs --pro", "test:headless": "node tools/run-headless-test.mjs", @@ -22,7 +25,10 @@ "test-monolithic-dev:pro": "node tools/test-monolithic-dev.mjs --pro", "test:e2e": "wdio run wdio.conf.js", "test:e2e:community": "wdio run wdio.conf.js", + "test:e2e:pro": "npm run build:e2e:pro && node tools/run-e2e-pro.mjs", + "test:e2e:screenshot": "wdio run wdio.conf.js --spec tests/e2e/specs/screenshot-docs-modal.spec.js", "build:e2e": "node tools/build-for-e2e.mjs", + "build:e2e:pro": "node tools/ensure-embedding-model.mjs && tauri build --debug --no-bundle -c src/backend/tauri.pro.conf.json -- --no-default-features --features pro", "kill-ports": "node tools/kill-ports.mjs" }, "dependencies": { diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 0a43104..96cdac0 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -180,7 +180,7 @@ [[package]] name = "app" -version = "0.3.3" +version = "0.3.2" dependencies = [ "anyhow", "axum", diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 5396f8c..45c02d7 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "app" -version = "0.3.3" +version = "0.3.2" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index e9d9261..91f8f68 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -23,7 +23,7 @@ .join("settings.json"); let default = serde_json::json!({ "min_score": 0.3, - "limit": 10, + "limit": 5, "run_on_login": false, "monitor_paths": [] }); @@ -48,7 +48,7 @@ .unwrap_or_else(|| vec![]); let merged = serde_json::json!({ "min_score": obj.get("min_score").or(def.get("min_score")).unwrap_or(&serde_json::json!(0.3)), - "limit": obj.get("limit").or(def.get("limit")).unwrap_or(&serde_json::json!(10)), + "limit": obj.get("limit").or(def.get("limit")).unwrap_or(&serde_json::json!(5)), "run_on_login": run_on_login, "monitor_paths": monitor_paths }); diff --git a/src/backend/src/mcp/handlers.rs b/src/backend/src/mcp/handlers.rs index be643ec..0e9869c 100644 --- a/src/backend/src/mcp/handlers.rs +++ b/src/backend/src/mcp/handlers.rs @@ -49,7 +49,7 @@ fn settings_default() -> serde_json::Value { serde_json::json!({ "min_score": 0.3, - "limit": 10, + "limit": 5, "run_on_login": false, "monitor_paths": [] }) @@ -80,7 +80,7 @@ .unwrap_or_else(|| vec![]); let merged = serde_json::json!({ "min_score": obj.get("min_score").and_then(|v| v.as_f64()).unwrap_or(0.3), - "limit": obj.get("limit").and_then(|v| v.as_i64()).unwrap_or(10), + "limit": obj.get("limit").and_then(|v| v.as_i64()).unwrap_or(5), "run_on_login": run_on_login, "monitor_paths": monitor_paths }); diff --git a/src/backend/src/mcp/tools/registry.rs b/src/backend/src/mcp/tools/registry.rs index 29ea976..79961be 100644 --- a/src/backend/src/mcp/tools/registry.rs +++ b/src/backend/src/mcp/tools/registry.rs @@ -32,7 +32,7 @@ "type": "object", "properties": { "content": { "type": "string" }, - "limit": { "type": "integer", "default": 10 }, + "limit": { "type": "integer", "default": 5 }, "min_score": { "type": "number", "default": 0.3, "description": "Minimum similarity (0-1). Results below this are dropped. Default 0.3." } }, "required": ["content"] @@ -89,3 +89,33 @@ }), ] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_list_has_expected_tools() { + let list = tool_list(); + let names: Vec = list + .iter() + .filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect(); + let expected = [ + "get_item_by_id", + "add_item_text", + "search_text", + "update_item", + "delete_item", + "list_documents", + "get_document_count", + "get_document", + "delete_document", + "lsa_retrain", + ]; + assert_eq!(list.len(), expected.len(), "tool_list length"); + for name in &expected { + assert!(names.contains(&name.to_string()), "tool_list contains {}", name); + } + } +} diff --git a/src/backend/src/mcp/tools/search.rs b/src/backend/src/mcp/tools/search.rs index 5ff666d..483936d 100644 --- a/src/backend/src/mcp/tools/search.rs +++ b/src/backend/src/mcp/tools/search.rs @@ -48,7 +48,7 @@ } else { args.get("content").and_then(|v| v.as_str()).unwrap_or("") }; - let search_limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10); + let search_limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5); // true のとき検索結果を文書単位にまとめ、同一文書のチャンクを結合して返す。未指定時は true。 let group_by_document = args.get("group_by_document") .or_else(|| args.get("groupByDocument")) diff --git a/src/backend/tauri.conf.json b/src/backend/tauri.conf.json index 869b83f..f78a3dc 100644 --- a/src/backend/tauri.conf.json +++ b/src/backend/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../../node_modules/@tauri-apps/cli/config.schema.json", "productName": "TelosDB", - "version": "0.3.3", + "version": "0.3.2", "identifier": "com.telosdb.app", "build": { "frontendDist": "../frontend", diff --git a/src/frontend/components/main-panel.js b/src/frontend/components/main-panel.js index 7bc265f..a5ae9ba 100644 --- a/src/frontend/components/main-panel.js +++ b/src/frontend/components/main-panel.js @@ -85,7 +85,7 @@
- +
@@ -104,7 +104,7 @@ // Expose showPanel as method const SETTINGS_KEY = 'telosdb_settings'; - const DEFAULTS = { min_score: 0.3, limit: 10, run_on_login: false, monitor_paths: [] }; + const DEFAULTS = { min_score: 0.3, limit: 5, run_on_login: false, monitor_paths: [] }; const renderMonitorPathsList = (paths) => { const listEl = this.querySelector('#settings-monitor-paths-list'); diff --git a/src/frontend/components/site-footer.js b/src/frontend/components/site-footer.js index 1d727e6..c48b9ac 100644 --- a/src/frontend/components/site-footer.js +++ b/src/frontend/components/site-footer.js @@ -1,5 +1,6 @@ const API_BASE = typeof window !== 'undefined' && window.API_BASE ? window.API_BASE : 'http://127.0.0.1:3001'; -const FALLBACK_VERSION = '0.3.2'; +/** API 取得失敗時は別バージョンを表示しない(誤解を防ぐため) */ +const FALLBACK_VERSION = '?'; class SiteFooter extends HTMLElement { connectedCallback() { @@ -18,15 +19,21 @@ async _fetchVersion() { const el = this.querySelector('.footer-version'); if (!el) return; - try { - const res = await fetch(`${API_BASE}/version`); - if (!res.ok) throw new Error(`version ${res.status}`); - const data = await res.json(); - if (data && typeof data.version === 'string') { - el.textContent = `v${data.version}`; + const maxAttempts = 35; + const delayMs = 1000; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch(`${API_BASE}/version`); + if (!res.ok) throw new Error(`version ${res.status}`); + const data = await res.json(); + if (data && typeof data.version === 'string') { + el.textContent = `v${data.version}`; + return; + } + } catch (_) { + if (attempt === maxAttempts) el.textContent = `v${FALLBACK_VERSION}`; + else await new Promise((r) => setTimeout(r, delayMs)); } - } catch { - el.textContent = `v${FALLBACK_VERSION}`; } } } diff --git a/src/frontend/index.html b/src/frontend/index.html index 020f602..2cb6df6 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -220,10 +220,10 @@ const settings = (() => { try { const raw = localStorage.getItem("telosdb_settings"); - const def = { min_score: 0.3, limit: 10, run_on_login: false }; + const def = { min_score: 0.3, limit: 5, run_on_login: false }; return raw ? { ...def, ...JSON.parse(raw) } : def; } catch (e) { - return { min_score: 0.3, limit: 10 }; + return { min_score: 0.3, limit: 5 }; } })(); diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 014fc26..98b680b 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -266,8 +266,9 @@ color: var(--text-primary, #ffffff); } +/* Toast UI ツールバーアイコン用ボタンは除外(.panel button を当てると background ショートハンドで background-image が消える) */ .panel input, -.panel button { +.panel button:not(.toastui-editor-toolbar-icons):not([class*="toolbar-icons"]) { color: var(--text-primary, #ffffff); background: var(--bg-surface, #121214); } @@ -387,6 +388,18 @@ gap: 8px; margin-bottom: 16px; } +.docs-toolbar .secondary-btn { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + color: var(--text-primary); + padding: 8px 14px; + font-weight: 600; +} +.docs-toolbar .secondary-btn:hover { + background: var(--border-base); + border-color: var(--accent-blue); + color: var(--text-primary); +} .docs-list { overflow: auto; margin-bottom: 16px; @@ -485,9 +498,10 @@ max-width: 640px; } .docs-editor-container .toastui-editor-defaultUI { - border-radius: 4px; - border-color: var(--border-base); + border: 1px solid var(--border-subtle); + border-radius: 6px; } +/* Toast UI ツールバー: 当てない(アイコンはバンドル CSS の background-image スプライトで描画) */ .docs-editor-fallback { padding: 12px; color: var(--text-dim); @@ -515,14 +529,46 @@ .docs-edit-modal-box { position: relative; background: var(--bg-panel); - border: 1px solid var(--border-base); - border-radius: 8px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); max-width: 90%; max-height: 90vh; overflow: auto; padding: 16px; } +/* 編集モーダル内の保存・キャンセルボタン */ +.docs-edit-modal .setting-actions { + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} +.docs-edit-modal .setting-actions .secondary-btn { + background: var(--bg-surface); + border: 1px solid var(--border-base); + color: var(--text-primary); + padding: 8px 16px; + font-size: 0.9rem; + font-weight: 600; + border-radius: 6px; +} +.docs-edit-modal .setting-actions .secondary-btn:hover { + background: var(--border-base); + border-color: var(--accent-blue); + color: var(--text-primary); +} +#docs-save-btn { + background: rgba(96, 165, 250, 0.15); + border-color: var(--accent-blue); + color: var(--accent-blue); +} +#docs-save-btn:hover { + background: rgba(96, 165, 250, 0.25); + border-color: var(--accent-blue); + color: #93c5fd; +} + /* Search Area */ .search-container { flex-shrink: 0; diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ac4f6e6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,41 @@ +# テスト一覧 + +**方針: テストは原則ルート直下の `tests/` に集約する。** + +**配布ビルド前のルール**: `build:community` / `build:pro` を実行する前に、`npm run test:all`(Community・Pro の単体+E2E の 4 本)を実行し、すべて成功していること。詳細は `docs/specification/05_development_guide.md` と `.agent/rules/distribution-build.md`。 + +- 単体: Rust はソース内 `#[cfg(test)]`(実行は `npm run test:rust` でルートから実行)。 +- 結合: Node は `tests/test_mcp_client.mjs`。Rust の結合テスト**ファイル**だけは Cargo の仕様で `src/backend/tests/` に配置(Cargo はクレート直下の `tests/` しか結合テストとして認識しない)。実行はルートから `npm run test:integration:rust` で呼び出す。 +- E2E: `tests/e2e/`。 + +単体・結合・E2E の 3 層を用意している。 + +| 種別 | 内容 | 場所 | 実行方法 | +|------|------|------|----------| +| **単体** | Rust のモジュール単位テスト(DB・LSA・MCP・埋め込み・トークナイザ等) | `src/backend/src/**/*.rs` 内の `#[cfg(test)] mod tests` | `npm run test:rust`(`cargo test`) | +| **結合** | MCP API を実際に叩くテスト(tools/list, /version, search_text, CRUD 一連, lsa_retrain 等)。**/version が package.json の version と一致することを検証**。 | Node: `tests/test_mcp_client.mjs`
Rust: `src/backend/tests/search_api.rs`(`#[ignore]` 付き) | Node: `npm run test:mcp` / `test-and-heal` 等(MCP 起動前提)
Rust: `npm run test:integration:rust`(MCP 起動前提) | +| **E2E** | アプリ全体を WebDriver で操作して UI を検証(タイトル・ヘッダー・検索・パネル・編集モーダル等)。**フッター表示バージョンが package.json と一致することを検証**。 | `tests/e2e/specs/*.spec.js`(app, panels, docs-edit-drawing, screenshot-docs-modal) | `npm run test:e2e`(Community ビルド)
`npm run test:e2e:pro`(Pro ビルド。embedding モデル要) | + +## Pro 版のテスト + +| 種別 | 実行方法 | 備考 | +|------|----------|------| +| **単体** | `npm run test:rust:pro` | `--no-default-features --features pro` で埋め込み・Pro 用コードを含めて `cargo test` | +| **結合** | `npm run test:mcp:pro` / `npm run test-and-heal:pro` | Pro アプリを起動してから `test_mcp_client.mjs`(Pro 時スコア・vector_search_used 等を検証) | +| **E2E** | `npm run test:e2e:pro` | 事前に `build:e2e:pro`(embedding モデル必須)を実行し、Pro バイナリで同じ E2E スペックを実行 | + +## 単体テスト(Unit) + +- **実行**: `npm run test:rust`(Community)/ `npm run test:rust:pro`(Pro) +- **中身**: `src/backend` 内の各モジュールに `#[cfg(test)] mod tests` で定義。DB 初期化・次元変更、LSA 分散・チャンク分割、MCP チャンク分割ロジック、埋め込み・トークナイザ・dev_static・registry(tool_list のツール数・名前)などのユニットテスト。 +- **前提**: vec0.dll 等のネイティブ依存があるテストは、未配置時はスキップされる場合あり。 + +## 結合テスト(Integration) + +- **Node(test_mcp_client.mjs)**: MCP が 127.0.0.1:3001 で起動している前提。`tools/list`・`search_text`・Pro 時のスコア検証など。`npm run test-and-heal` や `test-and-heal:pro` で MCP を立ち上げてからこのスクリプトを実行する流れ。 +- **Rust(search_api.rs)**: 同上、MCP 起動前提。ファイルは Cargo の都合で `src/backend/tests/search_api.rs` に配置。実行はルートから `npm run test:integration:rust`(MCP 起動前提)。通常の `cargo test` では `#[ignore]` によりスキップされる。 + +## E2E テスト + +- **実行**: `npm run test:e2e`(Community。onPrepare で `build:e2e` が走る)/ `npm run test:e2e:pro`(Pro。`build:e2e:pro` のあと wdio。embedding モデル要) +- **詳細**: `tests/e2e/README.md` を参照(tauri-driver・Edge Driver の準備など)。 diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..96cc767 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,71 @@ +# E2E テスト(WebDriver + tauri-driver) + +TelosDB の UI を WebDriver で操作し、表示・動作を検証します。 + +## 前提 + +1. **tauri-driver のインストール** + ```bash + cargo install tauri-driver --locked + ``` + +2. **プラットフォーム別ドライバ** + - **Windows**: `edgedriver` が devDependency に含まれており、初回実行時に Edge のバージョンに合わせて Edge Driver を自動ダウンロードする。手動で用意する場合は [Microsoft Edge Driver](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/) を PATH に通すか、`TELOS_EDGE_DRIVER` にバイナリのパスを指定する。 + - **Linux**: `WebKitWebDriver` をインストール(例: Debian 系では `sudo apt install webkit2gtk-driver`)。CI では仮想ディスプレイ用に `xvfb` が必要な場合あり。 + - **macOS**: デスクトップ向け WebDriver は非対応のため、この E2E は Windows / Linux で実行すること。 + +## 起動待ち + +各 spec の `before()` で **起動が完了するまで待機**してからテストを開始する。 + +- **ヘルパー**: `tests/e2e/helpers/wait-for-app.js` の `waitForAppReady(browser)` +- **手順**: (1) フロントのメイン UI(`#query`)が表示されるまで最大 20 秒待つ (2) MCP (3001) の `/edition` が応答するまで最大 20 秒ポーリング +- これにより「ウィンドウは開いたがフロントや MCP がまだ準備できていない」状態でアサートして失敗することを防ぐ。 + +## 実行 + +プロジェクトルートで: + +```bash +# 依存のインストール(未実施なら) +npm install + +# E2E 用にビルドしてからテスト実行(初回はビルドに時間がかかります) +npm run test:e2e +``` + +事前にビルドだけしたい場合: + +```bash +npm run build:e2e +``` + +その後、バイナリのパスを環境変数で渡してテストのみ実行する例: + +```bash +# Windows でルートに target がある場合の例 +set TELOS_E2E_APP=D:\develop\TelosDB\target\debug\app.exe +npm run test:e2e +``` + +## スペック + +- `tests/e2e/specs/app.spec.js` … ウィンドウタイトル、ヘッダー、検索 UI の表示と検索実行の確認。 +- `tests/e2e/specs/docs-edit-drawing.spec.js` … 文書編集モーダル・ツールバーの描画確認。 +- `tests/e2e/specs/screenshot-docs-modal.spec.js` … 編集モーダルのスクリーンショット取得(`npm run test:e2e:screenshot`)。 + +## 検索の可否をログで確認する + +バックエンドは検索実行時に次のログを出す(`RUST_LOG=info` で表示)。 + +- `[search] query="..." limit=N min_score=X` … 検索が開始された +- `[search] FTS hits=N` … FTS5 のヒット件数 +- `[search] Pro vector hits=N` / `[search] Community LSA vector hits=N` … ベクトル検索のヒット件数(該当する方のみ) +- `[search] result_count=N ok=true|false` … 最終結果件数と成否 + +手動でアプリを起動して検索するときは、ターミナルの stderr に上記が出る。E2E でアプリを tauri-driver 経由で起動している場合は、アプリの stderr がそのまま見えないことがあるため、検索の可否は E2E の「検索がヒットする」spec の成否と、必要なら `TELOS_LOG_FILE=log.txt` などでログをファイルに出して確認する。 + +## 注意 + +- テスト実行中は TelosDB のウィンドウが開きます。操作は WebDriver が行うため、手で触らないでください。 +- MCP サーバ(3001)がアプリ起動時に立ち上がるため、ポートが他プロセスで占有されていないことを確認してください。 diff --git a/tests/e2e/helpers/wait-for-app.js b/tests/e2e/helpers/wait-for-app.js new file mode 100644 index 0000000..128039f --- /dev/null +++ b/tests/e2e/helpers/wait-for-app.js @@ -0,0 +1,28 @@ +/** + * E2E でアプリの起動が完了するまで待機する。 + * 1) フロントのメイン UI(#query)が表示されるまで待つ + * 2) MCP (3001) の /edition が応答するまでポーリング + * + * 各 spec の before() で呼ぶこと。 + * @param {import('@wdio/globals').browser} browser - WDIO browser + * @param {{ uiTimeout?: number, mcpTimeout?: number, mcpInterval?: number }} [opts] + */ +export async function waitForAppReady(browser, opts = {}) { + const uiTimeout = opts.uiTimeout ?? 20000; + const mcpMaxAttempts = Math.floor((opts.mcpTimeout ?? 20000) / (opts.mcpInterval ?? 500)); + const mcpIntervalMs = opts.mcpInterval ?? 500; + + const query = await browser.$('#query'); + await query.waitForDisplayed({ timeout: uiTimeout }); + + for (let i = 0; i < mcpMaxAttempts; i++) { + const ok = await browser.executeAsync(function (done) { + fetch('http://127.0.0.1:3001/edition') + .then((r) => done(r.ok)) + .catch(() => done(false)); + }); + if (ok) return; + await browser.pause(mcpIntervalMs); + } + throw new Error('MCP (3001) did not become ready in time'); +} diff --git a/tests/e2e/screenshots/docs-edit-modal.png b/tests/e2e/screenshots/docs-edit-modal.png new file mode 100644 index 0000000..d82c10a --- /dev/null +++ b/tests/e2e/screenshots/docs-edit-modal.png Binary files differ diff --git a/tests/e2e/specs/app.spec.js b/tests/e2e/specs/app.spec.js new file mode 100644 index 0000000..82da144 --- /dev/null +++ b/tests/e2e/specs/app.spec.js @@ -0,0 +1,138 @@ +/** + * TelosDB の E2E スペック(WebDriver + tauri-driver)。 + * ウィンドウが開き、ヘッダー・検索 UI が表示され、検索が実際にヒットすることを検証する。 + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { waitForAppReady } from '../helpers/wait-for-app.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')); +const EXPECTED_APP_VERSION = pkg.version; + +const API_BASE = 'http://127.0.0.1:3001'; +const E2E_SEARCH_PHRASE = 'E2E検索テスト用の文書'; +const E2E_DOC_CONTENT = `${E2E_SEARCH_PHRASE}です。この文字列でヒットすることを確認する。`; + +async function waitForMcp(maxAttempts = 15, intervalMs = 500) { + for (let i = 0; i < maxAttempts; i++) { + try { + const res = await fetch(`${API_BASE}/edition`); + if (res.ok) return; + } catch (_) {} + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error('MCP (3001) did not become ready in time'); +} + +async function addDocumentViaMcp(content, path) { + const res = await fetch(`${API_BASE}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'add_item_text', + arguments: { content, path }, + }, + id: Date.now(), + }), + }); + const data = await res.json(); + if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); + return data; +} + +describe('TelosDB', () => { + before(async function () { + this.timeout(45000); + await waitForAppReady(browser); + }); + + it('ウィンドウのタイトルが TelosDB である', async () => { + const title = await browser.getTitle(); + expect(title).toMatch(/TelosDB/i); + }); + + it('ヘッダーに TelosDB のロゴ・テキストが表示される', async () => { + const logo = await $('.site-header .logo-mark'); + await expect(logo).toBeDisplayed(); + await expect(logo).toHaveText('TelosDB'); + }); + + it('検索入力と検索ボタンが表示される', async () => { + const searchInput = await $('#query'); + const searchBtn = await $('.search-btn'); + await expect(searchInput).toBeDisplayed(); + await expect(searchBtn).toBeDisplayed(); + await expect(searchBtn).toHaveText('検索'); + }); + + it('サイドバーに検索・文書管理・設定の3つのナビが表示される', async () => { + const navItems = await $$('.sidebar-nav .nav-item'); + expect(navItems.length).toBeGreaterThanOrEqual(3); + const texts = await Promise.all(navItems.slice(0, 3).map((el) => el.getText())); + expect(texts.some((t) => /検索/.test(t))).toBe(true); + expect(texts.some((t) => /文書/.test(t))).toBe(true); + expect(texts.some((t) => /設定/.test(t))).toBe(true); + }); + + it('フッターにバージョンが表示される', async () => { + const versionEl = await $('.site-footer .footer-version'); + await expect(versionEl).toBeDisplayed(); + const text = await versionEl.getText(); + expect(text).toMatch(/^v[\d.]+/); + }); + + it('フッターのバージョンがビルドバージョン(package.json)と一致する', async () => { + const versionEl = await $('.site-footer .footer-version'); + await expect(versionEl).toBeDisplayed(); + await browser.waitUntil( + async () => (await versionEl.getText()) === `v${EXPECTED_APP_VERSION}`, + { timeout: 10000, interval: 500 } + ); + const text = await versionEl.getText(); + expect(text).toBe(`v${EXPECTED_APP_VERSION}`); + }); + + it('検索パネル初期表示で結果エリアに empty-state が表示される', async () => { + const resultPanel = await $('#result'); + await expect(resultPanel).toBeDisplayed(); + const emptyState = await resultPanel.$('.empty-state'); + await expect(emptyState).toBeDisplayed(); + }); + + it('検索で0件でも結果エリアが表示される(エラーにならない)', async () => { + await waitForMcp(); + const searchInput = await $('#query'); + const searchBtn = await $('.search-btn'); + const resultPanel = await $('#result'); + await searchInput.setValue('E2E_存在しない文言_xyz_' + Date.now()); + await searchBtn.click(); + await browser.pause(2000); + await expect(resultPanel).toBeDisplayed(); + }); + + it('検索がヒットする(テスト用文書を登録し、その文言で検索して結果が返る)', async () => { + await waitForMcp(); + await addDocumentViaMcp(E2E_DOC_CONTENT, 'e2e-test-search-doc.txt'); + await browser.pause(2500); + + const searchInput = await $('#query'); + const searchBtn = await $('.search-btn'); + const resultPanel = await $('#result'); + await searchInput.setValue(E2E_SEARCH_PHRASE); + await searchBtn.click(); + await browser.pause(2500); + + await expect(resultPanel).toBeDisplayed(); + const resultCards = await $$('.result-card'); + expect(resultCards.length).toBeGreaterThanOrEqual(1); + + const firstCardText = await resultCards[0].getText(); + expect(firstCardText).toContain(E2E_SEARCH_PHRASE); + }); +}); diff --git a/tests/e2e/specs/docs-edit-drawing.spec.js b/tests/e2e/specs/docs-edit-drawing.spec.js new file mode 100644 index 0000000..a65e3dc --- /dev/null +++ b/tests/e2e/specs/docs-edit-drawing.spec.js @@ -0,0 +1,110 @@ +/** + * 文書編集モーダルの描画確認テスト。 + * 保存・キャンセルボタンの表示、Toast UI ツールバーおよびアイコンの描画を検証する。 + * + * 実行: npm run test:e2e + * このスペックのみ: npx wdio run wdio.conf.js --spec tests/e2e/specs/docs-edit-drawing.spec.js + */ + +import { waitForAppReady } from '../helpers/wait-for-app.js'; + +describe('文書編集モーダル 描画確認', () => { + const openDocsPanel = async () => { + const navDocs = await $('button[data-panel="docs"]'); + await navDocs.click(); + const panel = await $('#panel-docs'); + await panel.waitForDisplayed({ timeout: 5000 }); + await expect(panel).not.toHaveElementClass('hidden'); + }; + + const openEditModal = async () => { + const addBtn = await $('#docs-add-btn'); + await addBtn.waitForDisplayed({ timeout: 3000 }); + await addBtn.click(); + const modal = await $('#docs-edit-modal'); + await modal.waitForDisplayed({ timeout: 5000 }); + await expect(modal).not.toHaveElementClass('hidden'); + // Toast UI エディタのマウント待ち + await browser.pause(1500); + }; + + before(async function () { + this.timeout(45000); + await waitForAppReady(browser); + await openDocsPanel(); + await openEditModal(); + }); + + it('編集モーダルが表示されている', async () => { + const modal = await $('#docs-edit-modal'); + await expect(modal).toBeDisplayed(); + await expect(modal).not.toHaveElementClass('hidden'); + }); + + it('保存・キャンセルボタンが表示されている', async () => { + const saveBtn = await $('#docs-save-btn'); + const cancelBtn = await $('#docs-cancel-btn'); + await expect(saveBtn).toBeDisplayed(); + await expect(cancelBtn).toBeDisplayed(); + await expect(saveBtn).toHaveText('保存'); + await expect(cancelBtn).toHaveText('キャンセル'); + }); + + it('保存・キャンセルボタンが視認可能なスタイルを持つ', async () => { + const saveBtn = await $('#docs-save-btn'); + const cancelBtn = await $('#docs-cancel-btn'); + const saveColor = await saveBtn.getCSSProperty('color'); + const cancelColor = await cancelBtn.getCSSProperty('color'); + const saveBg = await saveBtn.getCSSProperty('background-color'); + const cancelBg = await cancelBtn.getCSSProperty('background-color'); + // 透明でないこと(視認可能) + expect(saveColor.value).not.toMatch(/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/i); + expect(cancelColor.value).not.toMatch(/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/i); + expect(saveBg.value).not.toMatch(/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/i); + expect(cancelBg.value).not.toMatch(/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/i); + }); + + it('Toast UI エディタのツールバーが表示されている', async () => { + const toolbar = await $('.docs-editor-container .toastui-editor-toolbar'); + await toolbar.waitForExist({ timeout: 5000 }); + await expect(toolbar).toBeDisplayed(); + }); + + it('ツールバーに操作可能な要素がある(アイコンは background-image で描画される想定)', async () => { + const toolbar = await $('.docs-editor-container .toastui-editor-toolbar'); + await expect(toolbar).toBeDisplayed(); + const buttons = await $$('.docs-editor-container .toastui-editor-toolbar button'); + expect(buttons.length).toBeGreaterThan(0); + let withImage = 0; + for (const el of buttons) { + const bgImage = await el.getCSSProperty('background-image'); + if (bgImage.value && bgImage.value !== 'none') withImage += 1; + } + if (withImage === 0) { + console.warn('[描画確認] ツールバー内のいずれの要素も background-image が none です。アイコンが非表示の可能性があります。'); + } + }); + + it('エディタのメインUI(ツールバーまたはタブ・モード切替)が存在する', async () => { + const defaultUI = await $('.docs-editor-container .toastui-editor-defaultUI'); + const toolbar = await $('.docs-editor-container .toastui-editor-toolbar'); + const tabs = await $('.docs-editor-container .toastui-editor-tabs'); + const modeSwitch = await $('.docs-editor-container [class*="mode-switch"]'); + const hasUI = (await defaultUI.isDisplayed()) || (await toolbar.isDisplayed()); + const hasTabsOrMode = + ((await tabs.isDisplayed()) && (await $$('.docs-editor-container .toastui-editor-tabs button')).length >= 2) || + ((await modeSwitch.isDisplayed()) && (await $$('.docs-editor-container [class*="mode-switch"] button')).length >= 2); + expect(hasUI).toBe(true); + if (!hasTabsOrMode) { + console.warn('[描画確認] タブ/モード切替は見つかりませんでした。initialEditType や DOM 構造により非表示の可能性があります。'); + } + }); + + it('キャンセルボタンでモーダルが閉じる', async () => { + const cancelBtn = await $('#docs-cancel-btn'); + await cancelBtn.click(); + await browser.pause(500); + const modal = await $('#docs-edit-modal'); + await expect(modal).toHaveElementClass('hidden'); + }); +}); diff --git a/tests/e2e/specs/panels.spec.js b/tests/e2e/specs/panels.spec.js new file mode 100644 index 0000000..ec7c38e --- /dev/null +++ b/tests/e2e/specs/panels.spec.js @@ -0,0 +1,51 @@ +/** + * 各パネル(検索・文書管理・設定)に遷移したときに表示が切り替わることを検証する。 + */ + +import { waitForAppReady } from '../helpers/wait-for-app.js'; + +describe('パネル表示', () => { + before(async function () { + this.timeout(45000); + await waitForAppReady(browser); + }); + + it('検索パネルが表示される', async () => { + const navSearch = await $('button[data-panel="search"]'); + await navSearch.click(); + const panel = await $('#panel-search'); + await panel.waitForDisplayed({ timeout: 5000 }); + await expect(panel).not.toHaveElementClass('hidden'); + const query = await $('#query'); + const searchBtn = await $('.search-btn'); + await expect(query).toBeDisplayed(); + await expect(searchBtn).toBeDisplayed(); + }); + + it('文書管理パネルが表示される', async () => { + const navDocs = await $('button[data-panel="docs"]'); + await navDocs.click(); + const panel = await $('#panel-docs'); + await panel.waitForDisplayed({ timeout: 5000 }); + await expect(panel).not.toHaveElementClass('hidden'); + const addBtn = await $('#docs-add-btn'); + await addBtn.waitForDisplayed({ timeout: 3000 }); + await expect(addBtn).toBeDisplayed(); + const refreshBtn = await $('#docs-refresh-btn'); + await expect(refreshBtn).toBeDisplayed(); + const listArea = await $('#docs-list'); + await expect(listArea).toBeDisplayed(); + }); + + it('設定パネルが表示される', async () => { + const navSettings = await $('button[data-panel="settings"]'); + await navSettings.click(); + const panel = await $('#panel-settings'); + await panel.waitForDisplayed({ timeout: 5000 }); + await expect(panel).not.toHaveElementClass('hidden'); + const form = await $('.settings-form'); + await expect(form).toBeDisplayed(); + const saveBtn = await $('#settings-save-btn'); + await expect(saveBtn).toBeDisplayed(); + }); +}); diff --git a/tests/e2e/specs/screenshot-docs-modal.spec.js b/tests/e2e/specs/screenshot-docs-modal.spec.js new file mode 100644 index 0000000..813dbe8 --- /dev/null +++ b/tests/e2e/specs/screenshot-docs-modal.spec.js @@ -0,0 +1,39 @@ +/** + * 編集モーダルを開いてスクリーンショットを保存するだけのスペック。 + * UI をこちらで確認しながらスタイル調整するために使う。 + * + * 実行: npm run test:e2e:screenshot + * 出力: tests/e2e/screenshots/docs-edit-modal.png + */ + +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { waitForAppReady } from '../helpers/wait-for-app.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCREENSHOT_DIR = path.resolve(__dirname, '../screenshots'); +const SCREENSHOT_PATH = path.join(SCREENSHOT_DIR, 'docs-edit-modal.png'); + +describe('編集モーダル スクリーンショット取得', () => { + before(async function () { + this.timeout(45000); + await waitForAppReady(browser); + const navDocs = await $('button[data-panel="docs"]'); + await navDocs.click(); + const panel = await $('#panel-docs'); + await panel.waitForDisplayed({ timeout: 5000 }); + const addBtn = await $('#docs-add-btn'); + await addBtn.waitForDisplayed({ timeout: 3000 }); + await addBtn.click(); + const modal = await $('#docs-edit-modal'); + await modal.waitForDisplayed({ timeout: 5000 }); + await browser.pause(1500); + }); + + it('スクリーンショットを保存する', async () => { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + await browser.saveScreenshot(SCREENSHOT_PATH); + console.log('[screenshot] 保存先:', SCREENSHOT_PATH); + }); +}); diff --git a/tests/test_mcp_client.mjs b/tests/test_mcp_client.mjs index 423978a..0761e63 100644 --- a/tests/test_mcp_client.mjs +++ b/tests/test_mcp_client.mjs @@ -1,5 +1,15 @@ /** * TelosDB MCP クライアントテスト。 + * ビルドバージョン(package.json)と API /version(Cargo.toml 由来)の一致も検証する。 + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PKG = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8")); + +/** * 前提: MCP が 127.0.0.1:3001 で起動していること(npm run test-and-heal または test-and-heal:pro で起動+実行される)。 * * テスト内容: @@ -7,6 +17,9 @@ * [2] search_text * [3] Pro: スコアが全件 0.4 でないこと(警告のみの緩いチェック) * [4] Pro: ベクトル化 E2E — RE-INDEX 実行 → indexing_status が idle になるまで待機 → 検索 → vector_search_used が true かつスコアが全件 0.4 でないことを検証(ここで失敗すればベクトル化が効いていない) + * [5] get_item_by_id(不正 id で isError) + * [6] get_document_count + * [7] add_item_text → list_documents → get_document → get_item_by_id → update_item → delete_item → delete_document(CRUD 一連) */ import axios from 'axios'; @@ -121,6 +134,66 @@ console.log(" Pro vectorization E2E: vector_search_used=true, スコア差あり (OK)"); } +function parseResultText(result) { + const text = result?.result?.content?.[0]?.text; + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function testMcpCrud(axiosInstance) { + console.log("\n[5] get_item_by_id with invalid id returns error..."); + const badItemRes = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: -1 } }); + if (!badItemRes.isError) throw new Error("get_item_by_id(-1): expected isError true"); + console.log(" get_item_by_id(-1) isError OK."); + + const path = "integration-test-" + Date.now() + ".txt"; + const content = "結合テスト用ドキュメントです。get_document_count / list_documents / get_document / get_item_by_id / update_item / delete_item / delete_document を検証します。"; + + console.log("\n[6] get_document_count..."); + const countRes = await postMessage(axiosInstance, "tools/call", { name: "get_document_count", arguments: {} }); + const countData = parseResultText(countRes); + const count = typeof countData === "number" ? countData : (countData?.count ?? 0); + if (typeof count !== "number" || count < 0) throw new Error("get_document_count: invalid result " + JSON.stringify(countData)); + console.log(" Count:", count); + + console.log("\n[7] add_item_text → list_documents → get_document → get_item_by_id → update_item → delete_item → delete_document..."); + await postMessage(axiosInstance, "tools/call", { name: "add_item_text", arguments: { path, content } }); + + const listRes = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: {} }); + const list = parseResultText(listRes); + const docList = Array.isArray(list) ? list : []; + const doc = docList.find((d) => d.path === path); + if (!doc || doc.id == null) throw new Error("list_documents: added doc not found or no id. list=" + JSON.stringify(docList).slice(0, 200)); + + const getDocRes = await postMessage(axiosInstance, "tools/call", { name: "get_document", arguments: { document_id: doc.id } }); + const getDoc = parseResultText(getDocRes); + if (!getDoc || !Array.isArray(getDoc.chunks) || getDoc.chunks.length === 0) throw new Error("get_document: no chunks. " + JSON.stringify(getDoc).slice(0, 200)); + const chunkId = getDoc.chunks[0].id; + + const getItemRes = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: chunkId } }); + const getItem = getItemRes.result && !getItemRes.isError ? getItemRes.result : null; + if (!getItem || getItem.content !== content) throw new Error("get_item_by_id: content mismatch"); + + const updatedContent = "更新後のチャンク内容"; + await postMessage(axiosInstance, "tools/call", { name: "update_item", arguments: { id: chunkId, content: updatedContent } }); + const getItem2Res = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: chunkId } }); + const getItem2 = getItem2Res.result && !getItem2Res.isError ? getItem2Res.result : null; + if (!getItem2 || getItem2.content !== updatedContent) throw new Error("update_item: content not updated. got=" + (getItem2?.content ?? "")); + + await postMessage(axiosInstance, "tools/call", { name: "delete_item", arguments: { id: chunkId } }); + await postMessage(axiosInstance, "tools/call", { name: "delete_document", arguments: { document_id: doc.id } }); + + const listRes2 = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: {} }); + const list2 = parseResultText(listRes2); + const docList2 = Array.isArray(list2) ? list2 : []; + if (docList2.some((d) => d.path === path)) throw new Error("delete_document: document still in list"); + console.log(" CRUD flow OK."); +} + async function testMcp() { console.log("=== TelosDB MCP Tool Test ==="); const axiosInstance = axios.create({ baseURL: API_BASE }); @@ -128,7 +201,25 @@ try { console.log("\n[1] Listing tools..."); const toolsResult = await postMessage(axiosInstance, "tools/list"); - console.log(" Success: Found", toolsResult.result?.tools?.length, "tools."); + const toolCount = toolsResult.result?.tools?.length ?? 0; + if (toolCount < 10) throw new Error("tools/list: expected at least 10 tools, got " + toolCount); + console.log(" Success: Found", toolCount, "tools."); + + const editionRes = await axiosInstance.get("/edition"); + const edition = editionRes.data?.edition; + if (edition !== "community" && edition !== "pro") throw new Error("/edition: expected community or pro, got " + JSON.stringify(editionRes.data)); + console.log(" /edition:", edition); + + const statusRes = await axiosInstance.get("/indexing_status"); + const status = statusRes.data?.status; + if (typeof status !== "string") throw new Error("/indexing_status: expected status string, got " + JSON.stringify(statusRes.data)); + console.log(" /indexing_status:", status); + + const versionRes = await axiosInstance.get("/version"); + if (!versionRes.data || typeof versionRes.data.version !== "string") throw new Error("/version: expected { version: string }, got " + JSON.stringify(versionRes.data)); + const apiVersion = versionRes.data.version; + if (apiVersion !== PKG.version) throw new Error("/version (" + apiVersion + ") が package.json の version (" + PKG.version + ") と一致しません。Cargo.toml と package.json を揃えてください。"); + console.log(" /version:", apiVersion, "(package.json と一致)"); console.log("\n[2] Testing search_text..."); const searchResult = await postMessage(axiosInstance, "tools/call", { @@ -144,6 +235,8 @@ console.log("\n[4] Pro: vectorization E2E (RE-INDEX → 完了待ち → 検索で vector_search_used 検証)..."); await testProVectorizationE2E(axiosInstance); + await testMcpCrud(axiosInstance); + console.log("\n=== Test Finished ==="); } catch (e) { console.error("\nTest failed:", e.message || e); diff --git a/wdio.conf.js b/wdio.conf.js index d6a9049..ac347f2 100644 --- a/wdio.conf.js +++ b/wdio.conf.js @@ -65,7 +65,7 @@ export const config = { host: '127.0.0.1', port: 4444, - specs: ['./e2e-tests/specs/**/*.spec.js'], + specs: ['./tests/e2e/specs/**/*.spec.js'], maxInstances: 1, capabilities: [ { @@ -83,11 +83,13 @@ }, onPrepare: () => { killE2ePorts(); - spawnSync('npm', ['run', 'build:e2e'], { - cwd: root, - stdio: 'inherit', - shell: true, - }); + if (!process.env.TELOS_E2E_PRO) { + spawnSync('npm', ['run', 'build:e2e'], { + cwd: root, + stdio: 'inherit', + shell: true, + }); + } }, onComplete: () => { closeTauriDriver();