fix(signals): honor selected files and input format options

Keep staged recording names as relative file operands, including a literal dash. Recognize exact selected input-option refusal captions even when sigrok exits successfully or a caller key contains a newline; retain producer diagnostics and typed request failures across CLI/MCP.

Fixes #6875
Fixes #6839
This commit is contained in:
morluto committed 2026-10-24 04:42:09 +08:00
1 parent 52ca222bd0
commit 3b94f2c2ef
4 files changed
+298 -105

No files matched your search

+14 -4
View File
@@ -40,12 +40,22 @@ unrelated producer warnings do not change request status.
Omitting `input_format` requests sigrok's autodetection. REA uses an immutable
private copy whose filename retains the selected recording's final suffix,
including a dotfile basename such as `.csv`. Filename-based format detection
receives that hint without exposing the
caller's directory to sigrok. Omitting `annotations`
retains every annotation class. Both omitted selections are recorded as null.
receives that hint without exposing the caller's directory to sigrok. The
input operand is an explicit relative path within the private workspace,
so a recording named `-` is opened as a file rather than
[sigrok's stdin operand](https://github.com/sigrokproject/sigrok-cli/blob/sigrok-cli-0.7.2/input.c#L49-L75).
Omitting `annotations` retains every annotation class. Both omitted selections
are recorded as null.
If sigrok reports an explicitly selected input format as unknown, REA returns
`invalid_request` for `input_format`, retains the producer diagnostic, and
advises correcting the format or omitting it for autodetection. See the
advises correcting the format or omitting it for autodetection. The
[tagged option parser](https://github.com/sigrokproject/sigrok-cli/blob/sigrok-cli-0.7.2/parsers.c#L387-L404)
warns when an option key is unknown. When that diagnostic's key exactly matches
a selected `input_format` option, REA returns `invalid_request`, even on exit zero.
Fallback output and the diagnostic remain in `captured_output`; unrelated
option warnings stay visible without rejecting the request. Matching keeps
the complete diagnostic caption, including an LF inside a selected key;
embedded warning substrings do not establish a refusal. See the
[upstream manual](https://sigrok.org/wiki/Sigrok-cli) for formats, decoder
stacking and annotation selectors. No arbitrary command flags are accepted.
+5 -3
View File
@@ -240,8 +240,8 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
},
},
);
// Some selected-channel refusals are diagnostics, not process failures:
// sigrok can emit fallback annotations and still exit successfully.
// Selected-channel and input-option refusals can be diagnostics rather
// than process failures: fallback annotations may still exit successfully.
const rejection =
selection === undefined
? undefined
@@ -331,7 +331,9 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
phase = "producer";
const args = [
"--dont-scan",
`--input-file=${snapshotName}`,
// An explicit relative path preserves filename hints while keeping
// a recording named "-" distinct from sigrok's stdin operand.
`--input-file=./${snapshotName}`,
`--protocol-decoders=${input.decoders}`,
"--protocol-decoder-samplenum",
...(input.input_format === null
+40 -14
View File
@@ -14,6 +14,8 @@ export type SigrokSelection =
>
| Pick<InspectSignalDecodersInput, "decoder">;
type SigrokDiagnosticMatcher = (message: string) => boolean;
const rejectedSelector = (
field: "decoder" | "decoders" | "annotations" | "input_format",
producerMessage: string,
@@ -32,6 +34,8 @@ const CHANNEL_RECOVERY =
"Set each decoder channel target to a channel present in the recording; inspect_signal_decoders shows the selected decoder's channel IDs.";
const INPUT_FORMAT_RECOVERY =
"Use a supported sigrok input format name, or omit input_format to let sigrok-cli autodetect the recording.";
const INPUT_OPTION_RECOVERY =
"Use options supported by the selected sigrok input format, or remove the rejected option.";
/**
* sigrok-cli loads selected decoders before decoding or showing help, so an
@@ -40,13 +44,13 @@ const INPUT_FORMAT_RECOVERY =
* whose own imports or code fail to load.
*/
const unknownDecoderRefusal = (
lines: ReadonlySet<string>,
hasDiagnostic: SigrokDiagnosticMatcher,
id: string,
): string | undefined => {
const producerMessage = `Failed to load protocol decoder ${id}.`;
return id !== "" &&
lines.has(producerMessage) &&
lines.has(
hasDiagnostic(producerMessage) &&
hasDiagnostic(
`srd: ModuleNotFoundError: Failed to load decoder ${id}: import by name failed: No module named '${id}'`,
)
? producerMessage
@@ -55,12 +59,12 @@ const unknownDecoderRefusal = (
/**
* The sigrok-cli 0.7.2 channel mapper reports missing and unknown targets as
* plain stderr lines, even though it continues with the other mappings. Match
* plain stderr diagnostics, even though it continues with the other mappings. Match
* the exact selected channel key or target value rather than searching the
* selector text for a substring.
*/
const missingSelectedChannelRefusal = (
lines: ReadonlySet<string>,
hasDiagnostic: SigrokDiagnosticMatcher,
decoders: string,
): string | undefined => {
const selectedValues = new Set<string>();
@@ -69,14 +73,14 @@ const missingSelectedChannelRefusal = (
const separator = assignment.indexOf("=");
if (separator < 0 && assignment !== "") {
const producerMessage = `Channel name for "${assignment}" missing.`;
if (lines.has(`cli: ${producerMessage}`)) return producerMessage;
if (hasDiagnostic(`cli: ${producerMessage}`)) return producerMessage;
}
if (separator > 0) selectedValues.add(assignment.slice(separator + 1));
}
}
for (const channel of selectedValues) {
const producerMessage = `No channel with name "${channel}" found.`;
if (lines.has(`cli: ${producerMessage}`)) return producerMessage;
if (hasDiagnostic(`cli: ${producerMessage}`)) return producerMessage;
}
return undefined;
};
@@ -89,32 +93,54 @@ export const parseSigrokSelectorRejection = (
stderr: string,
selection: SigrokSelection,
): SigrokSelectorRejection | undefined => {
const lines = new Set(stderr.split("\n"));
// Selected option keys may contain LF, which the tagged producer prints verbatim.
// Match the complete caption at diagnostic boundaries without splitting it.
const hasDiagnostic: SigrokDiagnosticMatcher = (message) =>
stderr === message ||
stderr.startsWith(`${message}\n`) ||
stderr.endsWith(`\n${message}`) ||
stderr.includes(`\n${message}\n`);
if ("decoder" in selection) {
const refusal =
selection.decoder === null
? undefined
: unknownDecoderRefusal(lines, selection.decoder);
: unknownDecoderRefusal(hasDiagnostic, selection.decoder);
return refusal === undefined
? undefined
: rejectedSelector("decoder", refusal, HELP_RECOVERY);
}
const inputFormat = selection.input_format?.split(":")[0] ?? "";
const [inputFormat = "", ...inputOptions] =
selection.input_format?.split(":") ?? [];
const inputFormatRefusal = `Error: unknown input module '${inputFormat}'.`;
if (inputFormat !== "" && lines.has(`cli: ${inputFormatRefusal}`))
if (inputFormat !== "" && hasDiagnostic(`cli: ${inputFormatRefusal}`))
return rejectedSelector(
"input_format",
inputFormatRefusal,
INPUT_FORMAT_RECOVERY,
);
// sigrok-cli 0.7.2 parses format:option=value with colon separators and
// warns on unknown option keys, then continues with the supported options.
for (const option of inputOptions) {
const key = option.split("=", 1)[0] ?? "";
const producerMessage = `Unknown keyword: ${key}.`;
if (key !== "" && hasDiagnostic(producerMessage))
return rejectedSelector(
"input_format",
producerMessage,
INPUT_OPTION_RECOVERY,
);
}
// sigrok-cli stacks decoders with "," and appends options with ":".
for (const entry of selection.decoders.split(",")) {
const refusal = unknownDecoderRefusal(lines, entry.split(":")[0] ?? "");
const refusal = unknownDecoderRefusal(
hasDiagnostic,
entry.split(":")[0] ?? "",
);
if (refusal !== undefined)
return rejectedSelector("decoders", refusal, DECODE_RECOVERY);
}
const missingChannel = missingSelectedChannelRefusal(
lines,
hasDiagnostic,
selection.decoders,
);
if (missingChannel !== undefined)
@@ -125,7 +151,7 @@ export const parseSigrokSelectorRejection = (
if (decoder === "" || classes === undefined || rest.length > 0) continue;
for (const annotation of classes.split(":")) {
const producerMessage = `Annotation '${annotation}' not found for protocol decoder '${decoder}'.`;
if (annotation !== "" && lines.has(producerMessage))
if (annotation !== "" && hasDiagnostic(producerMessage))
return rejectedSelector(
"annotations",
producerMessage,
+239 -84
View File
@@ -6,7 +6,8 @@ import { signalDecodersSchema } from "../../../src/domain/signals/signalDecoders
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js";
import { it, expect, onTestFinished } from "vitest";
import { writeFile, readdir, chmod, symlink } from "node:fs/promises";
import { writeFile, readFile, readdir, chmod, symlink } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, join } from "node:path";
import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js";
import { SigrokRecordingProvider } from "../../../src/signals/SigrokRecordingProvider.js";
@@ -30,7 +31,7 @@ const producerSource = (mode: string, snapshotName: string): string =>
`import {readFileSync, writeFileSync, chmodSync, truncateSync} from 'node:fs';
const inputFileArgument = process.argv.find((argument) => argument.startsWith('--input-file='));
const snapshotName = inputFileArgument?.slice('--input-file='.length);
if (inputFileArgument !== undefined && inputFileArgument !== ${JSON.stringify(`--input-file=${snapshotName}`)}) throw Error('wrong snapshot name');
if (${JSON.stringify(mode)} !== 'input-operand' && inputFileArgument !== undefined && inputFileArgument !== ${JSON.stringify(`--input-file=./${snapshotName}`)}) throw Error('wrong snapshot name');
if (process.argv.includes('--version')) {
if (${JSON.stringify(mode)} === 'invalid-version-stdout') process.stdout.write(Buffer.concat([Buffer.from('sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3\\n'), Buffer.from([0xff])]));
else console.log(${JSON.stringify(mode)} === 'bad-version' ? 'sigrok-cli 0.7.1' : 'sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3');
@@ -40,6 +41,10 @@ if (process.argv.includes('--version')) {
else if (${JSON.stringify(mode)} === 'bad-version') console.log('unexpected version fallback');
else if (${JSON.stringify(mode)} === 'nonzero') { console.error('invalid decoder'); process.exitCode = 1; }
else if (${JSON.stringify(mode)} === 'unknown-input-format') { if (!process.argv.includes('--input-format=missingformat')) console.error('fixture: selected input format was not forwarded'); console.error("cli: Error: unknown input module 'missingformat'."); process.exitCode = 1; }
else if (${JSON.stringify(mode)} === 'unknown-input-option') { if (!process.argv.includes('--input-format=vcd:down_sample=2')) throw Error('selected input option was not forwarded'); process.stdout.write('1104-1938 uart-1: 52\\n'); console.error('Unknown keyword: down_sample.'); }
else if (${JSON.stringify(mode)} === 'unknown-multiline-input-option') { if (!process.argv.includes(${JSON.stringify("--input-format=vcd:down\nsample=2")})) throw Error('selected input option was not forwarded'); process.stdout.write('1104-1938 uart-1: 52\\n'); console.error(${JSON.stringify("Unknown keyword: down\nsample.")}); }
else if (${JSON.stringify(mode)} === 'other-input-option-warning') { process.stdout.write('10-20 uart-1: 42\\n'); console.error('Unknown keyword: down_sample.'); }
else if (${JSON.stringify(mode)} === 'embedded-input-option-warning') { process.stdout.write('10-20 uart-1: 42\\n'); console.error('context: Unknown keyword: down_sample. suffix'); }
else if (${JSON.stringify(mode)} === 'unknown-decoder') { console.error("srd: ModuleNotFoundError: Failed to load decoder nosuchdecoder: import by name failed: No module named 'nosuchdecoder'\\nFailed to load protocol decoder nosuchdecoder."); process.exitCode = 1; }
else if (${JSON.stringify(mode)} === 'broken-decoder') { console.error("srd: ModuleNotFoundError: Failed to load decoder uart: import by name failed: No module named 'serial'\\nFailed to load protocol decoder uart."); process.exitCode = 1; }
else if (${JSON.stringify(mode)} === 'unknown-annotation') { console.error("Annotation 'nosuchclass' not found for protocol decoder 'uart'."); process.exitCode = 1; }
@@ -60,7 +65,8 @@ else if (${JSON.stringify(mode)} === 'wait') { console.log('ready'); setInterval
else if (${JSON.stringify(mode)} === 'many') process.stdout.write(Array.from({length:90},(_,i)=>'line '+(i+1)+'\\r\\n').join(''));
else if (process.argv.includes('--list-supported')) { console.log(${JSON.stringify(mode)} === 'unicode-search' ? 'Supported protocol decoders:\\n uart Universal Asynchronous Receiver/Transmitter\\n sigma ΟΣ\\n' : 'Supported hardware drivers:\\n ignored Device\\nSupported protocol decoders:\\n uart Universal Asynchronous Receiver/Transmitter\\n i2c Inter-Integrated Circuit\\n'); console.error('catalog warning'); }
else if (process.argv.includes('--show')) console.log(${JSON.stringify(mode)} === 'unicode-search' ? 'ID: uart\\nOptions:\\n- label: ΟΣ' : 'ID: uart\\nOptions:\\n- baudrate: Baud rate (default 115200)\\n- parity: Parity');
else if (${JSON.stringify(mode)} === 'csv-autodetect') { if (!process.argv.includes(${JSON.stringify(`--input-file=${snapshotName}`)}) || readFileSync(snapshotName, 'utf8') !== 'CLK,MOSI\\n0,0\\n1,0\\n') throw Error('CSV suffix or copy bytes lost'); console.log('10-20 spi-1: 1'); }
else if (${JSON.stringify(mode)} === 'csv-autodetect') { if (!process.argv.includes(${JSON.stringify(`--input-file=./${snapshotName}`)}) || readFileSync(snapshotName, 'utf8') !== 'CLK,MOSI\\n0,0\\n1,0\\n') throw Error('CSV suffix or copy bytes lost'); console.log('10-20 spi-1: 1'); }
else if (${JSON.stringify(mode)} === 'input-operand') { console.log(readFileSync(snapshotName === '-' ? 0 : snapshotName).toString('hex')); }
else if (${JSON.stringify(mode)} === 'long-basename') { if (!process.argv.includes('--input-format=vcd') || readFileSync(snapshotName, 'utf8') !== 'recording bytes') throw Error('long source basename or explicit format was lost'); console.log('10-20 uart-1: 42'); }
else { if(readFileSync(snapshotName, 'utf8') !== 'recording bytes') throw Error('wrong copy'); console.log('10-20 uart-1: 42'); console.error('producer warning'); }
`;
@@ -215,7 +221,7 @@ it("pages literal output without changing decoder arguments or normalizing text"
expect(report.output_window.returned_lines).toBe(count);
expect(report.tool.arguments).toEqual([
"--dont-scan",
`--input-file=${basename(f.path)}`,
`--input-file=./${basename(f.path)}`,
"--protocol-decoders=uart",
"--protocol-decoder-samplenum",
]);
@@ -258,7 +264,7 @@ it("decodes an immutable copy, preserves options and warnings, and releases its
expect(report.selection.input_format).toBeNull();
expect(report.tool.arguments).toEqual([
"--dont-scan",
"--input-file=recorded bytes.vcd",
"--input-file=./recorded bytes.vcd",
"--protocol-decoders=uart:rx=RX:baudrate=9600",
"--protocol-decoder-samplenum",
"--protocol-decoder-annotations=uart=rx-data",
@@ -285,7 +291,7 @@ it.each(["capture.csv", ".capture.csv", ".csv"])(
});
expect(report.tool.arguments).toEqual([
"--dont-scan",
`--input-file=${recordingName}`,
`--input-file=./${recordingName}`,
"--protocol-decoders=spi:clk=CLK:mosi=MOSI",
"--protocol-decoder-samplenum",
]);
@@ -307,13 +313,44 @@ it("stages a long source basename without expanding it before decoding", async (
expect(report.artifact.path).toBe(f.path);
expect(report.selection.input_format).toBe("vcd");
expect(report.annotations_text).toBe("10-20 uart-1: 42\n");
expect(report.tool.arguments).toContain(`--input-file=${recordingName}`);
expect(report.tool.arguments).toContain(`--input-file=./${recordingName}`);
expect(f.calls()).toBe(2);
expect(
(await readdir(f.directory)).filter((name) => name.startsWith("decoder-")),
).toEqual([]);
});
it.each(["control.vcd", "-"])(
"opens the complete private recording for %s rather than stdin",
async (recordingName) => {
// Model only the tagged sigrok input operand rule, not protocol decoding.
const f = await fixture("input-operand", false, recordingName);
const source = await readFile(f.path);
const result = await f.service.decode({
path: f.path,
decoders: "uart",
input_format: "vcd",
});
if (!result.ok) throw result.error;
const report = signalRecordingSchema.parse(result.value.normalized_result);
expect(report.artifact).toEqual({
path: f.path,
bytes: source.length,
sha256: createHash("sha256").update(source).digest("hex"),
});
expect(report.selection).toMatchObject({
path: f.path,
input_format: "vcd",
});
expect(report.annotations_text).toBe(`${source.toString("hex")}\n`);
expect(report.tool.arguments).not.toContain("--input-file=-");
expect(await readFile(f.path)).toEqual(source);
expect((await readdir(f.directory)).sort()).toEqual(
[recordingName, "producer.mjs"].sort(),
);
},
);
it.each([
["nonzero", "ProviderAdapterError"],
["malformed-copy", "AnalysisArtifactChangedError"],
@@ -395,93 +432,211 @@ it.each([
},
);
it("reports an unknown selected input format through CLI and MCP", async () => {
const f = await fixture("unknown-input-format");
const refusal = "Error: unknown input module 'missingformat'.";
const issueMessage =
`sigrok-cli rejected the input_format selector: ${refusal} ` +
"Use a supported sigrok input format name, or omit input_format to let sigrok-cli autodetect the recording.";
const harness = await createApplicationMcpHarness({
serverOptions: { environment: {}, signalRecording: f.service },
});
onTestFinished(harness.close);
const { error } = parseMcpToolError(
await harness.client.callTool({
name: "decode_signal_recording",
arguments: {
path: f.path,
decoders: "uart",
input_format: "missingformat",
},
}),
);
expect(error).toMatchObject({
code: "invalid_request",
category: "invalid_input",
message: expect.stringContaining(issueMessage),
details: {
issues: [
{
path: ["input_format"],
reason: "invalid_value",
message: expect.stringContaining(issueMessage),
it.each([
{
mode: "unknown-input-format",
inputFormat: "missingformat",
refusal: "Error: unknown input module 'missingformat'.",
recovery:
"Use a supported sigrok input format name, or omit input_format to let sigrok-cli autodetect the recording.",
producerExit: 1,
producerStdout: "",
},
{
mode: "unknown-input-option",
inputFormat: "vcd:down_sample=2",
refusal: "Unknown keyword: down_sample.",
recovery:
"Use options supported by the selected sigrok input format, or remove the rejected option.",
producerExit: 0,
producerStdout: "1104-1938 uart-1: 52\n",
},
{
mode: "unknown-multiline-input-option",
inputFormat: "vcd:down\nsample=2",
refusal: "Unknown keyword: down\nsample.",
recovery:
"Use options supported by the selected sigrok input format, or remove the rejected option.",
producerExit: 0,
producerStdout: "1104-1938 uart-1: 52\n",
},
])(
"reports $mode through CLI and MCP without successful fallback Evidence",
async ({
mode,
inputFormat,
refusal,
recovery,
producerExit,
producerStdout,
}) => {
const f = await fixture(mode);
const issueMessage =
`sigrok-cli rejected the input_format selector: ${refusal} ` + recovery;
const harness = await createApplicationMcpHarness({
serverOptions: { environment: {}, signalRecording: f.service },
});
onTestFinished(harness.close);
const { error } = parseMcpToolError(
await harness.client.callTool({
name: "decode_signal_recording",
arguments: {
path: f.path,
decoders: "uart",
input_format: inputFormat,
},
}),
);
expect(error).toMatchObject({
code: "invalid_request",
category: "invalid_input",
message: expect.stringContaining(issueMessage),
details: {
issues: [
{
path: ["input_format"],
reason: "invalid_value",
message: expect.stringContaining(issueMessage),
},
],
captured_output: {
exit_code: producerExit,
stdout: producerStdout,
stderr: expect.stringContaining(refusal),
},
],
captured_output: {
exit_code: 1,
stderr: expect.stringContaining(refusal),
},
},
});
expect(JSON.stringify(error)).not.toContain(
"fixture: selected input format was not forwarded",
);
});
expect(JSON.stringify(error)).not.toContain(
"fixture: selected input format was not forwarded",
);
const cli = Cli.create("signal-input-format-refusal-test", { sync: false });
registerSignalCommands(cli, silentLogger, {}, f.service);
let stdout = "";
await cli.serve(
[
"decode-signal-recording",
f.path,
"--decoders",
"uart",
"--input_format",
"missingformat",
"--format",
"json",
],
{
stdout: (text) => {
stdout += text;
},
exit: () => {},
},
);
expect(JSON.parse(stdout)).toMatchObject({
code: "invalid_request",
message: expect.stringContaining(issueMessage),
details: {
issues: [
const cli = Cli.create("signal-input-format-refusal-test", { sync: false });
registerSignalCommands(cli, silentLogger, {}, f.service);
let stdout = "";
let commandExitCode: typeof process.exitCode;
const previousExitCode = process.exitCode;
try {
process.exitCode = 0;
await cli.serve(
[
"decode-signal-recording",
f.path,
"--decoders",
"uart",
"--input_format",
inputFormat,
"--format",
"json",
],
{
path: ["input_format"],
reason: "invalid_value",
message: expect.stringContaining(issueMessage),
stdout: (text) => {
stdout += text;
},
exit: () => {},
},
],
);
commandExitCode = process.exitCode;
} finally {
process.exitCode = previousExitCode;
}
expect(commandExitCode).toBe(1);
const envelope: unknown = JSON.parse(stdout);
expect(envelope).not.toHaveProperty("evidence_id");
expect(envelope).toMatchObject({
code: "invalid_request",
message: expect.stringContaining(issueMessage),
details: {
issues: [
{
path: ["input_format"],
reason: "invalid_value",
message: expect.stringContaining(issueMessage),
},
],
captured_output: {
exit_code: producerExit,
stdout: producerStdout,
stderr: expect.stringContaining(refusal),
},
},
});
expect(stdout).not.toContain(
"fixture: selected input format was not forwarded",
);
expect(f.calls()).toBe(4);
expect(
(await readdir(f.directory)).filter((name) =>
name.startsWith("decoder-"),
),
).toEqual([]);
},
);
it.each([
"vcd:downsample=2",
"vcd:down_sample_extra=2",
"vcd:label=down_sample",
null,
])(
"retains an unrelated input-option warning with selection %s",
async (inputFormat) => {
const f = await fixture("other-input-option-warning");
const result = await f.service.decode({
path: f.path,
decoders: "uart",
input_format: inputFormat,
});
if (!result.ok) throw result.error;
const report = signalRecordingSchema.parse(result.value.normalized_result);
expect(report.selection.input_format).toBe(inputFormat);
expect(report.annotations_text).toBe("10-20 uart-1: 42\n");
expect(report.diagnostics).toBe("Unknown keyword: down_sample.\n");
if (inputFormat !== null)
expect(report.tool.arguments).toContain(`--input-format=${inputFormat}`);
else
expect(
report.tool.arguments.some((argument) =>
argument.startsWith("--input-format="),
),
).toBe(false);
},
);
it("recognizes a rejected bare input-format option key", async () => {
const f = await fixture("other-input-option-warning");
const result = await f.service.decode({
path: f.path,
decoders: "uart",
input_format: "vcd:down_sample",
});
if (result.ok) throw new Error("Expected selected bare option refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [{ path: ["input_format"], reason: "invalid_value" }],
captured_output: {
exit_code: 1,
stderr: expect.stringContaining(refusal),
exit_code: 0,
stdout: "10-20 uart-1: 42\n",
stderr: "Unknown keyword: down_sample.\n",
},
},
});
expect(stdout).not.toContain(
"fixture: selected input format was not forwarded",
});
it("does not treat an embedded warning substring as a selected option refusal", async () => {
const f = await fixture("embedded-input-option-warning");
const result = await f.service.decode({
path: f.path,
decoders: "uart",
input_format: "vcd:down_sample=2",
});
if (!result.ok) throw result.error;
const report = signalRecordingSchema.parse(result.value.normalized_result);
expect(report.selection.input_format).toBe("vcd:down_sample=2");
expect(report.annotations_text).toBe("10-20 uart-1: 42\n");
expect(report.diagnostics).toBe(
"context: Unknown keyword: down_sample. suffix\n",
);
expect(f.calls()).toBe(4);
expect(
(await readdir(f.directory)).filter((name) => name.startsWith("decoder-")),
).toEqual([]);
});
it("projects sigrok's zero-exit missing-channel refusal through CLI and MCP with partial output", async () => {