mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
52ca222bd0
commit
3b94f2c2ef
4 files changed
+298
-105
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user