Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions packages/programs-react/src/components/TraceContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Integration tests for TraceProvider's postcondition-aware
* context selection. Instruction contexts are postconditions, so
* the variables/call-info shown at the step about to execute
* instruction i come from instruction i-1 (program-level context
* at the first step). See effectiveContextForStep.
*/

import { describe, it, expect } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import React from "react";
import type { Program } from "@ethdebug/format";
import { TraceProvider, useTraceContext } from "./TraceContext.js";
import type { TraceStep } from "#utils/mockTrace";

function instr(offset: number, context: unknown): Program.Instruction {
return {
offset,
operation: { mnemonic: "JUMPDEST", arguments: [] },
context,
} as unknown as Program.Instruction;
}

const program = {
context: { variables: [{ identifier: "prog" }] },
instructions: [
instr(0, { variables: [{ identifier: "v0" }] }),
instr(3, { variables: [{ identifier: "v1" }] }),
instr(6, { variables: [{ identifier: "v2" }] }),
],
} as unknown as Program;

const trace: TraceStep[] = [
{ pc: 0, opcode: "JUMPDEST" },
{ pc: 3, opcode: "JUMPDEST" },
{ pc: 6, opcode: "JUMPDEST" },
];

// Stable identity so the provider's resolution effects don't
// re-run every render (the default `templates={}` would mint a new
// object each render).
const templates = {};

function renderTrace() {
return renderHook(() => useTraceContext(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<TraceProvider
trace={trace}
program={program}
templates={templates}
resolveVariables={false}
>
{children}
</TraceProvider>
),
});
}

const ids = (vars: { identifier?: string }[]) => vars.map((v) => v.identifier);

describe("TraceProvider postcondition context selection", () => {
it("shows the program-level context at the first step", () => {
const { result } = renderTrace();
expect(ids(result.current.currentVariables)).toEqual(["prog"]);
});

it("shows the previous instruction's variables after stepping", () => {
const { result } = renderTrace();

act(() => result.current.jumpToStep(1));
// step 1 executes pc=3; observed state is the postcondition of
// pc=0, so the panel shows v0 (NOT v1).
expect(ids(result.current.currentVariables)).toEqual(["v0"]);

act(() => result.current.jumpToStep(2));
expect(ids(result.current.currentVariables)).toEqual(["v1"]);
});
});

describe("TraceProvider postcondition call-info selection", () => {
const callProgram = {
instructions: [
instr(0, { invoke: { jump: true, identifier: "sum" } }),
instr(3, { variables: [] }),
],
} as unknown as Program;

const callTrace: TraceStep[] = [
{ pc: 0, opcode: "JUMPDEST" },
{ pc: 3, opcode: "JUMPDEST" },
];

function render() {
return renderHook(() => useTraceContext(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<TraceProvider
trace={callTrace}
program={callProgram}
templates={templates}
resolveVariables={false}
>
{children}
</TraceProvider>
),
});
}

it("does not show the invoke while parked on the invoke instruction", () => {
const { result } = render();
// step 0 is about to execute pc=0 (the invoke); it has not run
// yet, so no call info is shown.
expect(result.current.currentCallInfo).toBeUndefined();
});

it("shows the invoke once its instruction has executed", () => {
const { result } = render();
act(() => result.current.jumpToStep(1));
// step 1 observes the postcondition of pc=0, so the invoke of
// "sum" surfaces here.
expect(result.current.currentCallInfo?.kind).toBe("invoke");
expect(result.current.currentCallInfo?.identifier).toBe("sum");
});
});

