From 5ebaf60f4531574ca507240218bb0600d541a91d Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Sun, 21 Jun 2026 05:16:37 -0700 Subject: [PATCH] feat: improve pipeline builder workflows --- scripts/e2e/run-playwright-smoke.js | 75 ++++++++++++++++ src/app/[lang]/page.tsx | 17 ++-- src/components/layout/navbar-mobile-menu.tsx | 15 ++++ src/components/layout/navbar.tsx | 14 ++- src/components/layout/server-navbar.tsx | 1 + src/core/i18n/translations/de.json | 23 +++-- src/core/i18n/translations/en.json | 23 +++-- src/core/i18n/translations/fr.json | 23 +++-- src/core/i18n/translations/ja.json | 23 +++-- src/core/i18n/translations/ko.json | 23 +++-- src/core/i18n/translations/zh-CN.json | 23 +++-- src/core/i18n/translations/zh-TW.json | 23 +++-- src/features/pipeline/recipe-codec.ts | 15 ++-- src/features/pipeline/recipe-import-export.ts | 3 +- src/features/pipeline/recipe-store.ts | 29 +++--- src/features/pipeline/recipe-templates.ts | 54 ++++++++---- src/features/tools/pipeline-builder/page.tsx | 11 ++- .../pipeline-builder/pipeline-step-list.tsx | 17 +++- src/lib/sitemap-lastmod.json | 14 +-- tests/component/layout-components.test.tsx | 2 + .../phase3-pipeline-builder-page.test.tsx | 67 +++++++++++++- tests/unit/pipeline-foundation.test.ts | 88 +++++++++++++++---- 22 files changed, 448 insertions(+), 135 deletions(-) diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index ed9a0309..5396e619 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -1,5 +1,6 @@ import { createServer } from "node:http"; import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { chromium } from "playwright"; @@ -712,11 +713,85 @@ async function assertPipelineRecipeJourney(context, baseUrl) { await page.waitForSelector("main", { timeout: 15_000 }); await page.getByRole("button", { name: /Try Example/i }).first().click(); + await page.getByLabel("Recipe name").fill("Smoke saved recipe"); + await page.getByLabel("Initial input").fill('{ "apiKey": "runtime-secret-value-987", "ok": true }'); await page.getByRole("button", { name: /Run Recipe/i }).first().click(); await page.waitForFunction(() => { const readonlyOutput = Array.from(document.querySelectorAll("textarea")).find((node) => node.readOnly); return Boolean(readonlyOutput?.value.trim()) && document.body.innerText.includes("OK"); }, null, { timeout: 15_000 }); + + const saveButton = page.getByRole("button", { name: /^Save$/ }).first(); + await page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll("button")); + return buttons.some((button) => button.textContent?.trim() === "Save" && !button.disabled); + }, null, { timeout: 15_000 }); + await saveButton.click(); + await page.waitForFunction(() => { + const select = document.querySelector("[aria-label='Select saved recipe']"); + if (!(select instanceof HTMLSelectElement)) return false; + return Array.from(select.options).some((option) => option.textContent?.trim() === "Smoke saved recipe"); + }, null, { timeout: 15_000 }); + + const storedRecipes = await page.evaluate(async () => { + return await new Promise((resolve, reject) => { + const request = indexedDB.open("byteflow-pipeline-recipes"); + request.onerror = () => reject(request.error ?? new Error("Unable to open recipe store.")); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction("recipes", "readonly"); + const getAll = tx.objectStore("recipes").getAll(); + getAll.onerror = () => reject(getAll.error ?? new Error("Unable to read saved recipes.")); + getAll.onsuccess = () => { + db.close(); + resolve(getAll.result); + }; + }; + }); + }); + const serializedSavedRecipes = JSON.stringify(storedRecipes); + if (!serializedSavedRecipes.includes("Smoke saved recipe")) { + throw new Error("Pipeline Builder did not save the smoke recipe locally."); + } + if (serializedSavedRecipes.includes("runtime-secret-value-987")) { + throw new Error("Pipeline Builder saved runtime input in IndexedDB."); + } + + await page.getByLabel("Recipe name").fill("Unsaved scratch recipe"); + await page.getByLabel("Select saved recipe").selectOption({ label: "Smoke saved recipe" }); + await page.getByRole("button", { name: /^Load$/ }).click(); + await page.waitForFunction(() => { + const input = document.querySelector("#recipe-name"); + return input instanceof HTMLInputElement && input.value === "Smoke saved recipe"; + }, null, { timeout: 15_000 }); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: /^Export JSON$/ }).first().click(); + const download = await downloadPromise; + const downloadPath = await download.path(); + if (!downloadPath) { + throw new Error("Pipeline Builder export did not produce a downloadable file."); + } + const exportedRecipeJson = await readFile(downloadPath, "utf8"); + const exportedRecipe = JSON.parse(exportedRecipeJson); + if (exportedRecipeJson.includes("runtime-secret-value-987")) { + throw new Error("Pipeline Builder exported runtime input in recipe JSON."); + } + if (!Array.isArray(exportedRecipe.steps) || exportedRecipe.steps.length < 2) { + throw new Error("Pipeline Builder export did not include recipe steps."); + } + + await page.locator('input[type="file"]').setInputFiles({ + name: "smoke-recipe.json", + mimeType: "application/json", + buffer: Buffer.from(exportedRecipeJson), + }); + await page.waitForFunction(() => { + const input = document.querySelector("#recipe-name"); + return input instanceof HTMLInputElement && input.value === "Smoke saved recipe"; + }, null, { timeout: 15_000 }); + await page.getByRole("button", { name: /Run Recipe/i }).first().click(); + await page.waitForFunction(() => document.body.innerText.includes("OK"), null, { timeout: 15_000 }); await assertBasicAccessibility(page, "/en/pipeline-builder recipe"); if (runtimeErrors.length > 0) { diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index d586bed1..3ac81f43 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -194,11 +194,18 @@ export default async function Home({ params }: { params: Promise<{ lang: string

- - + + {getLocalizedToolTitle("pipeline_builder")} + + {installAppLinkLabel} diff --git a/src/components/layout/navbar-mobile-menu.tsx b/src/components/layout/navbar-mobile-menu.tsx index ec4c37a3..a78ca231 100644 --- a/src/components/layout/navbar-mobile-menu.tsx +++ b/src/components/layout/navbar-mobile-menu.tsx @@ -51,6 +51,21 @@ export function NavbarMobileMenu({ > {t.nav.navigation}
+ + + {(t.tools["pipeline_builder"] as { title?: string } | undefined)?.title ?? "Pipeline Builder"} + + {CATEGORY_LINKS.map((cat) => { const href = getCategoryHref(cat.slug) return ( diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 4579746f..ace806d9 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { ArrowUpRight, Search } from "lucide-react" +import { ArrowUpRight, Search, Workflow } from "lucide-react" import { DeferredMobileNavMenu } from "./deferred-mobile-nav-menu" import { DeferredNavbarControls } from "./deferred-navbar-controls" import { Button } from "@/components/ui/button" @@ -10,6 +10,7 @@ import { cn } from "@/core/utils/utils" export type NavbarLabels = { allTools: string openNavigation: string + pipelineBuilder: string search: string } @@ -64,6 +65,17 @@ export function Navbar({
+ + + {labels.pipelineBuilder} + + diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 9abf2133..573bd168 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -2461,21 +2461,15 @@ "pipeline_builder": { "title": "Pipeline-Ersteller", "description": "Verkettet lokale Entwicklerwerkzeuge zu wiederholbaren Browser-Rezepten mit Import, Export, Teilen und lokalem Speichern.", - "privacy_note": "Rezepte laufen vollständig in diesem Browser. Geteilte URLs enthalten standardmäßig nur Workflow-Struktur und öffentliche Optionen, keine Laufzeiteingaben.", - "share_runtime_input_hint": "Share-URLs enthalten nur Schrittstruktur und öffentliche Optionen. Konstante Schritteingaben bleiben lokal, außer beim JSON-Export.", + "privacy_note": "Rezepte laufen vollständig in diesem Browser. Speichern, Export und Teilen enthalten standardmäßig nur Workflow-Struktur und öffentliche Optionen, keine Laufzeiteingaben.", + "share_runtime_input_hint": "Speichern, Export und Teilen behalten nur Schrittstruktur und öffentliche Optionen. Konstante Schritteingaben werden nur für den aktuellen Lauf verwendet und aus gespeicherten Recipe-JSON entfernt.", "storage_unavailable": "IndexedDB ist nicht verfügbar; lokal gespeicherte Rezepte sind in diesem Browser deaktiviert.", "templates_title": "Integrierte Rezepte", "templates_description": "Starten Sie mit lokalen, datenschutzfreundlichen Workflows und passen Sie die Schritte vor dem Ausführen an.", "use_template": "Nutzen", "template_loaded": "Rezeptvorlage geladen", - "template_json_minify_base64url_title": "JSON minifizieren zu URL-sicherem Base64", - "template_json_minify_base64url_description": "Minifiziert eine JSON-Nutzlast und kodiert sie für URLs, Umgebungswerte oder Testdaten.", - "template_url_decode_json_title": "URL dekodieren und JSON formatieren", - "template_url_decode_json_description": "Dekodiert einen URL-kodierten JSON-Blob und formatiert ihn zur Prüfung.", "template_clean_copied_config_title": "Kopierten Konfigurationstext bereinigen", "template_clean_copied_config_description": "Entfernt unsichtbare Zeichen, normalisiert Leerzeichen und reduziert versehentliche Mehrfachabstände.", - "template_scrub_log_secrets_title": "Logs bereinigen und Secrets maskieren", - "template_scrub_log_secrets_description": "Normalisiert eingefügte Logs und maskiert Tokens, E-Mails, IPs und Zugangsdaten vor dem Teilen.", "template_step_minify_json": "JSON minifizieren", "template_step_base64url_encode": "Base64 URL-sicher kodieren", "template_step_url_decode": "URL-Komponente dekodieren", @@ -2566,7 +2560,18 @@ "choice_minify": "Minifizieren", "choice_component": "Komponente", "choice_full": "Vollständige URL", - "choice_reserved": "Reservierte Zeichen" + "choice_reserved": "Reservierte Zeichen", + "template_api_payload_cleanup_title": "API-Payload bereinigen", + "template_api_payload_cleanup_description": "Minifiziert eine JSON-Nutzlast und kodiert sie für URLs, Umgebungswerte oder Testdaten.", + "template_url_json_cleanup_title": "URL-JSON bereinigen", + "template_url_json_cleanup_description": "Dekodiert einen URL-kodierten JSON-Blob und formatiert ihn zur Prüfung.", + "template_security_token_review_title": "Security-Token prüfen", + "template_security_token_review_description": "Dekodiert ein Beispiel-JWT-Payload lokal und formatiert die Claims zur Prüfung, ohne die Signatur zu verifizieren.", + "template_log_scrub_before_sharing_title": "Logs vor dem Teilen maskieren", + "template_log_scrub_before_sharing_description": "Normalisiert eingefügte Logs und maskiert Tokens, E-Mails, IPs und Zugangsdaten vor dem Teilen.", + "template_step_decode_jwt_payload": "JWT-Payload dekodieren", + "step_io_hint": "{input}-Eingabe -> {output}-Ausgabe", + "external_request_step_notice": "Externer Request-Schritt: Netzwerkziel vor dem Ausführen prüfen." }, "saml_decoder": { "title": "SAML-Dekoder", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 043d0cd9..b8aaa257 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -2481,21 +2481,15 @@ "pipeline_builder": { "title": "Pipeline Builder", "description": "Chain local developer tools into repeatable browser-only recipes with import, export, sharing, and local saves.", - "privacy_note": "Recipes run in this browser. Shared URLs include workflow structure and public options only; runtime input is excluded by default.", - "share_runtime_input_hint": "Share URLs keep step structure and public options only. Constant step inputs stay local unless exported as JSON.", + "privacy_note": "Recipes run in this browser. Save, export, and share keep workflow structure and public options only; runtime input is excluded by default.", + "share_runtime_input_hint": "Save, export, and share keep step structure and public options only. Constant step input is used only for the current run and is stripped from saved recipe JSON.", "storage_unavailable": "IndexedDB is unavailable, so local saved recipes are disabled in this browser.", "templates_title": "Built-in recipes", "templates_description": "Start from local, privacy-safe workflows and edit the steps before running.", "use_template": "Use", "template_loaded": "Recipe template loaded", - "template_json_minify_base64url_title": "JSON minify to URL-safe Base64", - "template_json_minify_base64url_description": "Minify a JSON payload, then encode it for URLs, env values, or test fixtures.", - "template_url_decode_json_title": "URL decode and pretty-print JSON", - "template_url_decode_json_description": "Decode a URL-encoded JSON blob and format it for review.", "template_clean_copied_config_title": "Clean copied config text", "template_clean_copied_config_description": "Remove invisible characters, normalize spaces, and collapse accidental whitespace.", - "template_scrub_log_secrets_title": "Clean and scrub log secrets", - "template_scrub_log_secrets_description": "Normalize pasted logs, then redact tokens, emails, IPs, and credentials before sharing.", "template_step_minify_json": "Minify JSON", "template_step_base64url_encode": "Base64 URL-safe encode", "template_step_url_decode": "URL component decode", @@ -2586,7 +2580,18 @@ "choice_minify": "Minify", "choice_component": "Component", "choice_full": "Full URL", - "choice_reserved": "Reserved chars" + "choice_reserved": "Reserved chars", + "template_api_payload_cleanup_title": "API payload cleanup", + "template_api_payload_cleanup_description": "Minify a JSON payload, then encode it for URLs, env values, or test fixtures.", + "template_url_json_cleanup_title": "URL JSON cleanup", + "template_url_json_cleanup_description": "Decode a URL-encoded JSON blob and format it for review.", + "template_security_token_review_title": "Security token review", + "template_security_token_review_description": "Decode a sample JWT payload locally, then format the claims for review without verifying the signature.", + "template_log_scrub_before_sharing_title": "Log scrub before sharing", + "template_log_scrub_before_sharing_description": "Normalize pasted logs, then redact tokens, emails, IPs, and credentials before sharing.", + "template_step_decode_jwt_payload": "Decode JWT payload", + "step_io_hint": "{input} input -> {output} output", + "external_request_step_notice": "External request step: confirm the network target before running." }, "saml_decoder": { "title": "SAML Decoder", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 978284e1..46766204 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -2461,21 +2461,15 @@ "pipeline_builder": { "title": "Constructeur de pipeline", "description": "Chaînez des outils développeur locaux en recettes répétables dans le navigateur, avec import, export, partage et sauvegarde locale.", - "privacy_note": "Les recettes s’exécutent entièrement dans ce navigateur. Les URL partagées n’incluent par défaut que la structure et les options publiques, pas les entrées d’exécution.", - "share_runtime_input_hint": "Les URL de partage ne gardent que la structure des étapes et les options publiques. Les entrées constantes restent locales sauf export JSON.", + "privacy_note": "Les recettes s’exécutent entièrement dans ce navigateur. Sauvegarde, export et partage gardent par défaut uniquement la structure et les options publiques, pas les entrées d’exécution.", + "share_runtime_input_hint": "Sauvegarde, export et partage ne gardent que la structure des étapes et les options publiques. Les entrées constantes servent uniquement à l’exécution courante et sont retirées du JSON de recette sauvegardé.", "storage_unavailable": "IndexedDB est indisponible ; les recettes locales sauvegardées sont désactivées dans ce navigateur.", "templates_title": "Recettes intégrées", "templates_description": "Démarrez avec des workflows locaux respectueux de la confidentialité, puis ajustez les étapes avant exécution.", "use_template": "Utiliser", "template_loaded": "Modèle de recette chargé", - "template_json_minify_base64url_title": "Minifier JSON vers Base64 compatible URL", - "template_json_minify_base64url_description": "Minifie une charge utile JSON, puis l’encode pour les URL, variables d’environnement ou jeux de test.", - "template_url_decode_json_title": "Décoder une URL et formater le JSON", - "template_url_decode_json_description": "Décode un blob JSON encodé en URL et le formate pour inspection.", "template_clean_copied_config_title": "Nettoyer un texte de configuration copié", "template_clean_copied_config_description": "Supprime les caractères invisibles, normalise les espaces et réduit les blancs accidentels.", - "template_scrub_log_secrets_title": "Nettoyer et masquer les secrets de logs", - "template_scrub_log_secrets_description": "Normalise les logs collés, puis masque tokens, e-mails, IP et identifiants avant partage.", "template_step_minify_json": "Minifier JSON", "template_step_base64url_encode": "Encoder en Base64 compatible URL", "template_step_url_decode": "Décoder le composant URL", @@ -2566,7 +2560,18 @@ "choice_minify": "Minifier", "choice_component": "Composant", "choice_full": "URL complète", - "choice_reserved": "Caractères réservés" + "choice_reserved": "Caractères réservés", + "template_api_payload_cleanup_title": "Nettoyage de payload API", + "template_api_payload_cleanup_description": "Minifie une charge utile JSON, puis l’encode pour les URL, variables d’environnement ou jeux de test.", + "template_url_json_cleanup_title": "Nettoyage JSON depuis URL", + "template_url_json_cleanup_description": "Décode un blob JSON encodé en URL et le formate pour inspection.", + "template_security_token_review_title": "Revue de token sécurité", + "template_security_token_review_description": "Décode localement le payload d’un JWT d’exemple, puis formate les claims pour inspection sans vérifier la signature.", + "template_log_scrub_before_sharing_title": "Masquer les logs avant partage", + "template_log_scrub_before_sharing_description": "Normalise les logs collés, puis masque tokens, e-mails, IP et identifiants avant partage.", + "template_step_decode_jwt_payload": "Décoder le payload JWT", + "step_io_hint": "Entrée {input} -> sortie {output}", + "external_request_step_notice": "Étape de requête externe : vérifiez la cible réseau avant exécution." }, "saml_decoder": { "title": "Décodeur SAML", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index a4885824..f1aa4996 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -2461,21 +2461,15 @@ "pipeline_builder": { "title": "パイプラインビルダー", "description": "ローカル開発者ツールをブラウザ内の再利用可能な recipe として連結し、インポート、エクスポート、共有、ローカル保存を行います。", - "privacy_note": "Recipe はこのブラウザ内だけで実行されます。共有 URL には既定でワークフロー構造と公開オプションのみが含まれ、実行入力は含まれません。", - "share_runtime_input_hint": "共有 URL にはステップ構造と公開オプションのみが入ります。固定ステップ入力は JSON としてエクスポートしない限りローカルに残ります。", + "privacy_note": "Recipe はこのブラウザ内だけで実行されます。保存、エクスポート、共有には既定でワークフロー構造と公開オプションのみが含まれ、実行入力は含まれません。", + "share_runtime_input_hint": "保存、エクスポート、共有にはステップ構造と公開オプションのみが残ります。固定ステップ入力は現在の実行だけに使われ、保存済み recipe JSON からは削除されます。", "storage_unavailable": "このブラウザでは IndexedDB を利用できないため、ローカル保存は無効です。", "templates_title": "組み込み Recipe", "templates_description": "ローカルでプライバシーを保つワークフローから始め、実行前にステップを編集できます。", "use_template": "使用", "template_loaded": "Recipe テンプレートを読み込みました", - "template_json_minify_base64url_title": "JSON を圧縮して URL セーフ Base64 へ", - "template_json_minify_base64url_description": "JSON ペイロードを圧縮し、URL、環境変数、テストデータに使いやすい文字列へエンコードします。", - "template_url_decode_json_title": "URL デコードして JSON を整形", - "template_url_decode_json_description": "URL エンコードされた JSON blob をデコードし、確認しやすい形に整形します。", "template_clean_copied_config_title": "コピーした設定テキストを清掃", "template_clean_copied_config_description": "不可視文字を削除し、空白を正規化し、意図しない余分な空白をまとめます。", - "template_scrub_log_secrets_title": "ログの清掃と秘密情報のマスク", - "template_scrub_log_secrets_description": "貼り付けたログを正規化し、共有前に token、メール、IP、認証情報をマスクします。", "template_step_minify_json": "JSON を圧縮", "template_step_base64url_encode": "Base64 URL セーフエンコード", "template_step_url_decode": "URL コンポーネントをデコード", @@ -2566,7 +2560,18 @@ "choice_minify": "圧縮", "choice_component": "コンポーネント", "choice_full": "完全な URL", - "choice_reserved": "予約文字" + "choice_reserved": "予約文字", + "template_api_payload_cleanup_title": "API payload クリーンアップ", + "template_api_payload_cleanup_description": "JSON ペイロードを圧縮し、URL、環境変数、テストデータに使いやすい文字列へエンコードします。", + "template_url_json_cleanup_title": "URL JSON クリーンアップ", + "template_url_json_cleanup_description": "URL エンコードされた JSON blob をデコードし、確認しやすい形に整形します。", + "template_security_token_review_title": "セキュリティトークン確認", + "template_security_token_review_description": "サンプル JWT payload をローカルでデコードし、claims を整形して確認します。署名検証は行いません。", + "template_log_scrub_before_sharing_title": "共有前のログマスク", + "template_log_scrub_before_sharing_description": "貼り付けたログを正規化し、共有前に token、メール、IP、認証情報をマスクします。", + "template_step_decode_jwt_payload": "JWT payload をデコード", + "step_io_hint": "{input} 入力 -> {output} 出力", + "external_request_step_notice": "外部リクエストステップ: 実行前にネットワーク先を確認してください。" }, "saml_decoder": { "title": "SAML デコーダー", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 16b2529b..53ae410f 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -2461,21 +2461,15 @@ "pipeline_builder": { "title": "파이프라인 빌더", "description": "로컬 개발자 도구를 브라우저 안에서 반복 실행 가능한 recipe로 연결하고 가져오기, 내보내기, 공유, 로컬 저장을 지원합니다.", - "privacy_note": "Recipe는 이 브라우저 안에서만 실행됩니다. 공유 URL에는 기본적으로 워크플로 구조와 공개 옵션만 포함되며 실행 입력은 제외됩니다.", - "share_runtime_input_hint": "공유 URL에는 단계 구조와 공개 옵션만 들어갑니다. 고정 단계 입력은 JSON으로 내보내지 않는 한 로컬에 남습니다.", + "privacy_note": "Recipe는 이 브라우저 안에서만 실행됩니다. 저장, 내보내기, 공유에는 기본적으로 워크플로 구조와 공개 옵션만 포함되며 실행 입력은 제외됩니다.", + "share_runtime_input_hint": "저장, 내보내기, 공유에는 단계 구조와 공개 옵션만 남습니다. 고정 단계 입력은 현재 실행에만 사용되며 저장된 recipe JSON에서 제거됩니다.", "storage_unavailable": "이 브라우저에서 IndexedDB를 사용할 수 없어 로컬 저장이 비활성화되었습니다.", "templates_title": "기본 제공 Recipe", "templates_description": "로컬에서 개인정보를 지키는 워크플로로 시작하고 실행 전에 단계를 편집하세요.", "use_template": "사용", "template_loaded": "Recipe 템플릿을 불러왔습니다", - "template_json_minify_base64url_title": "JSON 압축 후 URL 안전 Base64", - "template_json_minify_base64url_description": "JSON 페이로드를 압축한 뒤 URL, 환경 변수, 테스트 데이터에 쓰기 좋은 문자열로 인코딩합니다.", - "template_url_decode_json_title": "URL 디코드 후 JSON 정리", - "template_url_decode_json_description": "URL 인코딩된 JSON blob을 디코드하고 검토하기 쉽게 포맷합니다.", "template_clean_copied_config_title": "복사한 설정 텍스트 정리", "template_clean_copied_config_description": "보이지 않는 문자를 제거하고 공백을 정규화하며 실수로 섞인 여분의 공백을 접습니다.", - "template_scrub_log_secrets_title": "로그 정리 및 시크릿 마스킹", - "template_scrub_log_secrets_description": "붙여넣은 로그를 정규화한 뒤 공유 전에 token, 이메일, IP, 자격 증명을 마스킹합니다.", "template_step_minify_json": "JSON 압축", "template_step_base64url_encode": "Base64 URL 안전 인코드", "template_step_url_decode": "URL 컴포넌트 디코드", @@ -2566,7 +2560,18 @@ "choice_minify": "압축", "choice_component": "컴포넌트", "choice_full": "전체 URL", - "choice_reserved": "예약 문자" + "choice_reserved": "예약 문자", + "template_api_payload_cleanup_title": "API payload 정리", + "template_api_payload_cleanup_description": "JSON 페이로드를 압축한 뒤 URL, 환경 변수, 테스트 데이터에 쓰기 좋은 문자열로 인코딩합니다.", + "template_url_json_cleanup_title": "URL JSON 정리", + "template_url_json_cleanup_description": "URL 인코딩된 JSON blob을 디코드하고 검토하기 쉽게 포맷합니다.", + "template_security_token_review_title": "보안 토큰 검토", + "template_security_token_review_description": "샘플 JWT payload를 로컬에서 디코드한 뒤 claims를 포맷해 검토합니다. 서명 검증은 하지 않습니다.", + "template_log_scrub_before_sharing_title": "공유 전 로그 마스킹", + "template_log_scrub_before_sharing_description": "붙여넣은 로그를 정규화한 뒤 공유 전에 token, 이메일, IP, 자격 증명을 마스킹합니다.", + "template_step_decode_jwt_payload": "JWT payload 디코드", + "step_io_hint": "{input} 입력 -> {output} 출력", + "external_request_step_notice": "외부 요청 단계: 실행 전에 네트워크 대상을 확인하세요." }, "saml_decoder": { "title": "SAML 디코더", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 8662d5ba..80043d17 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -2461,21 +2461,15 @@ "pipeline_builder": { "title": "管道构建器", "description": "把本地开发者工具串成可重复执行的浏览器内 recipe,支持导入、导出、分享和本地保存。", - "privacy_note": "Recipe 完全在当前浏览器运行。分享 URL 默认只包含流程结构和公开选项,不包含运行输入。", - "share_runtime_input_hint": "分享 URL 只保留步骤结构和公开选项。固定步骤输入会留在本地,除非导出为 JSON。", + "privacy_note": "Recipe 完全在当前浏览器运行。保存、导出和分享默认只包含流程结构和公开选项,不包含运行输入。", + "share_runtime_input_hint": "保存、导出和分享只保留步骤结构和公开选项。固定步骤输入仅用于当前运行,并会从已保存的 recipe JSON 中移除。", "storage_unavailable": "当前浏览器不可用 IndexedDB,本地保存 recipe 已禁用。", "templates_title": "内置 Recipe", "templates_description": "从本地、隐私安全的工作流开始,运行前可继续编辑步骤。", "use_template": "使用", "template_loaded": "Recipe 模板已载入", - "template_json_minify_base64url_title": "JSON 压缩后转 URL 安全 Base64", - "template_json_minify_base64url_description": "先压缩 JSON 载荷,再编码成适合 URL、环境变量或测试样例的文本。", - "template_url_decode_json_title": "URL 解码并美化 JSON", - "template_url_decode_json_description": "解码 URL 编码的 JSON blob,并格式化成便于审查的结构。", "template_clean_copied_config_title": "清理复制来的配置文本", "template_clean_copied_config_description": "移除不可见字符,规范化空格,并折叠误带的多余空白。", - "template_scrub_log_secrets_title": "清理并脱敏日志密钥", - "template_scrub_log_secrets_description": "先规范化粘贴日志,再分享前脱敏 token、邮箱、IP 和凭据。", "template_step_minify_json": "压缩 JSON", "template_step_base64url_encode": "Base64 URL 安全编码", "template_step_url_decode": "URL 组件解码", @@ -2566,7 +2560,18 @@ "choice_minify": "压缩", "choice_component": "组件", "choice_full": "完整 URL", - "choice_reserved": "保留字符" + "choice_reserved": "保留字符", + "template_api_payload_cleanup_title": "API payload 清理", + "template_api_payload_cleanup_description": "先压缩 JSON 载荷,再编码成适合 URL、环境变量或测试样例的文本。", + "template_url_json_cleanup_title": "URL JSON 清理", + "template_url_json_cleanup_description": "解码 URL 编码的 JSON blob,并格式化成便于审查的结构。", + "template_security_token_review_title": "安全令牌检查", + "template_security_token_review_description": "在本地解码示例 JWT payload,再格式化 claims 供检查;此流程不验证签名。", + "template_log_scrub_before_sharing_title": "分享前清理日志", + "template_log_scrub_before_sharing_description": "先规范化粘贴日志,再分享前脱敏 token、邮箱、IP 和凭据。", + "template_step_decode_jwt_payload": "解码 JWT payload", + "step_io_hint": "{input} 输入 -> {output} 输出", + "external_request_step_notice": "外部请求步骤:运行前确认网络目标。" }, "saml_decoder": { "title": "SAML 解码器", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 8da324b9..b6ee1a50 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -2461,21 +2461,15 @@ "pipeline_builder": { "title": "管道建構器", "description": "把本機開發者工具串成可重複執行的瀏覽器內 recipe,支援匯入、匯出、分享與本機保存。", - "privacy_note": "Recipe 完全在目前瀏覽器執行。分享 URL 預設只包含流程結構和公開選項,不包含執行輸入。", - "share_runtime_input_hint": "分享 URL 只保留步驟結構和公開選項。固定步驟輸入會留在本機,除非匯出為 JSON。", + "privacy_note": "Recipe 完全在目前瀏覽器執行。保存、匯出和分享預設只包含流程結構和公開選項,不包含執行輸入。", + "share_runtime_input_hint": "保存、匯出和分享只保留步驟結構和公開選項。固定步驟輸入僅用於目前執行,並會從已保存的 recipe JSON 中移除。", "storage_unavailable": "目前瀏覽器無法使用 IndexedDB,本機保存 recipe 已停用。", "templates_title": "內建 Recipe", "templates_description": "從本機、隱私安全的工作流程開始,執行前可繼續編輯步驟。", "use_template": "使用", "template_loaded": "Recipe 模板已載入", - "template_json_minify_base64url_title": "JSON 壓縮後轉 URL 安全 Base64", - "template_json_minify_base64url_description": "先壓縮 JSON 載荷,再編碼成適合 URL、環境變數或測試樣本的文字。", - "template_url_decode_json_title": "URL 解碼並美化 JSON", - "template_url_decode_json_description": "解碼 URL 編碼的 JSON blob,並格式化成便於審查的結構。", "template_clean_copied_config_title": "清理複製來的設定文字", "template_clean_copied_config_description": "移除不可見字元,正規化空格,並折疊誤帶的多餘空白。", - "template_scrub_log_secrets_title": "清理並遮蔽日誌密鑰", - "template_scrub_log_secrets_description": "先正規化貼上的日誌,再於分享前遮蔽 token、電子郵件、IP 和憑證。", "template_step_minify_json": "壓縮 JSON", "template_step_base64url_encode": "Base64 URL 安全編碼", "template_step_url_decode": "URL 元件解碼", @@ -2566,7 +2560,18 @@ "choice_minify": "壓縮", "choice_component": "元件", "choice_full": "完整 URL", - "choice_reserved": "保留字元" + "choice_reserved": "保留字元", + "template_api_payload_cleanup_title": "API payload 清理", + "template_api_payload_cleanup_description": "先壓縮 JSON 載荷,再編碼成適合 URL、環境變數或測試樣本的文字。", + "template_url_json_cleanup_title": "URL JSON 清理", + "template_url_json_cleanup_description": "解碼 URL 編碼的 JSON blob,並格式化成便於審查的結構。", + "template_security_token_review_title": "安全權杖檢查", + "template_security_token_review_description": "在本機解碼範例 JWT payload,再格式化 claims 供檢查;此流程不驗證簽章。", + "template_log_scrub_before_sharing_title": "分享前清理日誌", + "template_log_scrub_before_sharing_description": "先正規化貼上的日誌,再於分享前遮蔽 token、電子郵件、IP 和憑證。", + "template_step_decode_jwt_payload": "解碼 JWT payload", + "step_io_hint": "{input} 輸入 -> {output} 輸出", + "external_request_step_notice": "外部請求步驟:執行前確認網路目標。" }, "saml_decoder": { "title": "SAML 解碼器", diff --git a/src/features/pipeline/recipe-codec.ts b/src/features/pipeline/recipe-codec.ts index 89a741c6..e9b81e5d 100644 --- a/src/features/pipeline/recipe-codec.ts +++ b/src/features/pipeline/recipe-codec.ts @@ -52,7 +52,7 @@ export function recipeContainsRuntimeInput(recipe: RecipeDocument): boolean { return recipe.steps.some((step) => step.inputMode === "constant" && typeof step.constantInput === "string" && step.constantInput.length > 0) } -function sanitizeShareOptions(toolKey: string, options: Record): Record { +function sanitizePortableOptions(toolKey: string, options: Record): Record { const adapter = getPipelineAdapter(toolKey) if (!adapter) return {} @@ -63,16 +63,16 @@ function sanitizeShareOptions(toolKey: string, options: Record) ) } -export function encodeRecipeForShareUrl(recipe: RecipeDocument, options: { includeRuntimeInput?: boolean } = {}): string { - const clone: RecipeDocument = { +export function createPortableRecipe(recipe: RecipeDocument): RecipeDocument { + return { ...recipe, steps: recipe.steps.map((step) => { const base = { ...step, - options: sanitizeShareOptions(step.toolKey, step.options || {}), + options: sanitizePortableOptions(step.toolKey, step.options || {}), } - if (!options.includeRuntimeInput && step.inputMode === "constant") { + if (step.inputMode === "constant") { const withoutInput = { ...base } delete withoutInput.constantInput return { @@ -84,5 +84,8 @@ export function encodeRecipeForShareUrl(recipe: RecipeDocument, options: { inclu return base }), } - return encodeRecipeForUrl(clone) +} + +export function encodeRecipeForShareUrl(recipe: RecipeDocument): string { + return encodeRecipeForUrl(createPortableRecipe(recipe)) } diff --git a/src/features/pipeline/recipe-import-export.ts b/src/features/pipeline/recipe-import-export.ts index 96cafc3e..e7121ae6 100644 --- a/src/features/pipeline/recipe-import-export.ts +++ b/src/features/pipeline/recipe-import-export.ts @@ -1,4 +1,5 @@ import { validateRecipe } from "./executor" +import { createPortableRecipe } from "./recipe-codec" import type { RecipeDocument } from "./recipe-types" export type RecipeImportResult = @@ -6,7 +7,7 @@ export type RecipeImportResult = | { ok: false; errors: string[] } export function exportRecipeToJson(recipe: RecipeDocument): string { - return `${JSON.stringify(recipe, null, 2)}\n` + return `${JSON.stringify(createPortableRecipe(recipe), null, 2)}\n` } export function importRecipeFromJson(source: string): RecipeImportResult { diff --git a/src/features/pipeline/recipe-store.ts b/src/features/pipeline/recipe-store.ts index 317c9597..c70f38f6 100644 --- a/src/features/pipeline/recipe-store.ts +++ b/src/features/pipeline/recipe-store.ts @@ -1,4 +1,5 @@ import type { RecipeDocument } from "./recipe-types" +import { createPortableRecipe } from "./recipe-codec" const DB_NAME = "byteflow-pipeline-recipes" const DB_VERSION = 1 @@ -22,6 +23,23 @@ export function isRecipeStoreAvailable(): boolean { return typeof window !== "undefined" && typeof window.indexedDB !== "undefined" } +export function createSavedRecipeRecord( + recipe: RecipeDocument, + metadata: Partial = {}, + now = new Date().toISOString(), +): SavedRecipeRecord { + const portableRecipe = createPortableRecipe(recipe) + return { + id: portableRecipe.id, + name: portableRecipe.name, + recipe: portableRecipe, + createdAt: metadata.createdAt ?? portableRecipe.createdAt ?? now, + updatedAt: now, + lastRunAt: metadata.lastRunAt, + pinned: metadata.pinned, + } +} + function requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result) @@ -69,16 +87,7 @@ async function withStore( } export async function saveRecipeRecord(recipe: RecipeDocument, metadata: Partial = {}): Promise> { - const now = new Date().toISOString() - const record: SavedRecipeRecord = { - id: recipe.id, - name: recipe.name, - recipe, - createdAt: metadata.createdAt ?? recipe.createdAt ?? now, - updatedAt: now, - lastRunAt: metadata.lastRunAt, - pinned: metadata.pinned, - } + const record = createSavedRecipeRecord(recipe, metadata) const result = await withStore("readwrite", (store) => store.put(record)) if (!result.ok) return result diff --git a/src/features/pipeline/recipe-templates.ts b/src/features/pipeline/recipe-templates.ts index 7cc4e743..50c94dc6 100644 --- a/src/features/pipeline/recipe-templates.ts +++ b/src/features/pipeline/recipe-templates.ts @@ -22,9 +22,9 @@ export interface RecipeFromTemplateResult { export const PIPELINE_RECIPE_TEMPLATES = [ { - id: "json_minify_base64url", - titleKey: "template_json_minify_base64url_title", - descriptionKey: "template_json_minify_base64url_description", + id: "api_payload_cleanup", + titleKey: "template_api_payload_cleanup_title", + descriptionKey: "template_api_payload_cleanup_description", sampleInput: '{ "user": "alice@example.com", "role": "admin", "active": true }', steps: [ { @@ -40,9 +40,9 @@ export const PIPELINE_RECIPE_TEMPLATES = [ ], }, { - id: "url_decode_json_pretty", - titleKey: "template_url_decode_json_title", - descriptionKey: "template_url_decode_json_description", + id: "url_json_cleanup", + titleKey: "template_url_json_cleanup_title", + descriptionKey: "template_url_json_cleanup_description", sampleInput: "%7B%22user%22%3A%22alice%40example.com%22%2C%22active%22%3Atrue%7D", steps: [ { @@ -58,10 +58,28 @@ export const PIPELINE_RECIPE_TEMPLATES = [ ], }, { - id: "clean_copied_config", - titleKey: "template_clean_copied_config_title", - descriptionKey: "template_clean_copied_config_description", - sampleInput: "API_KEY\u200b=\u00a0abc123\nNAME\u3000=\tByteflow", + id: "security_token_review", + titleKey: "template_security_token_review_title", + descriptionKey: "template_security_token_review_description", + sampleInput: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJleHAiOjE4OTM0NTYwMDAsInNjb3BlIjoicmVhZDpsb2dzIn0.signature-placeholder", + steps: [ + { + toolKey: "jwt_decoder", + labelKey: "template_step_decode_jwt_payload", + options: { part: "payload" }, + }, + { + toolKey: "json_formatter", + labelKey: "template_step_pretty_json", + options: { mode: "pretty", indent: 2 }, + }, + ], + }, + { + id: "log_scrub_before_sharing", + titleKey: "template_log_scrub_before_sharing_title", + descriptionKey: "template_log_scrub_before_sharing_description", + sampleInput: "2026-06-10T12:00:00Z WARN user=alice@example.com ip=203.0.113.10 Authorization: Bearer sample-token-value-12345", steps: [ { toolKey: "invisible_chars_detector", @@ -73,17 +91,17 @@ export const PIPELINE_RECIPE_TEMPLATES = [ }, }, { - toolKey: "multiple_whitespace_remover", - labelKey: "template_step_normalize_whitespace", + toolKey: "log_scrubber", + labelKey: "template_step_scrub_log_secrets", options: {}, }, ], }, { - id: "scrub_log_secrets", - titleKey: "template_scrub_log_secrets_title", - descriptionKey: "template_scrub_log_secrets_description", - sampleInput: "2026-06-10T12:00:00Z WARN user=alice@example.com ip=203.0.113.10 Authorization: Bearer secret-token-value-12345", + id: "clean_copied_config", + titleKey: "template_clean_copied_config_title", + descriptionKey: "template_clean_copied_config_description", + sampleInput: "API_KEY\u200b=\u00a0sample_value\nNAME\u3000=\tByteflow", steps: [ { toolKey: "invisible_chars_detector", @@ -95,8 +113,8 @@ export const PIPELINE_RECIPE_TEMPLATES = [ }, }, { - toolKey: "log_scrubber", - labelKey: "template_step_scrub_log_secrets", + toolKey: "multiple_whitespace_remover", + labelKey: "template_step_normalize_whitespace", options: {}, }, ], diff --git a/src/features/tools/pipeline-builder/page.tsx b/src/features/tools/pipeline-builder/page.tsx index 7650f06c..9e9eee2c 100644 --- a/src/features/tools/pipeline-builder/page.tsx +++ b/src/features/tools/pipeline-builder/page.tsx @@ -18,9 +18,10 @@ import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-actio import { useLang } from "@/core/i18n/lang-provider" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { FILE_INPUT_POLICIES, readTextFileWithPolicy, validateFileAgainstPolicy } from "@/core/files/file-input-policy" +import { getToolByKey } from "@/core/registry" import { getToolHandoffFromSearchParams } from "@/core/routing/tool-handoff" import { PIPELINE_TOOL_ADAPTERS } from "@/features/pipeline/adapter-registry" -import { decodeRecipeFromUrlParam, encodeRecipeForShareUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" +import { createPortableRecipe, decodeRecipeFromUrlParam, encodeRecipeForShareUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" import { runRecipe, validateRecipe } from "@/features/pipeline/executor" import { exportRecipeToJson, importRecipeFromJson } from "@/features/pipeline/recipe-import-export" import { createRecipeFromTemplate, PIPELINE_RECIPE_TEMPLATES, type PipelineRecipeTemplate } from "@/features/pipeline/recipe-templates" @@ -203,6 +204,8 @@ export function PipelineBuilderPage() { toast.error(saveResult.error) return } + setRecipe(saveResult.value.recipe) + setSelectedStepId(saveResult.value.recipe.steps[0]?.id ?? null) toast.success(text("recipe_saved")) await refreshSavedRecipes() }, [recipe, refreshSavedRecipes, storageAvailable, text]) @@ -237,7 +240,8 @@ export function PipelineBuilderPage() { }, [refreshSavedRecipes, selectedSavedId, storageAvailable, text]) const exportRecipe = React.useCallback(() => { - downloadText(`${recipe.name.trim().replace(/[^\w.-]+/g, "-") || "byteflow-recipe"}.json`, exportRecipeToJson(recipe)) + const portableRecipe = createPortableRecipe(recipe) + downloadText(`${portableRecipe.name.trim().replace(/[^\w.-]+/g, "-") || "byteflow-recipe"}.json`, exportRecipeToJson(portableRecipe)) toast.success(text("recipe_exported")) }, [recipe, text]) @@ -359,6 +363,9 @@ export function PipelineBuilderPage() { ({ + externalRequestRequired: getToolByKey(adapter.toolKey)?.privacy.externalRequest.required === true, + inputKind: adapter.inputKind, + outputKind: adapter.outputKind, title: (t.tools[adapter.toolKey] as Record | undefined)?.title ?? adapter.toolKey, toolKey: adapter.toolKey, }))} diff --git a/src/features/tools/pipeline-builder/pipeline-step-list.tsx b/src/features/tools/pipeline-builder/pipeline-step-list.tsx index 4255635e..857ba32e 100644 --- a/src/features/tools/pipeline-builder/pipeline-step-list.tsx +++ b/src/features/tools/pipeline-builder/pipeline-step-list.tsx @@ -1,9 +1,12 @@ -import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react" +import { ArrowDown, ArrowUp, Network, Plus, Trash2 } from "lucide-react" import { Button } from "@/components/ui/button" import type { RecipeStep } from "@/features/pipeline/recipe-types" import type { StepCompatibilityHint } from "./logic" type AdapterOption = { + externalRequestRequired: boolean + inputKind: string + outputKind: string title: string toolKey: string } @@ -69,6 +72,7 @@ export function PipelineStepList({
{text("no_steps")}
) : steps.map((step, index) => { const adapterTitle = adapterTitleByKey.get(step.toolKey) ?? step.toolKey + const adapterOption = adapterOptions.find((adapter) => adapter.toolKey === step.toolKey) const active = step.id === selectedStepId const hint = hintByStepId.get(step.id) return ( @@ -85,11 +89,22 @@ export function PipelineStepList({ {index + 1}. {step.label || adapterTitle} {adapterTitle} + {adapterOption ? ( + + {text("step_io_hint").replace("{input}", adapterOption.inputKind).replace("{output}", adapterOption.outputKind)} + + ) : null} {hint ? ( {text("compatibility_hint").replace("{from}", hint.fromKind).replace("{to}", hint.toKind)} ) : null} + {adapterOption?.externalRequestRequired ? ( + + + {text("external_request_step_notice")} + + ) : null}
diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index 02890660..1e6d6587 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -10,13 +10,13 @@ "fr" ], "home": { - "en": "2026-06-20T00:00:00.000Z", - "zh-CN": "2026-06-20T00:00:00.000Z", - "zh-TW": "2026-06-20T00:00:00.000Z", - "ja": "2026-06-20T00:00:00.000Z", - "ko": "2026-06-20T00:00:00.000Z", - "de": "2026-06-20T00:00:00.000Z", - "fr": "2026-06-20T00:00:00.000Z" + "en": "2026-06-21T00:00:00.000Z", + "zh-CN": "2026-06-21T00:00:00.000Z", + "zh-TW": "2026-06-21T00:00:00.000Z", + "ja": "2026-06-21T00:00:00.000Z", + "ko": "2026-06-21T00:00:00.000Z", + "de": "2026-06-21T00:00:00.000Z", + "fr": "2026-06-21T00:00:00.000Z" }, "hubs": { "formatters": { diff --git a/tests/component/layout-components.test.tsx b/tests/component/layout-components.test.tsx index ee769cea..50a76166 100644 --- a/tests/component/layout-components.test.tsx +++ b/tests/component/layout-components.test.tsx @@ -227,12 +227,14 @@ describe("layout components", () => { labels={{ allTools: "All tools", openNavigation: "Open Navigation", + pipelineBuilder: "Pipeline Builder", search: "Search", }} />, ) expect(screen.getByRole("link", { name: "byteflow.tools" })).toHaveAttribute("href", "/en") + expect(screen.getByRole("link", { name: "Pipeline Builder" })).toHaveAttribute("href", "/en/pipeline-builder") expect(screen.getByRole("link", { name: "All tools" })).toHaveAttribute("href", getAllToolsHref("en")) expect(screen.getByLabelText("Search")).toHaveAttribute("data-command-palette-trigger") }) diff --git a/tests/component/phase3-pipeline-builder-page.test.tsx b/tests/component/phase3-pipeline-builder-page.test.tsx index 35beb007..a7619219 100644 --- a/tests/component/phase3-pipeline-builder-page.test.tsx +++ b/tests/component/phase3-pipeline-builder-page.test.tsx @@ -4,6 +4,7 @@ import { LangProvider } from "@/core/i18n/lang-provider" import PipelineBuilderPage from "@/app/[lang]/pipeline-builder/page" import { getTranslation } from "@/core/i18n/translations/catalog" import type { Locale } from "@/core/i18n/i18n" +import { PipelineStepList } from "@/features/tools/pipeline-builder/pipeline-step-list" vi.mock("next/navigation", () => ({ usePathname: () => "/en/pipeline-builder", @@ -47,7 +48,9 @@ describe("phase 3 pipeline builder page", () => { expect(screen.getByLabelText("Initial input")).toBeInTheDocument() expect(screen.getByText("Steps")).toBeInTheDocument() expect(screen.getByText("Built-in recipes")).toBeInTheDocument() - expect(screen.getByText("URL decode and pretty-print JSON")).toBeInTheDocument() + expect(screen.getByText("API payload cleanup")).toBeInTheDocument() + expect(screen.getByText("Security token review")).toBeInTheDocument() + expect(screen.getByText("Log scrub before sharing")).toBeInTheDocument() expect(screen.getByRole("button", { name: /Run Recipe/i })).toBeInTheDocument() expect(screen.getAllByRole("button", { name: /Export JSON/i }).length).toBeGreaterThan(0) expect(screen.getByRole("button", { name: /Share URL/i })).toBeInTheDocument() @@ -75,7 +78,8 @@ describe("phase 3 pipeline builder page", () => { expect(screen.getByRole("heading", { name: "Recipe settings" })).toBeInTheDocument() expect(screen.getByRole("switch", { name: "Stop on error" })).toBeChecked() expect(screen.getByText("Check handoff: text output into json input.")).toBeInTheDocument() - expect(screen.getByText(/Constant step inputs stay local unless exported as JSON/i)).toBeInTheDocument() + expect(screen.getByText(/Constant step input is used only for the current run/i)).toBeInTheDocument() + expect(screen.getByText("text input -> text output")).toBeInTheDocument() }) it("renders without IndexedDB and keeps non-storage actions available", async () => { @@ -136,7 +140,7 @@ describe("phase 3 pipeline builder page", () => { const useTemplateButtons = screen.getAllByRole("button", { name: "Use" }) fireEvent.click(useTemplateButtons[1]) - expect(screen.getByLabelText("Recipe name")).toHaveValue("URL decode and pretty-print JSON") + expect(screen.getByLabelText("Recipe name")).toHaveValue("URL JSON cleanup") expect(screen.getByLabelText("Initial input")).toHaveValue("%7B%22user%22%3A%22alice%40example.com%22%2C%22active%22%3Atrue%7D") expect(screen.getByLabelText("Step label")).toHaveValue("URL component decode") @@ -151,6 +155,63 @@ describe("phase 3 pipeline builder page", () => { expect(screen.getByText("Recipe is valid for the linear MVP executor.")).toBeInTheDocument() }) + it("loads the security token review template without putting the sample JWT into recipe JSON", () => { + renderWithEnglish() + + const useTemplateButtons = screen.getAllByRole("button", { name: "Use" }) + fireEvent.click(useTemplateButtons[2]) + + expect(screen.getByLabelText("Recipe name")).toHaveValue("Security token review") + expect(screen.getByLabelText("Initial input")).toHaveValue() + expect((screen.getByLabelText("Initial input") as HTMLTextAreaElement).value).toContain("signature-placeholder") + expect(screen.getByLabelText("Step label")).toHaveValue("Decode JWT payload") + expect(screen.getByText("text input -> json output")).toBeInTheDocument() + }) + + it("shows a per-step warning for external-request pipeline adapters", () => { + render( + undefined} + onMoveStep={() => undefined} + onPendingToolKeyChange={() => undefined} + onRemoveStep={() => undefined} + onSelectStep={() => undefined} + pendingToolKey="external_lookup" + selectedStepId="lookup" + steps={[{ + adapterVersion: 1, + id: "lookup", + inputMode: "previous_output", + options: {}, + toolKey: "external_lookup", + }]} + text={(key) => ({ + add_step: "Add", + adapter_select: "Select tool adapter", + external_request_step_notice: "External request step: confirm the network target before running.", + move_down: "Move step down", + move_up: "Move step up", + no_steps: "No steps", + remove_step: "Remove step", + step_io_hint: "{input} input -> {output} output", + steps_title: "Steps", + }[key] ?? key)} + />, + ) + + expect(screen.getByText("url input -> json output")).toBeInTheDocument() + expect(screen.getByText("External request step: confirm the network target before running.")).toBeInTheDocument() + }) + it("imports a valid recipe JSON file", async () => { const { container } = renderWithEnglish() const importedRecipe = { diff --git a/tests/unit/pipeline-foundation.test.ts b/tests/unit/pipeline-foundation.test.ts index 9572d4b6..9fc681f7 100644 --- a/tests/unit/pipeline-foundation.test.ts +++ b/tests/unit/pipeline-foundation.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest" import { getPipelineAdapter, getPipelineAdapterKeys, PIPELINE_TOOL_ADAPTERS } from "@/features/pipeline/adapter-registry" import { TOOL_MANIFESTS } from "@/core/registry" -import { decodeRecipeFromUrlParam, encodeRecipeForShareUrl, encodeRecipeForUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" +import { createPortableRecipe, decodeRecipeFromUrlParam, encodeRecipeForShareUrl, encodeRecipeForUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" import { createEmptyRecipe, runRecipe, validateRecipe } from "@/features/pipeline/executor" import { exportRecipeToJson, importRecipeFromJson } from "@/features/pipeline/recipe-import-export" import { createRecipeFromTemplate, PIPELINE_RECIPE_TEMPLATES } from "@/features/pipeline/recipe-templates" -import { isRecipeStoreAvailable } from "@/features/pipeline/recipe-store" +import { createSavedRecipeRecord, isRecipeStoreAvailable } from "@/features/pipeline/recipe-store" import { DEFAULT_RECIPE_SETTINGS, type PipelineToolAdapter, type RecipeDocument } from "@/features/pipeline/recipe-types" import { getStepCompatibilityHints } from "@/features/tools/pipeline-builder/logic" @@ -651,7 +651,7 @@ describe("pipeline foundation", () => { expect(result.steps.every((step) => !Object.prototype.hasOwnProperty.call(step, "output"))).toBe(true) }) - it("uses constant input without leaking it into default share URLs", () => { + it("creates portable recipes without runtime payloads or private options", () => { const recipe = buildRecipe({ steps: [ { @@ -660,24 +660,26 @@ describe("pipeline foundation", () => { adapterVersion: 1, inputMode: "constant", constantInput: "Authorization: Bearer secret-token-value", - options: {}, + options: { + bearerTokens: true, + privatePayload: "secret-token-value", + }, }, ], edges: [], }) expect(recipeContainsRuntimeInput(recipe)).toBe(true) - const shared = encodeRecipeForShareUrl(recipe) - const decoded = decodeRecipeFromUrlParam(shared) + const portable = createPortableRecipe(recipe) - expect(decoded.ok).toBe(true) - if (decoded.ok) { - expect(decoded.recipe.steps[0].inputMode).toBe("previous_output") - expect(decoded.recipe.steps[0].constantInput).toBeUndefined() - } + expect(portable.steps[0].inputMode).toBe("previous_output") + expect(portable.steps[0].constantInput).toBeUndefined() + expect(portable.steps[0].options).toHaveProperty("bearerTokens", true) + expect(portable.steps[0].options).not.toHaveProperty("privatePayload") + expect(JSON.stringify(portable)).not.toContain("secret-token-value") }) - it("keeps constant input only when share URLs explicitly include runtime input", () => { + it("uses constant input without leaking it into share URLs", () => { const recipe = buildRecipe({ steps: [ { @@ -691,12 +693,13 @@ describe("pipeline foundation", () => { ], edges: [], }) - const decoded = decodeRecipeFromUrlParam(encodeRecipeForShareUrl(recipe, { includeRuntimeInput: true })) + const decoded = decodeRecipeFromUrlParam(encodeRecipeForShareUrl(recipe)) expect(decoded.ok).toBe(true) if (decoded.ok) { - expect(decoded.recipe.steps[0].inputMode).toBe("constant") - expect(decoded.recipe.steps[0].constantInput).toBe("Authorization: Bearer secret-token-value") + expect(decoded.recipe.steps[0].inputMode).toBe("previous_output") + expect(decoded.recipe.steps[0].constantInput).toBeUndefined() + expect(JSON.stringify(decoded.recipe)).not.toContain("secret-token-value") } }) @@ -789,17 +792,57 @@ describe("pipeline foundation", () => { }) }) - it("exports and imports recipe JSON without auto-running it", () => { - const recipe = buildRecipe() + it("exports and imports portable recipe JSON without auto-running it or including runtime payload", () => { + const recipe = buildRecipe({ + steps: [ + { + id: "secret_sample", + toolKey: "log_scrubber", + adapterVersion: 1, + inputMode: "constant", + constantInput: "Authorization: Bearer secret-token-value", + options: {}, + }, + ], + edges: [], + }) + const exported = exportRecipeToJson(recipe) const imported = importRecipeFromJson(exportRecipeToJson(recipe)) + expect(exported).not.toContain("secret-token-value") expect(imported.ok).toBe(true) if (imported.ok) { expect(imported.recipe.name).toBe("Test recipe") - expect(imported.recipe.steps.map((step) => step.toolKey)).toEqual(["json_formatter", "base64_encode_decode"]) + expect(imported.recipe.steps[0].toolKey).toBe("log_scrubber") + expect(imported.recipe.steps[0].inputMode).toBe("previous_output") + expect(imported.recipe.steps[0].constantInput).toBeUndefined() } }) + it("builds saved recipe records without storing runtime payload", () => { + const recipe = buildRecipe({ + steps: [ + { + id: "constant_secret", + toolKey: "jwt_decoder", + adapterVersion: 1, + inputMode: "constant", + constantInput: "eyJ.secret.payload", + options: { part: "payload", rawToken: "eyJ.secret.payload" }, + }, + ], + edges: [], + }) + const record = createSavedRecipeRecord(recipe, {}, "2026-06-10T01:00:00.000Z") + const serialized = JSON.stringify(record) + + expect(record.recipe.steps[0].inputMode).toBe("previous_output") + expect(record.recipe.steps[0].constantInput).toBeUndefined() + expect(record.recipe.steps[0].options).toEqual({ part: "payload" }) + expect(serialized).not.toContain("eyJ.secret.payload") + expect(serialized).not.toContain("rawToken") + }) + it("returns structured import errors for invalid recipe JSON", () => { const imported = importRecipeFromJson("{bad json") @@ -822,6 +865,15 @@ describe("pipeline foundation", () => { }) it("creates valid built-in recipe templates with deterministic linear edges", () => { + expect(PIPELINE_RECIPE_TEMPLATES.map((template) => template.id)).toEqual( + expect.arrayContaining([ + "api_payload_cleanup", + "security_token_review", + "log_scrub_before_sharing", + ]), + ) + expect(PIPELINE_RECIPE_TEMPLATES.length).toBeGreaterThanOrEqual(3) + for (const template of PIPELINE_RECIPE_TEMPLATES) { const generated = createRecipeFromTemplate(template, { recipeId: `recipe_${template.id}`,