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
11 changes: 11 additions & 0 deletions .changeset/breakpoint-steps-cse-machine.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions src/common/cse-machine/src/__tests__/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("structured-clone safety", () => {
{ stepIndex: 1, control: [{ displayText: "pop" }], stash: [], environments: [] },
],
totalSteps: 2,
breakpointSteps: [1],
};
expect(structuredClone(msg)).toEqual(msg);
});
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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 ───────────────────────────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions src/common/cse-machine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
16 changes: 16 additions & 0 deletions src/runner/cse-machine/src/__tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/runner/cse-machine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
25 changes: 20 additions & 5 deletions src/web/cse-machine/src/__tests__/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -83,15 +84,15 @@ 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", () => {
const channel = makeChannel();
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", () => {
Expand All @@ -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", () => {
Expand All @@ -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()], []);
});
});

Expand Down
8 changes: 6 additions & 2 deletions src/web/cse-machine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 : [],
);
}
});
}
Expand Down