describe("TraceProvider call-stack timing", () => {
// A real call as the compiler emits it: invoke on the caller
// JUMP and on the callee entry JUMPDEST (with the argument
// pointers), then the callee body. The JUMP pops its target, so
// the argument's stack slot only holds the argument once the
// JUMPDEST is reached; JUMPDEST itself is a no-op, so the state
// observed at the JUMPDEST and at the step after it coincide.
const callProgram = {
instructions: [
instr(0, { invoke: { jump: true, identifier: "f" } }),
instr(1, {
invoke: {
jump: true,
identifier: "f",
arguments: {
pointer: { group: [{ name: "x", location: "stack", slot: 0 }] },
},
},
}),
instr(2, { code: {} }),
],
} as unknown as Program;

// Stack entries are listed bottom-to-top.
const callTrace: TraceStep[] = [
{ pc: 0, opcode: "JUMP", stack: ["0x2a", "0x01"] },
{ pc: 1, opcode: "JUMPDEST", stack: ["0x2a"] },
{ pc: 2, opcode: "PUSH1", stack: ["0x2a"] },
];

function render() {
return renderHook(() => useTraceContext(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<TraceProvider
trace={callTrace}
program={callProgram}
templates={templates}
resolveVariables={true}
>
{children}
</TraceProvider>
),
});
}

it("shows the frame and the invoke banner on the same step", () => {
const { result } = render();
// Parked on the caller JUMP: neither the banner nor the frame
// list shows the call yet.
expect(result.current.currentCallInfo).toBeUndefined();
expect(result.current.callStack).toHaveLength(0);

act(() => result.current.jumpToStep(1));
expect(result.current.currentCallInfo?.kind).toBe("invoke");
expect(result.current.callStack).toHaveLength(1);
expect(result.current.callStack[0].identifier).toBe("f");
});

it("resolves arguments against the entry's postcondition", async () => {
const { result } = render();
act(() => result.current.jumpToStep(2));

// The frame is rooted at the step after the JUMPDEST, which is
// where its argument pointers describe the observed state.
expect(result.current.callStack[0].stepIndex).toBe(2);
expect(result.current.callStack[0].argumentNames).toEqual(["x"]);

await waitFor(() => {
const args = result.current.resolvedCallStack[0]?.resolvedArgs;
expect(args?.[0]?.value).toBeDefined();
});
const [x] = result.current.resolvedCallStack[0].resolvedArgs!;
expect(x.name).toBe("x");
expect(x.error).toBeUndefined();
expect(BigInt(x.value!)).toBe(42n);
});
});
53 changes: 43 additions & 10 deletions packages/programs-react/src/components/TraceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
buildCallStack,
} from "#utils/mockTrace";
import { traceStepToMachineState } from "#utils/traceState";
import { effectiveContextForStep } from "#utils/effectiveContext";

