fix(python): surface Python tracebacks in node error + dialog Output
Backend: PythonTransformNode wraps script execution in a try/catch that runs the exception through IronPython's ExceptionOperations.FormatException. Users now see a proper Python-style traceback (file, line, type, message) instead of the bare CLR message (which was often empty or just "unexpected token"). Source is named "script" for readable File lines in the trace. Frontend: findLatestIo also returns status + error; NodeEditorDialog renders a red-bordered traceback block in the Output column when the node failed, replacing the misleading "No data yet" state.
This commit is contained in:
@@ -179,6 +179,31 @@ describe("NodeEditorDialog", () => {
|
||||
await waitFor(() => expect(screen.getByText("code")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("shows the node error (Python traceback) in the Output column on failure", async () => {
|
||||
server.use(
|
||||
http.get("/api/workflows/:id/executions", () =>
|
||||
HttpResponse.json([
|
||||
{ id: "e-fail", workflowId: "wf1", trigger: "manual", status: "failed",
|
||||
startedAt: "", endedAt: null, error: null } satisfies ExecutionSummary,
|
||||
])),
|
||||
http.get("/api/executions/e-fail", () =>
|
||||
HttpResponse.json({
|
||||
id: "e-fail", workflowId: "wf1", trigger: "manual", status: "failed",
|
||||
startedAt: "", endedAt: "", error: null,
|
||||
nodes: [
|
||||
{ nodeId: "n1", status: "failed", startedAt: "", endedAt: "",
|
||||
error: "Traceback (most recent call last):\n File \"script\", line 1, in <module>\nNameError: name 'x' is not defined",
|
||||
inputs: [], outputs: [] },
|
||||
],
|
||||
} satisfies ExecutionDetail)),
|
||||
);
|
||||
renderDialog();
|
||||
const box = await screen.findByTestId("node-error");
|
||||
expect(box.textContent).toContain("Traceback");
|
||||
expect(box.textContent).toContain("NameError");
|
||||
expect(box.textContent).toContain("name 'x' is not defined");
|
||||
});
|
||||
|
||||
it("Webhook URL block shows for http.webhook", () => {
|
||||
const node: Node<VcNodeData> = {
|
||||
id: "w1", type: "vc",
|
||||
|
||||
@@ -89,6 +89,7 @@ export function NodeEditorDialog({
|
||||
|
||||
const inputsArray = normalizePorts(io?.inputs);
|
||||
const outputsArray = normalizePorts(io?.outputs);
|
||||
const failure = io?.status === "failed" ? (io.error ?? "(no error message)") : null;
|
||||
const previewInput = inputsArray[0]?.items[0]?.json;
|
||||
|
||||
const invalidate = useInvalidate();
|
||||
@@ -247,7 +248,7 @@ export function NodeEditorDialog({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} />
|
||||
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} failure={failure} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,17 +266,28 @@ function normalizePorts(v: unknown): Port[] {
|
||||
}
|
||||
|
||||
function PortsColumn({
|
||||
title, ports, clickableFirst, onPickPath,
|
||||
title, ports, clickableFirst, onPickPath, failure,
|
||||
}: {
|
||||
title: "Input" | "Output";
|
||||
ports: Port[];
|
||||
clickableFirst: boolean;
|
||||
onPickPath?: (path: string) => void;
|
||||
// When set, the node failed — render the error text (typically a Python
|
||||
// traceback) instead of "No data yet". Only meaningful for the Output column.
|
||||
failure?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-3 overflow-y-auto">
|
||||
<h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3>
|
||||
{ports.length === 0 && (
|
||||
{failure && (
|
||||
<div
|
||||
data-testid="node-error"
|
||||
className="mb-3 rounded border border-red-900 bg-red-950/40 p-2 text-xs font-mono text-red-300 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{failure}
|
||||
</div>
|
||||
)}
|
||||
{!failure && ports.length === 0 && (
|
||||
<div className="text-xs text-slate-500 italic">
|
||||
No data yet — press <span className="text-slate-300">Run</span> or activate the workflow.
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,27 @@ describe("findLatestIo", () => {
|
||||
expect(io).toEqual({
|
||||
inputs: [[{ json: { c: 3 } }]],
|
||||
outputs: [[{ json: { d: 4 } }]],
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates status and error for failed rows", () => {
|
||||
const d: ExecutionDetail = {
|
||||
id: "e1", workflowId: "wf", trigger: "manual", status: "failed",
|
||||
startedAt: "", endedAt: null, error: null,
|
||||
nodes: [{
|
||||
nodeId: "n1", status: "failed",
|
||||
startedAt: "", endedAt: "",
|
||||
error: "Traceback (most recent call last):\n File \"script\", line 1, in <module>\nNameError: name 'x' is not defined",
|
||||
inputs: [], outputs: [],
|
||||
}],
|
||||
};
|
||||
const io = findLatestIo(d, "n1")!;
|
||||
expect(io.status).toBe("failed");
|
||||
expect(io.error).toContain("NameError");
|
||||
});
|
||||
|
||||
it("returns null when the node did not appear in the execution", () => {
|
||||
const d = detail([{ nodeId: "n1", inputs: [], outputs: [] }]);
|
||||
expect(findLatestIo(d, "missing")).toBeNull();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { ExecutionDetail } from "@/api/types";
|
||||
import type { ExecutionDetail, NodeExecutionDto } from "@/api/types";
|
||||
|
||||
export interface NodeIo {
|
||||
inputs: unknown;
|
||||
outputs: unknown;
|
||||
status: NodeExecutionDto["status"];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function findLatestIo(execution: ExecutionDetail | undefined, nodeId: string): NodeIo | null {
|
||||
if (!execution) return null;
|
||||
const row = execution.nodes.find((n) => n.nodeId === nodeId);
|
||||
if (!row) return null;
|
||||
return { inputs: row.inputs, outputs: row.outputs };
|
||||
return { inputs: row.inputs, outputs: row.outputs, status: row.status, error: row.error };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user