Skip to content

Commit 8acb774

Browse files
committed
feat: initial support for test scenarios
1 parent b50389b commit 8acb774

File tree

7 files changed

+292
-9
lines changed

7 files changed

+292
-9
lines changed

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"dependencies": {
2323
"@iarna/toml": "2.2.5",
2424
"arg": "^5.0.2",
25-
"ws": "^8.13.0"
25+
"ws": "^8.13.0",
26+
"yaml": "^2.3.1"
2627
},
2728
"devDependencies": {
2829
"@types/ws": "^8.5.4",

src/APIClient.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import type {
99
APISimStartParams,
1010
} from './APITypes';
1111

12-
const DEFAULT_SERVER = 'wss://wokwi.com/api/ws/beta';
12+
const DEFAULT_SERVER = process.env.WOKWI_CLI_SERVER ?? 'wss://wokwi.com/api/ws/beta';
1313

1414
export class APIClient {
1515
private readonly socket: WebSocket;
1616
private lastId = 0;
17+
private _running = false;
18+
private _lastNanos = 0;
1719
private readonly pendingCommands = new Map<
1820
string,
1921
[(result: any) => void, (error: Error) => void]
@@ -52,6 +54,7 @@ export class APIClient {
5254
}
5355

5456
async simStart(params: APISimStartParams) {
57+
this._running = false;
5558
return await this.sendCommand('sim:start', params);
5659
}
5760

@@ -60,6 +63,7 @@ export class APIClient {
6063
}
6164

6265
async simResume(pauseAfter?: number) {
66+
this._running = true;
6367
return await this.sendCommand('sim:resume', { pauseAfter });
6468
}
6569

@@ -83,6 +87,10 @@ export class APIClient {
8387
return await this.sendCommand<{ png: string }>('framebuffer:read', { id: partId });
8488
}
8589

90+
async controlSet(partId: string, control: string, value: number) {
91+
return await this.sendCommand('control:set', { part: partId, control, value });
92+
}
93+
8694
async sendCommand<T = unknown>(command: string, params?: any) {
8795
return await new Promise<T>((resolve, reject) => {
8896
const id = this.lastId++;
@@ -92,6 +100,14 @@ export class APIClient {
92100
});
93101
}
94102

103+
get running() {
104+
return this._running;
105+
}
106+
107+
get lastNanos() {
108+
return this._lastNanos;
109+
}
110+
95111
processMessage(message: APIError | APIHello | APIEvent | APIResponse) {
96112
switch (message.type) {
97113
case 'error':
@@ -116,6 +132,10 @@ export class APIClient {
116132
}
117133

118134
processEvent(message: APIEvent) {
135+
if (message.event === 'sim:pause') {
136+
this._running = false;
137+
}
138+
this._lastNanos = message.nanos;
119139
this.onEvent?.(message);
120140
}
121141

src/TestScenario.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import chalk from 'chalk';
2+
import type { APIClient } from './APIClient';
3+
import type { EventManager } from './EventManager';
4+
import type { ExpectEngine } from './ExpectEngine';
5+
import { parseTime } from './utils/parseTime';
6+
7+
const validStepKeys = ['name'];
8+
9+
export interface ISetControlParams {
10+
'part-id': string;
11+
control: string;
12+
value: number;
13+
}
14+
15+
export interface IStepDefinition {
16+
name?: string;
17+
'wait-serial': string;
18+
delay: string;
19+
'set-control': ISetControlParams;
20+
}
21+
22+
export interface IScenarioDefinition {
23+
name: string;
24+
version: number;
25+
author?: string;
26+
27+
steps: IStepDefinition[];
28+
}
29+
30+
export class TestScenario {
31+
private stepIndex = 0;
32+
private client?: APIClient;
33+
34+
constructor(
35+
readonly scenario: IScenarioDefinition,
36+
readonly eventManager: EventManager,
37+
readonly expectEngine: ExpectEngine
38+
) {}
39+
40+
validate() {
41+
const { scenario } = this;
42+
if (scenario.name == null) {
43+
throw new Error(`Scenario name is missing`);
44+
}
45+
46+
if (typeof scenario.name !== 'string') {
47+
throw new Error(`Scenario name must be a string`);
48+
}
49+
50+
if (scenario.version !== 1) {
51+
throw new Error(`Unsupported scenario version: ${scenario.version}`);
52+
}
53+
54+
if (!Array.isArray(scenario.steps)) {
55+
throw new Error(`Scenario steps must be an array`);
56+
}
57+
58+
for (const step of scenario.steps) {
59+
if (typeof step !== 'object') {
60+
throw new Error(`Scenario step must be an object`);
61+
}
62+
63+
for (const key of Object.keys(step)) {
64+
if (!validStepKeys.includes(key) && !Object.keys(this.handlers).includes(key)) {
65+
throw new Error(`Invalid scenario step key: ${key}`);
66+
}
67+
}
68+
}
69+
}
70+
71+
async start(client: APIClient) {
72+
this.stepIndex = 0;
73+
this.client = client;
74+
await this.nextStep();
75+
}
76+
77+
async nextStep() {
78+
if (this.client?.running) {
79+
void this.client.simPause();
80+
}
81+
82+
const step = this.scenario.steps[this.stepIndex];
83+
if (step == null) {
84+
this.log(chalk`{green Scenario completed successfully}`);
85+
process.exit(0);
86+
}
87+
if (step.name) {
88+
this.log(chalk`{gray Executing step:} {yellow ${step.name}`);
89+
}
90+
for (const key of Object.keys(this.handlers) as Array<keyof typeof this.handlers>) {
91+
if (key in step) {
92+
const value = step[key];
93+
console.log('running handler for ' + key);
94+
void this.handlers[key](value as any, step);
95+
this.stepIndex++;
96+
return;
97+
}
98+
}
99+
console.error('Unknown key in step: ', step);
100+
process.exit(1);
101+
}
102+
103+
log(message: string) {
104+
console.log(chalk`{cyan [${this.scenario.name}]}`, message);
105+
}
106+
107+
async resume() {
108+
await this.client?.simResume(
109+
this.eventManager.timeToNextEvent >= 0 ? this.eventManager.timeToNextEvent : undefined
110+
);
111+
}
112+
113+
handlers = {
114+
'wait-serial': async (text: string) => {
115+
this.expectEngine.expectTexts.push(text);
116+
this.expectEngine.once('match', () => {
117+
this.log(chalk`Expected text matched: {green "${text}"}`);
118+
const textIndex = this.expectEngine.expectTexts.indexOf(text);
119+
if (textIndex >= 0) {
120+
this.expectEngine.expectTexts.splice(textIndex, 1);
121+
}
122+
void this.nextStep();
123+
});
124+
await this.resume();
125+
},
126+
delay: async (value: string, step: IStepDefinition) => {
127+
const nanos = parseTime(value);
128+
const targetNanos = (this.client?.lastNanos ?? 0) + nanos;
129+
this.log(chalk`delay {yellow "${value}"}`);
130+
this.eventManager.at(targetNanos, () => {
131+
void this.nextStep();
132+
});
133+
await this.resume();
134+
},
135+
'set-control': async (params: ISetControlParams) => {
136+
await this.client?.controlSet(params['part-id'], params.control, params.value);
137+
await this.nextStep();
138+
},
139+
};
140+
}

src/main.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import arg from 'arg';
22
import chalk from 'chalk';
33
import { existsSync, readFileSync, writeFileSync } from 'fs';
44
import path, { join } from 'path';
5+
import YAML from 'yaml';
56
import { APIClient } from './APIClient';
67
import type { APIEvent, ChipsLogPayload, SerialMonitorDataPayload } from './APITypes';
78
import { EventManager } from './EventManager';
89
import { ExpectEngine } from './ExpectEngine';
10+
import { TestScenario } from './TestScenario';
911
import { parseConfig } from './config';
1012
import { cliHelp } from './help';
11-
import { readVersion } from './readVersion';
1213
import { loadChips } from './loadChips';
14+
import { readVersion } from './readVersion';
1315

1416
const millis = 1_000_000;
1517

@@ -21,6 +23,7 @@ async function main() {
2123
'--version': Boolean,
2224
'--expect-text': String,
2325
'--fail-text': String,
26+
'--scenario': String,
2427
'--screenshot-part': String,
2528
'--screenshot-file': String,
2629
'--screenshot-time': Number,
@@ -35,6 +38,7 @@ async function main() {
3538
const quiet = args['--quiet'];
3639
const expectText = args['--expect-text'];
3740
const failText = args['--fail-text'];
41+
const scenarioFile = args['--scenario'];
3842
const timeout = args['--timeout'] ?? 0;
3943
const screenshotPart = args['--screenshot-part'];
4044
const screenshotTime = args['--screenshot-time'];
@@ -93,11 +97,31 @@ async function main() {
9397

9498
const chips = loadChips(config.chip ?? [], rootDir);
9599

100+
if (scenarioFile && !existsSync(scenarioFile)) {
101+
console.error(`Error: scenario file not found: ${path.resolve(scenarioFile)}`);
102+
process.exit(1);
103+
}
104+
105+
const eventManager = new EventManager();
96106
const expectEngine = new ExpectEngine();
97107

108+
let scenario;
109+
if (scenarioFile) {
110+
scenario = new TestScenario(
111+
YAML.parse(readFileSync(scenarioFile, 'utf-8')),
112+
eventManager,
113+
expectEngine
114+
);
115+
scenario.validate();
116+
}
117+
98118
if (expectText) {
99119
expectEngine.expectTexts.push(expectText);
100120
expectEngine.on('match', (text) => {
121+
if (text !== expectText) {
122+
return;
123+
}
124+
101125
if (!quiet) {
102126
console.log(chalk`\n\nExpected text found: {green "${expectText}"}`);
103127
console.log('TEST PASSED.');
@@ -109,14 +133,17 @@ async function main() {
109133
if (failText) {
110134
expectEngine.failTexts.push(failText);
111135
expectEngine.on('fail', (text) => {
136+
if (text !== failText) {
137+
return;
138+
}
139+
112140
console.error(chalk`\n\n{red Error:} Unexpected text found: {yellow "${text}"}`);
113141
console.error('TEST FAILED.');
114142
process.exit(1);
115143
});
116144
}
117145

118146
const client = new APIClient(token);
119-
const eventManager = new EventManager();
120147
client.onConnected = (hello) => {
121148
if (!quiet) {
122149
console.log(`Connected to Wokwi Simulation API ${hello.appVersion}`);
@@ -138,6 +165,8 @@ async function main() {
138165
console.log('Starting simulation...');
139166
}
140167

168+
await scenario?.start(client);
169+
141170
if (timeoutNanos) {
142171
eventManager.at(timeoutNanos, () => {
143172
// We are using setImmediate to make sure other events (e.g. screen shot) are processed first

0 commit comments

Comments
 (0)