/**
* Compute a key representing an instruction's source range,
Expand Down Expand Up @@ -282,13 +283,39 @@ export function TraceProvider({
? pcToInstruction.get(currentStep.pc)
: undefined;

// Instruction contexts are POSTCONDITIONS: the semantic facts and
// pointers shown at the step about to execute instruction i come
// from instruction i-1 (program-level context at the first step).
// Pointer resolution still runs against the state observed at step
// i; only the context selection shifts. See effectiveContextForStep.
const effectiveContext = useMemo(
() =>
effectiveContextForStep({
programContext: program.context,
contextAtPc: (pc) => pcToInstruction.get(pc)?.context,
trace,
stepIndex: currentStepIndex,
}),
[program.context, pcToInstruction, trace, currentStepIndex],
);

// A synthetic instruction lets the context-tree extractors (which
// read `.context`) apply uniformly to the program-level base case.
const effectiveInstruction = useMemo<Program.Instruction | undefined>(
() =>
effectiveContext
? ({ context: effectiveContext } as Program.Instruction)
: undefined,
[effectiveContext],
);

// Extract variable metadata (synchronous)
const extractedVars = useMemo(() => {
if (!currentInstruction) {
if (!effectiveInstruction) {
return [];
}
return extractVariablesFromInstruction(currentInstruction);
}, [currentInstruction]);
return extractVariablesFromInstruction(effectiveInstruction);
}, [effectiveInstruction]);

// Async variable resolution
const [currentVariables, setCurrentVariables] = useState<ResolvedVariable[]>(
Expand Down Expand Up @@ -358,13 +385,19 @@ export function TraceProvider({
};
}, [extractedVars, currentStep, shouldResolve, templates]);

// Build call stack by scanning instructions up to current step
// Build the call stack on the same postcondition timing as the
// panels above: at step i the frame list reflects instructions
// 0..i-1 (program-level context as the base case), so a frame
// appears on the step the call-info banner first names its invoke.
const callStack = useMemo(
() => buildCallStack(trace, pcToInstruction, currentStepIndex),
[trace, pcToInstruction, currentStepIndex],
() =>
buildCallStack(trace, pcToInstruction, currentStepIndex, program.context),
[trace, pcToInstruction, currentStepIndex, program.context],
);

// Resolve argument values for call stack frames.
// Resolve argument values for call stack frames. A frame's
// stepIndex is the step whose observed state its argument
// pointers describe (the callee entry's postcondition).
// Cache by stepIndex so we don't re-resolve frames that
// haven't changed when the user steps forward.
const argCacheRef = useRef<Map<number, ResolvedCallFrame["resolvedArgs"]>>(
Expand Down Expand Up @@ -456,12 +489,12 @@ export function TraceProvider({
};
}, [callStack, shouldResolve, trace, templates]);

// Extract call info for current instruction (synchronous)
// Extract call info from the effective (postcondition) context.
const extractedCallInfo = useMemo((): CallInfo | undefined => {
if (!currentInstruction) {
if (!effectiveInstruction) {
return undefined;
}
return extractCallInfoFromInstruction(currentInstruction);
return extractCallInfoFromInstruction(effectiveInstruction);
}, [currentInstruction]);

// Async call info pointer resolution
Expand Down
2 changes: 2 additions & 0 deletions packages/programs-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export {
type FindSourceRangeOptions,
type ResolverOptions,
traceStepToMachineState,
effectiveContextForStep,
type EffectiveContextInput,
type TraceStep,
type MockTraceSpec,
} from "#utils/index";
Expand Down
79 changes: 79 additions & 0 deletions packages/programs-react/src/utils/effectiveContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Tests for effectiveContextForStep — the postcondition-aware
* selection of which instruction context describes the machine
* state observed at a given trace step.
*
* Instruction contexts are POSTCONDITIONS: instruction i's
* context describes the state AFTER i executes. A debugger
* paused at the trace step about to execute instruction i is
* observing the state produced by instruction i-1, so it must
* apply instruction i-1's context. Program-level context is the
* base case for the first step.
*/

import { describe, it, expect } from "vitest";
import type { Program } from "@ethdebug/format";
import { effectiveContextForStep } from "./effectiveContext.js";

const programContext = {
code: { range: { offset: 0, length: 1 } },
} as Program.Context;
const ctxAt10 = {
variables: [{ identifier: "a" }],
} as unknown as Program.Context;
const ctxAt20 = {
variables: [{ identifier: "b" }],
} as unknown as Program.Context;

const contextByPc = new Map<number, Program.Context>([
[10, ctxAt10],
[20, ctxAt20],
]);
const contextAtPc = (pc: number) => contextByPc.get(pc);

const trace = [{ pc: 10 }, { pc: 20 }];

describe("effectiveContextForStep", () => {
it("returns the program-level context at the first step", () => {
const result = effectiveContextForStep({
programContext,
contextAtPc,
trace,
stepIndex: 0,
});
expect(result).toBe(programContext);
});

it("returns undefined at the first step when there is no program context", () => {
const result = effectiveContextForStep({
programContext: undefined,
contextAtPc,
trace,
stepIndex: 0,
});
expect(result).toBeUndefined();
});

it("returns the PREVIOUS step's instruction context, not the current step's", () => {
const result = effectiveContextForStep({
programContext,
contextAtPc,
trace,
stepIndex: 1,
});
// step 1 executes pc=20; the observed state is the postcondition
// of pc=10, so context(pc=10) must be returned.
expect(result).toBe(ctxAt10);
expect(result).not.toBe(ctxAt20);
});

it("returns undefined when the previous step's instruction has no context", () => {
const result = effectiveContextForStep({
programContext,
contextAtPc: () => undefined,
trace,
stepIndex: 1,
});
expect(result).toBeUndefined();
});
});
Loading
Loading