Skip to content

Commit 190a8f8

Browse files
committed
fix!: improve test scenario reliability
Replace the internal event mechanism with server-side pause points to ensure test scenarios reproduce the same result on every run, regardless of network latency.
1 parent 1ed4b57 commit 190a8f8

11 files changed

+191
-202
lines changed

src/APIClient.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
APISimStartParams,
1111
PinReadResponse,
1212
} from './APITypes.js';
13+
import { PausePoint, type PausePointParams } from './PausePoint.js';
1314
import { readVersion } from './readVersion.js';
1415

1516
const DEFAULT_SERVER = process.env.WOKWI_CLI_SERVER ?? 'wss://wokwi.com/api/ws/beta';
@@ -19,10 +20,12 @@ export class APIClient {
1920
private socket: WebSocket;
2021
private connectionAttempts = 0;
2122
private lastId = 0;
23+
private lastPausePointId = 0;
2224
private closed = false;
2325
private _running = false;
2426
private _lastNanos = 0;
2527
private readonly apiEvents = new EventTarget();
28+
private readonly pausePoints = new Map<string, PausePoint>();
2629
private readonly pendingCommands = new Map<
2730
string,
2831
[(result: any) => void, (error: Error) => void]
@@ -139,9 +142,9 @@ export class APIClient {
139142
return await this.sendCommand('sim:pause');
140143
}
141144

142-
async simResume(pauseAfter?: number, { waitForBytes }: { waitForBytes?: number[] } = {}) {
145+
async simResume(pauseAfter?: number) {
143146
this._running = true;
144-
return await this.sendCommand('sim:resume', { pauseAfter, waitForBytes });
147+
return await this.sendCommand('sim:resume', { pauseAfter });
145148
}
146149

147150
async simRestart({ pause }: { pause?: boolean } = {}) {
@@ -162,6 +165,15 @@ export class APIClient {
162165
});
163166
}
164167

168+
get pausedPromise() {
169+
if (!this._running) {
170+
return Promise.resolve();
171+
}
172+
return new Promise<APIEvent<any>>((resolve) => {
173+
this.listen('sim:pause', resolve, { once: true });
174+
});
175+
}
176+
165177
serialMonitorWritable() {
166178
return new Writable({
167179
write: (chunk, encoding, callback) => {
@@ -196,6 +208,46 @@ export class APIClient {
196208
});
197209
}
198210

211+
async addPausePoint(params: PausePointParams, resume = false) {
212+
const id = `pp${this.lastPausePointId++}_${params.type}`;
213+
const commands = [this.sendCommand('pause-point:add', { id, ...params })];
214+
if (resume && !this._running) {
215+
commands.push(this.simResume());
216+
this._running = true;
217+
}
218+
await Promise.all(commands);
219+
const pausePoint = new PausePoint(id, params);
220+
this.pausePoints.set(id, pausePoint);
221+
return pausePoint;
222+
}
223+
224+
async removePausePoint(pausePoint: PausePoint) {
225+
if (this.pausePoints.has(pausePoint.id)) {
226+
this.pausePoints.delete(pausePoint.id);
227+
await this.sendCommand('pause-point:remove', { id: pausePoint.id });
228+
return true;
229+
}
230+
return false;
231+
}
232+
233+
async atNanos(nanos: number) {
234+
const pausePoint = await this.addPausePoint({ type: 'time-absolute', nanos });
235+
await pausePoint.promise;
236+
}
237+
238+
async delay(nanos: number) {
239+
const pausePoint = await this.addPausePoint({ type: 'time-relative', nanos }, true);
240+
await pausePoint.promise;
241+
}
242+
243+
async waitForSerialBytes(bytes: number[] | Uint8Array) {
244+
if (bytes instanceof Uint8Array) {
245+
bytes = Array.from(bytes);
246+
}
247+
const pausePoint = await this.addPausePoint({ type: 'serial-bytes', bytes }, true);
248+
await pausePoint.promise;
249+
}
250+
199251
async sendCommand<T = unknown>(command: string, params?: any) {
200252
return await new Promise<T>((resolve, reject) => {
201253
const id = this.lastId++;
@@ -251,6 +303,12 @@ export class APIClient {
251303
processEvent(message: APIEvent) {
252304
if (message.event === 'sim:pause') {
253305
this._running = false;
306+
const pausePointId: string = message.payload.pausePoint;
307+
const pausePoint = this.pausePoints.get(pausePointId);
308+
if (pausePoint) {
309+
pausePoint.resolve(message.payload.pausePointInfo);
310+
this.pausePoints.delete(pausePointId);
311+
}
254312
}
255313
this._lastNanos = message.nanos;
256314
this.apiEvents.dispatchEvent(new CustomEvent<APIEvent>(message.event, { detail: message }));

src/EventManager.spec.ts

Lines changed: 0 additions & 42 deletions
This file was deleted.

src/EventManager.ts

Lines changed: 0 additions & 43 deletions
This file was deleted.

src/PausePoint.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export type PausePointType = 'time-relative' | 'time-absolute' | 'serial-bytes';
2+
3+
export interface ITimePausePoint {
4+
type: 'time-relative' | 'time-absolute';
5+
nanos: number;
6+
}
7+
8+
export interface ISerialBytesPausePoint {
9+
type: 'serial-bytes';
10+
bytes: number[];
11+
}
12+
13+
export type PausePointParams = ISerialBytesPausePoint | ITimePausePoint;
14+
15+
export class PausePoint<T = any> {
16+
private _resolve!: (info: T) => void;
17+
readonly promise: Promise<T>;
18+
19+
constructor(
20+
readonly id: string,
21+
readonly params: PausePointParams,
22+
) {
23+
this.promise = new Promise<T>((resolve) => {
24+
this._resolve = resolve;
25+
});
26+
}
27+
28+
resolve(info: T) {
29+
this._resolve(info);
30+
}
31+
}

src/SimulationTimeoutError.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class SimulationTimeoutError extends Error {
2+
constructor(
3+
public readonly exitCode: number,
4+
message: string,
5+
) {
6+
super(message);
7+
this.name = 'SimulationTimeoutError';
8+
}
9+
}

src/TestScenario.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import chalkTemplate from 'chalk-template';
22
import type { APIClient } from './APIClient.js';
3-
import type { EventManager } from './EventManager.js';
43

54
export interface IScenarioCommand {
65
/** Validates the input to the command. Throws an exception of the input is not valid */
@@ -28,12 +27,12 @@ export class TestScenario {
2827
private stepIndex = 0;
2928
private client?: APIClient;
3029

30+
private serialNewLine = false;
31+
private skipSerialNewline = false;
32+
3133
readonly handlers: Record<string, IScenarioCommand> = {};
3234

33-
constructor(
34-
readonly scenario: IScenarioDefinition,
35-
readonly eventManager: EventManager,
36-
) {}
35+
constructor(readonly scenario: IScenarioDefinition) {}
3736

3837
registerCommands(commands: Record<string, IScenarioCommand>) {
3938
Object.assign(this.handlers, commands);
@@ -77,9 +76,6 @@ export class TestScenario {
7776
this.stepIndex = 0;
7877
this.client = client;
7978
for (const step of this.scenario.steps) {
80-
if (client.running) {
81-
void client.simPause();
82-
}
8379
if (step.name) {
8480
this.log(chalkTemplate`{gray Executing step:} {yellow ${step.name}}`);
8581
}
@@ -102,17 +98,34 @@ export class TestScenario {
10298
process.exit(1);
10399
}
104100

105-
log(message: string) {
106-
console.log(chalkTemplate`{cyan [${this.scenario.name}]}`, message);
101+
/**
102+
* The purpose of the method is to ensure that scenario logs don't appear in the middle of a line
103+
* printed by the simulated project.
104+
* @param bytes - incoming serial bytes
105+
* @returns processed bytes (with leading newline removed if needed)
106+
*/
107+
processSerialBytes(bytes: number[]) {
108+
if (((bytes[0] === 13 && bytes[1] === 10) || bytes[0] === 10) && this.skipSerialNewline) {
109+
bytes = bytes.slice(bytes[0] === 13 ? 2 : 1);
110+
}
111+
this.skipSerialNewline = false;
112+
if (bytes.length > 0) {
113+
this.serialNewLine = bytes[bytes.length - 1] === 10;
114+
}
115+
return bytes;
107116
}
108117

109-
fail(message: string) {
110-
throw new Error(`[${this.client?.lastNanos}ns] ${message}`);
118+
log(message: string) {
119+
let prefix = '';
120+
if (!this.serialNewLine) {
121+
prefix = '\n';
122+
this.skipSerialNewline = true;
123+
this.serialNewLine = true;
124+
}
125+
console.log(prefix + chalkTemplate`{cyan [${this.scenario.name}]}`, message);
111126
}
112127

113-
async resume() {
114-
await this.client?.simResume(
115-
this.eventManager.timeToNextEvent >= 0 ? this.eventManager.timeToNextEvent : undefined,
116-
);
128+
fail(message: string) {
129+
throw new Error(`[${this.client?.lastNanos.toFixed(0)}ns] ${message}`);
117130
}
118131
}

0 commit comments

Comments
 (0)