diff --git a/packages/cli/src/commands/__tests__/research.test.ts b/packages/cli/src/commands/__tests__/research.test.ts index f772324785..7da3fc0e14 100644 --- a/packages/cli/src/commands/__tests__/research.test.ts +++ b/packages/cli/src/commands/__tests__/research.test.ts @@ -1,4 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/* +FNXC:CliQuietMode 2026-07-25-09:05: +JSON and other result-bearing CLI output uses `result()` (raw stdout), not +console.log, so quiet mode can suppress chatter without dropping payloads. +Capture that seam instead of expecting log spies to see JSON. +*/ +const resultSpy = vi.hoisted(() => vi.fn()); +vi.mock("../../output.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + result: (text: string) => { + resultSpy(text); + }, + }; +}); + import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js"; function makeConstructibleMock unknown>(impl?: T) { @@ -116,6 +134,7 @@ describe("research commands", () => { beforeEach(() => { vi.clearAllMocks(); + resultSpy.mockClear(); process.exit = vi.fn(((code?: number) => { throw new Error(`process.exit:${code ?? 0}`); }) as typeof process.exit); @@ -156,7 +175,7 @@ describe("research commands", () => { it("lists runs as json", async () => { await runResearchList({ json: true, status: "completed", limit: 3 }); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"')); + expect(resultSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"')); expect(researchStoreMock.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 }); }); @@ -175,7 +194,7 @@ describe("research commands", () => { await runResearchList({ json: true, status: "completed", limit: 3 }); expect(asyncStore.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 }); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"')); + expect(resultSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"')); }); it("rejects invalid list status", async () => { @@ -212,14 +231,14 @@ describe("research commands", () => { it("cancels a run", async () => { await runResearchCancel("RR-001", { json: true }); expect(orchestratorMock.cancelRun).toHaveBeenCalledWith("RR-001"); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"cancelled"')); + expect(resultSpy).toHaveBeenCalledWith(expect.stringContaining('"cancelled"')); }); it("retries a run", async () => { researchStoreMock.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "queued" } : { ...mockRun, status: "failed" })); await runResearchRetry("RR-001", { json: true }); expect(orchestratorMock.retryRun).toHaveBeenCalledWith("RR-001"); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"retryOf"')); + expect(resultSpy).toHaveBeenCalledWith(expect.stringContaining('"retryOf"')); }); it("errors when research is disabled", async () => { diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index 722592eff9..c7a701fd65 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +/* +FNXC:CliQuietMode 2026-07-25-09:05: +Task create/link success lines use `result()` so they survive quiet mode. +Capture that seam for assertions that previously spied console.log. +*/ +const resultSpy = vi.hoisted(() => vi.fn()); +vi.mock("../../output.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + result: (text: string) => { + resultSpy(text); + }, + }; +}); + // Mock node:readline/promises before importing the module under test vi.mock("node:readline/promises", () => ({ createInterface: vi.fn(), @@ -680,9 +696,9 @@ describe("project-aware task command behavior", () => { }); it("runTaskCreate links existing task on deterministic duplicate", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const existing = makeTask({ id: "FN-777", description: "same task", column: "todo" }); const mockCreateTask = vi.fn(); + resultSpy.mockClear(); vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({ action: "duplicate", @@ -702,8 +718,7 @@ describe("project-aware task command behavior", () => { await runTaskCreate("same task"); expect(mockCreateTask).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Linked existing FN-777"))).toBe(true); - logSpy.mockRestore(); + expect(resultSpy.mock.calls.some((call) => String(call[0]).includes("Linked existing FN-777"))).toBe(true); }); it("runTaskCreate proceeds when no high-signal tokens are present", async () => { diff --git a/packages/cli/src/commands/__tests__/update.test.ts b/packages/cli/src/commands/__tests__/update.test.ts index 4f7b86fae9..d8299046f2 100644 --- a/packages/cli/src/commands/__tests__/update.test.ts +++ b/packages/cli/src/commands/__tests__/update.test.ts @@ -1,6 +1,11 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; -const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMock, getConfiguredUpdateChannelMock, persistUpdateChannelMock } = vi.hoisted(() => ({ +/* +FNXC:CliQuietMode 2026-07-25-09:05: +`fn update --json` writes through `result()` (raw stdout) so quiet mode cannot +drop machine-readable payloads. Capture that seam instead of console.log. +*/ +const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMock, getConfiguredUpdateChannelMock, persistUpdateChannelMock, resultSpy } = vi.hoisted(() => ({ execAsyncMock: vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>(), existsSyncMock: vi.fn<(path: string) => boolean>(), readFileSyncMock: vi.fn<(path: string, encoding: BufferEncoding) => string>(), @@ -12,8 +17,19 @@ const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMo } | null>(), getConfiguredUpdateChannelMock: vi.fn<() => Promise<"stable" | "beta">>(), persistUpdateChannelMock: vi.fn<(channel: "stable" | "beta") => Promise>(), + resultSpy: vi.fn<(text: string) => void>(), })); +vi.mock("../../output.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + result: (text: string) => { + resultSpy(text); + }, + }; +}); + vi.mock("node:child_process", async () => { const { promisify } = await import("node:util"); const execFn: Record = vi.fn(); @@ -47,6 +63,7 @@ describe("runUpdate", () => { beforeEach(() => { vi.clearAllMocks(); + resultSpy.mockClear(); process.exitCode = 0; existsSyncMock.mockImplementation((path: string) => path.endsWith("package.json")); @@ -103,7 +120,7 @@ describe("runUpdate", () => { await runUpdate({ json: true }); - const output = logSpy.mock.calls[0]?.[0] as string; + const output = resultSpy.mock.calls[0]?.[0] as string; const parsed = JSON.parse(output) as { currentVersion: string; latestVersion: string; @@ -272,11 +289,12 @@ describe("runUpdate", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: testCase.latest } }) })); readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: testCase.current })); logSpy.mockClear(); + resultSpy.mockClear(); process.exitCode = 0; await runUpdate({ check: true, json: true }); - const output = logSpy.mock.calls[0]?.[0] as string; + const output = resultSpy.mock.calls[0]?.[0] as string; expect(JSON.parse(output), `${testCase.latest} vs ${testCase.current}`).toMatchObject({ currentVersion: testCase.current, latestVersion: testCase.latest, @@ -297,11 +315,12 @@ describe("runUpdate", () => { latestVersion, }); logSpy.mockClear(); + resultSpy.mockClear(); process.exitCode = 0; await runUpdate({ check: true, json: true }); - const output = logSpy.mock.calls.at(-1)?.[0] as string; + const output = resultSpy.mock.calls.at(-1)?.[0] as string; expect(JSON.parse(output)).toMatchObject({ currentVersion: "1.2.3", latestVersion, @@ -361,7 +380,7 @@ describe("runUpdate", () => { await runUpdate({ check: true, json: true }); - const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Record; + const parsed = JSON.parse(resultSpy.mock.calls[0]?.[0] as string) as Record; expect(parsed).toMatchObject({ latestVersion: "1.3.0-beta.2", updateAvailable: true, channel: "beta" }); expect(process.exitCode).toBe(1); }); @@ -371,7 +390,7 @@ describe("runUpdate", () => { await runUpdate({ check: true, json: true }); - const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Record; + const parsed = JSON.parse(resultSpy.mock.calls[0]?.[0] as string) as Record; expect(parsed).toMatchObject({ latestVersion: "1.2.3", updateAvailable: false, channel: "stable" }); expect(process.exitCode).toBe(0); }); @@ -462,11 +481,12 @@ describe("runUpdate", () => { expect(logSpy.mock.calls.flat().join("\n")).not.toContain("A newer beta"); logSpy.mockClear(); + resultSpy.mockClear(); process.exitCode = 0; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.1" } }) })); await runUpdate({ check: true, json: true }); - const output = logSpy.mock.calls[0]?.[0] as string; + const output = resultSpy.mock.calls[0]?.[0] as string; expect(JSON.parse(output)).toEqual({ currentVersion: "1.2.3", latestVersion: "1.2.3", @@ -475,6 +495,7 @@ describe("runUpdate", () => { channel: "stable", }); expect(logSpy.mock.calls.flat().join("\n")).not.toContain("A newer beta"); + expect(resultSpy.mock.calls.flat().join("\n")).not.toContain("A newer beta"); }); }); }); diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 25171b3f15..0f0db08bca 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -318,6 +318,13 @@ const NOT_SURFACED_ALLOWLIST: Record = { ephemeralAgentsCanCreateTasks: "legacy compatibility input replaced by ephemeralAgentTaskCreationPolicy", // Global-only serve/dashboard LAN discovery switch; no Settings UI description field exists. localNetworkDiscoveryEnabled: "global-only LAN discovery runtime switch", + /* + FNXC:VoiceInput 2026-07-25-09:05: + Nested Voice Input settings object. DEFAULT_SETTINGS stores voiceInput as undefined + (opt-in object); the VoiceInputSection enable toggle documents Default: off for the + nested enabled flag rather than a top-level plain description field. + */ + voiceInput: "nested Voice Input section object; enable toggle owns Default: off for voiceInput.enabled", // Moved to workflow settings (U4) — see MOVED_SETTINGS_KEYS in settings-schema.ts. workflowStepTimeoutMs: "moved to workflow settings (U4)", workflowStepScopeEnforcement: "moved to workflow settings (U4)", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 266d896e9e..a2ed6bf770 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6390,7 +6390,8 @@ }, "globalMcp": "MCP Servers · Global", "mcp": "MCP Servers · Project", - "configVersions": "Configuration Versions" + "configVersions": "Configuration Versions", + "voiceInput": "Voice Input" }, "nodeRouting": { "blockExecution": "Block execution", @@ -6896,6 +6897,25 @@ "confirmTitle": "Roll back configuration?", "confirmMessage": "Restore this version? The rollback is recorded as a new version.", "confirmRollback": "Roll back" + }, + "voiceInput": { + "title": "Voice Input", + "enable": "Enable voice input", + "enableHelp": "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.", + "modelStatus": "Parakeet v3 model status", + "modelStatusHelp": "The speech model is installed and managed locally on this device.", + "modelActions": "Model management", + "modelActionsHelp": "Download or remove the Parakeet v3 speech model.", + "statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.", + "runtimeUnavailable": "Voice runtime unavailable", + "unknown": "Status unavailable", + "downloading": "Downloading model…", + "downloadingProgress": "Downloading: {{progress}}%", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "remove": "Remove", + "error": "Model error: {{message}}" } }, "setup": { diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 441599e765..c9d2950996 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -6366,7 +6366,8 @@ }, "globalMcp": "MCP Servers · Global", "mcp": "MCP Servers · Project", - "configVersions": "Versiones de configuración" + "configVersions": "Versiones de configuración", + "voiceInput": "Voice Input" }, "nodeRouting": { "blockExecution": "", @@ -6886,6 +6887,25 @@ "confirmTitle": "¿Revertir la configuración?", "confirmMessage": "¿Restaurar esta versión? La reversión se registra como una nueva versión.", "confirmRollback": "Revertir" + }, + "voiceInput": { + "title": "Voice Input", + "enable": "Enable voice input", + "enableHelp": "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.", + "modelStatus": "Parakeet v3 model status", + "modelStatusHelp": "The speech model is installed and managed locally on this device.", + "modelActions": "Model management", + "modelActionsHelp": "Download or remove the Parakeet v3 speech model.", + "statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.", + "runtimeUnavailable": "Voice runtime unavailable", + "unknown": "Status unavailable", + "downloading": "Downloading model…", + "downloadingProgress": "Downloading: {{progress}}%", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "remove": "Remove", + "error": "Model error: {{message}}" } }, "setup": { diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index ed80b726aa..08574358b4 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6366,7 +6366,8 @@ }, "globalMcp": "MCP Servers · Global", "mcp": "MCP Servers · Project", - "configVersions": "Versions de configuration" + "configVersions": "Versions de configuration", + "voiceInput": "Voice Input" }, "nodeRouting": { "blockExecution": "", @@ -6886,6 +6887,25 @@ "confirmTitle": "Revenir à une configuration antérieure ?", "confirmMessage": "Restaurer cette version ? Le retour est enregistré comme une nouvelle version.", "confirmRollback": "Revenir en arrière" + }, + "voiceInput": { + "title": "Voice Input", + "enable": "Enable voice input", + "enableHelp": "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.", + "modelStatus": "Parakeet v3 model status", + "modelStatusHelp": "The speech model is installed and managed locally on this device.", + "modelActions": "Model management", + "modelActionsHelp": "Download or remove the Parakeet v3 speech model.", + "statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.", + "runtimeUnavailable": "Voice runtime unavailable", + "unknown": "Status unavailable", + "downloading": "Downloading model…", + "downloadingProgress": "Downloading: {{progress}}%", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "remove": "Remove", + "error": "Model error: {{message}}" } }, "setup": { diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index fb560f7fbf..a964df5dca 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -6366,7 +6366,8 @@ }, "globalMcp": "MCP Servers · Global", "mcp": "MCP Servers · Project", - "configVersions": "구성 버전" + "configVersions": "구성 버전", + "voiceInput": "Voice Input" }, "nodeRouting": { "blockExecution": "", @@ -6886,6 +6887,25 @@ "confirmTitle": "구성을 롤백할까요?", "confirmMessage": "이 버전을 복원할까요? 롤백은 새 버전으로 기록됩니다.", "confirmRollback": "롤백" + }, + "voiceInput": { + "title": "Voice Input", + "enable": "Enable voice input", + "enableHelp": "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.", + "modelStatus": "Parakeet v3 model status", + "modelStatusHelp": "The speech model is installed and managed locally on this device.", + "modelActions": "Model management", + "modelActionsHelp": "Download or remove the Parakeet v3 speech model.", + "statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.", + "runtimeUnavailable": "Voice runtime unavailable", + "unknown": "Status unavailable", + "downloading": "Downloading model…", + "downloadingProgress": "Downloading: {{progress}}%", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "remove": "Remove", + "error": "Model error: {{message}}" } }, "setup": { diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index df9298dbac..8168d49818 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -6366,7 +6366,8 @@ }, "globalMcp": "MCP Servers · Global", "mcp": "MCP Servers · Project", - "configVersions": "配置版本" + "configVersions": "配置版本", + "voiceInput": "Voice Input" }, "nodeRouting": { "blockExecution": "", @@ -6886,6 +6887,25 @@ "confirmTitle": "回滚配置?", "confirmMessage": "恢复此版本?回滚会记录为新版本。", "confirmRollback": "回滚" + }, + "voiceInput": { + "title": "Voice Input", + "enable": "Enable voice input", + "enableHelp": "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.", + "modelStatus": "Parakeet v3 model status", + "modelStatusHelp": "The speech model is installed and managed locally on this device.", + "modelActions": "Model management", + "modelActionsHelp": "Download or remove the Parakeet v3 speech model.", + "statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.", + "runtimeUnavailable": "Voice runtime unavailable", + "unknown": "Status unavailable", + "downloading": "Downloading model…", + "downloadingProgress": "Downloading: {{progress}}%", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "remove": "Remove", + "error": "Model error: {{message}}" } }, "setup": { diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 50251fb097..1ae5a4f780 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -6366,7 +6366,8 @@ }, "globalMcp": "MCP Servers · Global", "mcp": "MCP Servers · Project", - "configVersions": "設定版本" + "configVersions": "設定版本", + "voiceInput": "Voice Input" }, "nodeRouting": { "blockExecution": "", @@ -6886,6 +6887,25 @@ "confirmTitle": "回滾設定?", "confirmMessage": "還原此版本?回滾會記錄為新版本。", "confirmRollback": "回滾" + }, + "voiceInput": { + "title": "Voice Input", + "enable": "Enable voice input", + "enableHelp": "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.", + "modelStatus": "Parakeet v3 model status", + "modelStatusHelp": "The speech model is installed and managed locally on this device.", + "modelActions": "Model management", + "modelActionsHelp": "Download or remove the Parakeet v3 speech model.", + "statusUnavailable": "Voice runtime status could not be determined; voice mode stays disabled.", + "runtimeUnavailable": "Voice runtime unavailable", + "unknown": "Status unavailable", + "downloading": "Downloading model…", + "downloadingProgress": "Downloading: {{progress}}%", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "remove": "Remove", + "error": "Model error: {{message}}" } }, "setup": {