fix(cli-tests,dashboard): align quiet-mode result spies and voiceInput allowlist

CLI JSON/create success lines write via result() (raw stdout) so quiet mode
cannot drop machine-readable output; capture that seam in research/update/task
tests instead of console.log. Allowlist nested voiceInput settings for the
FN-7505 default-description guard and ship locale keys for Voice Input UI.
This commit is contained in:
gsxdsm
2026-07-25 09:02:59 -07:00
parent 927efb1477
commit 2ea5ac206d
10 changed files with 202 additions and 20 deletions

View File

@@ -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<typeof import("../../output.js")>();
return {
...actual,
result: (text: string) => {
resultSpy(text);
},
};
});
import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js";
function makeConstructibleMock<T extends (...args: any[]) => 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 () => {

View File

@@ -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<typeof import("../../output.js")>();
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 () => {

View File

@@ -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<void>>(),
resultSpy: vi.fn<(text: string) => void>(),
}));
vi.mock("../../output.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../output.js")>();
return {
...actual,
result: (text: string) => {
resultSpy(text);
},
};
});
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn: Record<PropertyKey, unknown> = 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<string, unknown>;
const parsed = JSON.parse(resultSpy.mock.calls[0]?.[0] as string) as Record<string, unknown>;
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<string, unknown>;
const parsed = JSON.parse(resultSpy.mock.calls[0]?.[0] as string) as Record<string, unknown>;
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");
});
});
});

View File

@@ -318,6 +318,13 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
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)",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {