diff --git a/.changeset/breakpoint-steps-cse-machine.md b/.changeset/breakpoint-steps-cse-machine.md new file mode 100644 index 0000000..5f2daaf --- /dev/null +++ b/.changeset/breakpoint-steps-cse-machine.md @@ -0,0 +1,11 @@ +--- +"@sourceacademy/common-cse-machine": minor +"@sourceacademy/runner-cse-machine": minor +"@sourceacademy/web-cse-machine": minor +--- + +Add `breakpointSteps` to the CSE snapshot protocol: a run-level array of 0-based step indices +where a breakpoint (e.g. Python's `breakpoint()`) sits on top of the control. `CseMachinePlugin.sendSnapshots` +takes it as an optional second argument (defaulting to `[]`); `CseMachineHostPlugin.receiveSnapshots` +now receives it as a second parameter. Enables host apps to wire breakpoint-navigation controls +for CSE-machine-based evaluators, matching the stepper's existing `redexNodeType` contract. diff --git a/src/common/cse-machine/src/__tests__/common.test.ts b/src/common/cse-machine/src/__tests__/common.test.ts index c431fd1..fa012f3 100644 --- a/src/common/cse-machine/src/__tests__/common.test.ts +++ b/src/common/cse-machine/src/__tests__/common.test.ts @@ -88,6 +88,7 @@ describe("structured-clone safety", () => { { stepIndex: 1, control: [{ displayText: "pop" }], stash: [], environments: [] }, ], totalSteps: 2, + breakpointSteps: [1], }; expect(structuredClone(msg)).toEqual(msg); }); @@ -106,6 +107,7 @@ describe("CseSnapshotMessage", () => { type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots, totalSteps: snapshots.length, + breakpointSteps: [], }; expect(msg.totalSteps).toBe(3); expect(msg.snapshots.length).toBe(msg.totalSteps); @@ -116,6 +118,7 @@ describe("CseSnapshotMessage", () => { type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [], totalSteps: 0, + breakpointSteps: [], }; expect(msg.snapshots).toHaveLength(0); expect(msg.totalSteps).toBe(0); @@ -126,9 +129,35 @@ describe("CseSnapshotMessage", () => { type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [], totalSteps: 0, + breakpointSteps: [], }; expect(msg.type).toBe(CSE_MESSAGE_TYPE_SNAPSHOTS); }); + + it("breakpointSteps carries the step indices where a breakpoint sits on top of the control", () => { + const snapshots: CseSnapshot[] = [ + { stepIndex: 0, control: [], stash: [], environments: [] }, + { stepIndex: 1, control: [{ displayText: "breakpoint()" }], stash: [], environments: [] }, + { stepIndex: 2, control: [], stash: [], environments: [] }, + ]; + const msg: CseSnapshotMessage = { + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots, + totalSteps: snapshots.length, + breakpointSteps: [1], + }; + expect(msg.breakpointSteps).toEqual([1]); + }); + + it("breakpointSteps may be empty when no breakpoint was hit", () => { + const msg: CseSnapshotMessage = { + type: CSE_MESSAGE_TYPE_SNAPSHOTS, + snapshots: [], + totalSteps: 0, + breakpointSteps: [], + }; + expect(msg.breakpointSteps).toHaveLength(0); + }); }); // ── CseSnapshot optional fields ─────────────────────────────────────────────── diff --git a/src/common/cse-machine/src/index.ts b/src/common/cse-machine/src/index.ts index 80db98c..300db4c 100644 --- a/src/common/cse-machine/src/index.ts +++ b/src/common/cse-machine/src/index.ts @@ -138,4 +138,11 @@ export interface CseSnapshotMessage { snapshots: CseSnapshot[]; /** Convenience count; equals `snapshots.length`. */ totalSteps: number; + /** + * 0-based `stepIndex`es, in ascending order, of steps where a breakpoint (e.g. Python's + * `breakpoint()`, JavaScript's `debugger;`) sits on top of the control. Used by the host's + * breakpoint-navigation controls to jump directly between these steps. Optional so older + * runners that predate this field remain valid `CseSnapshotMessage`s. + */ + breakpointSteps?: number[]; } diff --git a/src/runner/cse-machine/src/__tests__/runner.test.ts b/src/runner/cse-machine/src/__tests__/runner.test.ts index 29e9ab6..0f0f984 100644 --- a/src/runner/cse-machine/src/__tests__/runner.test.ts +++ b/src/runner/cse-machine/src/__tests__/runner.test.ts @@ -102,9 +102,25 @@ describe("sendSnapshots", () => { type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [], totalSteps: 0, + breakpointSteps: [], }); }); + test("defaults breakpointSteps to an empty array when omitted", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + plugin.sendSnapshots([minimalSnapshot()]); + expect(channel.send.mock.calls[0][0].breakpointSteps).toEqual([]); + }); + + test("forwards the breakpointSteps argument unchanged", () => { + const channel = makeChannel(); + const plugin = makePlugin(channel); + const snapshots = [minimalSnapshot(), { ...minimalSnapshot(), stepIndex: 1 }]; + plugin.sendSnapshots(snapshots, [1]); + expect(channel.send.mock.calls[0][0].breakpointSteps).toEqual([1]); + }); + test("handles a large batch of snapshots", () => { const channel = makeChannel(); const plugin = makePlugin(channel); diff --git a/src/runner/cse-machine/src/index.ts b/src/runner/cse-machine/src/index.ts index 16f56e6..fdc0902 100644 --- a/src/runner/cse-machine/src/index.ts +++ b/src/runner/cse-machine/src/index.ts @@ -39,12 +39,14 @@ export class CseMachinePlugin implements IPlugin { /** * Send a full run's worth of snapshots to the host plugin. * @param snapshots The serialized evaluation snapshots, in step order. + * @param breakpointSteps 0-based step indices where a breakpoint sits on top of the control. */ - sendSnapshots(snapshots: CseSnapshot[]): void { + sendSnapshots(snapshots: CseSnapshot[], breakpointSteps: number[] = []): void { this.__cseChannel.send({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots, totalSteps: snapshots.length, + breakpointSteps, }); } } diff --git a/src/web/cse-machine/src/__tests__/web.test.ts b/src/web/cse-machine/src/__tests__/web.test.ts index e816288..0926db1 100644 --- a/src/web/cse-machine/src/__tests__/web.test.ts +++ b/src/web/cse-machine/src/__tests__/web.test.ts @@ -34,10 +34,11 @@ const snapshot = (): CseSnapshot => ({ environments: [{ id: "g", name: "global", parentId: null, bindings: [], isActive: true }], }); -const validMessage = (snapshots: CseSnapshot[] = [snapshot()]) => ({ +const validMessage = (snapshots: CseSnapshot[] = [snapshot()], breakpointSteps: number[] = []) => ({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots, totalSteps: snapshots.length, + breakpointSteps, }); // ── Identity / wiring ───────────────────────────────────────────────────────── @@ -83,7 +84,7 @@ describe("valid messages", () => { const { receive } = makePlugin(channel); channel.emit(validMessage()); expect(receive).toHaveBeenCalledOnce(); - expect(receive).toHaveBeenCalledWith([snapshot()]); + expect(receive).toHaveBeenCalledWith([snapshot()], []); }); test("receiveSnapshots receives multiple snapshots in order", () => { @@ -91,7 +92,7 @@ describe("valid messages", () => { const { receive } = makePlugin(channel); const snaps = [snapshot(), { ...snapshot(), stepIndex: 1 }, { ...snapshot(), stepIndex: 2 }]; channel.emit(validMessage(snaps)); - expect(receive).toHaveBeenCalledWith(snaps); + expect(receive).toHaveBeenCalledWith(snaps, []); }); test("receiveSnapshots is called once per valid message", () => { @@ -106,7 +107,7 @@ describe("valid messages", () => { const channel = makeChannel(); const { receive } = makePlugin(channel); channel.emit(validMessage([])); - expect(receive).toHaveBeenCalledWith([]); + expect(receive).toHaveBeenCalledWith([], []); }); test("preserves snapshot fields passed through the channel", () => { @@ -120,7 +121,21 @@ describe("valid messages", () => { currentLine: 9, }; channel.emit(validMessage([snap])); - expect(receive).toHaveBeenCalledWith([snap]); + expect(receive).toHaveBeenCalledWith([snap], []); + }); + + test("receiveSnapshots is called with the breakpointSteps array", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit(validMessage([snapshot(), { ...snapshot(), stepIndex: 1 }], [1])); + expect(receive).toHaveBeenCalledWith([snapshot(), { ...snapshot(), stepIndex: 1 }], [1]); + }); + + test("defaults breakpointSteps to an empty array when the field is missing from the message", () => { + const channel = makeChannel(); + const { receive } = makePlugin(channel); + channel.emit({ type: CSE_MESSAGE_TYPE_SNAPSHOTS, snapshots: [snapshot()], totalSteps: 1 }); + expect(receive).toHaveBeenCalledWith([snapshot()], []); }); }); diff --git a/src/web/cse-machine/src/index.ts b/src/web/cse-machine/src/index.ts index 1407915..bb1a322 100644 --- a/src/web/cse-machine/src/index.ts +++ b/src/web/cse-machine/src/index.ts @@ -36,8 +36,9 @@ export abstract class CseMachineHostPlugin implements IPlugin { /** * Called by the plugin with each received batch of snapshots. * Implement this in the host app to wire snapshots into the visualization layer. + * @param breakpointSteps 0-based step indices where a breakpoint sits on top of the control. */ - abstract receiveSnapshots(snapshots: CseSnapshot[]): void; + abstract receiveSnapshots(snapshots: CseSnapshot[], breakpointSteps: number[]): void; constructor( _conduit: IConduit, @@ -53,7 +54,10 @@ export abstract class CseMachineHostPlugin implements IPlugin { Array.isArray(message.snapshots) && message.totalSteps === message.snapshots.length ) { - this.receiveSnapshots(message.snapshots); + this.receiveSnapshots( + message.snapshots, + Array.isArray(message.breakpointSteps) ? message.breakpointSteps : [], + ); } }); }