@typecad/expect 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +345 -0
- package/dist/host/chain-collector.d.ts +48 -0
- package/dist/host/chain-collector.js +153 -0
- package/dist/host/cli.d.ts +2 -0
- package/dist/host/cli.js +185 -0
- package/dist/host/compiler.d.ts +27 -0
- package/dist/host/compiler.js +250 -0
- package/dist/host/config.d.ts +29 -0
- package/dist/host/config.js +218 -0
- package/dist/host/evaluator.d.ts +14 -0
- package/dist/host/evaluator.js +113 -0
- package/dist/host/finder.d.ts +12 -0
- package/dist/host/finder.js +70 -0
- package/dist/host/parser.d.ts +5 -0
- package/dist/host/parser.js +196 -0
- package/dist/host/preprocessor.d.ts +33 -0
- package/dist/host/preprocessor.js +115 -0
- package/dist/host/protocol-emitter.d.ts +29 -0
- package/dist/host/protocol-emitter.js +107 -0
- package/dist/host/reporter.d.ts +11 -0
- package/dist/host/reporter.js +200 -0
- package/dist/host/runner.d.ts +7 -0
- package/dist/host/runner.js +256 -0
- package/dist/host/serial.d.ts +28 -0
- package/dist/host/serial.js +129 -0
- package/dist/host/types.d.ts +121 -0
- package/dist/host/types.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/stubs.d.ts +17 -0
- package/dist/stubs.js +26 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +8 -0
- package/package.json +80 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Test runner pipeline
|
|
3
|
+
//
|
|
4
|
+
// Orchestrates the full test cycle:
|
|
5
|
+
// discover → preprocess → transpile → compile → upload → serial → parse → evaluate → report
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { findTestFiles } from './finder.js';
|
|
10
|
+
import { preprocess } from './preprocessor.js';
|
|
11
|
+
import { transpileTestFile, compileSketch, uploadSketch } from './compiler.js';
|
|
12
|
+
import { readSerialOutput } from './serial.js';
|
|
13
|
+
import { parseProtocolLines } from './parser.js';
|
|
14
|
+
import { reportFileResult, reportSummary } from './reporter.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// ANSI codes (for inline progress messages)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const DIM = '\x1b[2m';
|
|
19
|
+
const RESET = '\x1b[0m';
|
|
20
|
+
const CYAN = '\x1b[36m';
|
|
21
|
+
const YELLOW = '\x1b[33m';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Public API
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Run all test files matching the configuration.
|
|
27
|
+
*
|
|
28
|
+
* @returns Exit code: 0 = all passed, 1 = failures, 2 = error.
|
|
29
|
+
*/
|
|
30
|
+
export async function run(config) {
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
// 1. Discover test files
|
|
33
|
+
const testFiles = findTestFiles(config.projectRoot, config.test.include);
|
|
34
|
+
if (testFiles.length === 0) {
|
|
35
|
+
console.log(`${YELLOW}No test files found matching: ${config.test.include.join(', ')}${RESET}`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
console.log(`${DIM}Found ${testFiles.length} test file${testFiles.length !== 1 ? 's' : ''}${RESET}`);
|
|
39
|
+
console.log();
|
|
40
|
+
// 2. Process each file sequentially (one compile/upload cycle per file)
|
|
41
|
+
const fileResults = [];
|
|
42
|
+
for (const filePath of testFiles) {
|
|
43
|
+
const result = await processTestFile(filePath, config);
|
|
44
|
+
fileResults.push(result);
|
|
45
|
+
reportFileResult(result, { verbose: config.test.verbose });
|
|
46
|
+
}
|
|
47
|
+
// 3. Aggregate results
|
|
48
|
+
const runResult = aggregateResults(fileResults, Date.now() - startTime);
|
|
49
|
+
// 4. Final summary
|
|
50
|
+
reportSummary(runResult, {
|
|
51
|
+
board: config.board,
|
|
52
|
+
port: config.test.port,
|
|
53
|
+
});
|
|
54
|
+
return (runResult.totalFailed > 0 || runResult.totalErrors > 0) ? 1 : 0;
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Internal — Process a single test file through the full pipeline
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
async function processTestFile(filePath, config) {
|
|
60
|
+
const startTime = Date.now();
|
|
61
|
+
const relativePath = path.relative(config.projectRoot, filePath);
|
|
62
|
+
// Read source
|
|
63
|
+
let source;
|
|
64
|
+
try {
|
|
65
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return errorResult(filePath, `Failed to read file: ${e.message}`, startTime);
|
|
69
|
+
}
|
|
70
|
+
const skipReason = getSkipReason(source, relativePath, config);
|
|
71
|
+
if (skipReason) {
|
|
72
|
+
return skippedResult(relativePath, skipReason, startTime);
|
|
73
|
+
}
|
|
74
|
+
// Validate port only for files that will actually compile/upload. This lets
|
|
75
|
+
// target-incompatible files be skipped without requiring hardware to be
|
|
76
|
+
// connected.
|
|
77
|
+
if (!config.test.port) {
|
|
78
|
+
return errorResult(filePath, 'No serial port specified. Use --port <port> or set test.port in cuttlefish.config.ts', startTime);
|
|
79
|
+
}
|
|
80
|
+
console.log(`${CYAN}●${RESET} ${relativePath}`);
|
|
81
|
+
// Step 1: Preprocess
|
|
82
|
+
console.log(` ${DIM}preprocessing...${RESET}`);
|
|
83
|
+
let preprocessed;
|
|
84
|
+
try {
|
|
85
|
+
preprocessed = preprocess(source, path.basename(filePath), {
|
|
86
|
+
isAvr: config.target === 'avr' || config.target === 'megaavr',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return errorResult(filePath, `Preprocessing failed: ${e.message}`, startTime);
|
|
91
|
+
}
|
|
92
|
+
// Step 2: Transpile to C++
|
|
93
|
+
console.log(` ${DIM}transpiling...${RESET}`);
|
|
94
|
+
const transpileResult = transpileTestFile(preprocessed, filePath, config.projectRoot, config.buildTarget);
|
|
95
|
+
if (!transpileResult.success) {
|
|
96
|
+
return errorResult(filePath, transpileResult.error ?? 'Transpilation failed', startTime);
|
|
97
|
+
}
|
|
98
|
+
// Step 3: Compile with arduino-cli
|
|
99
|
+
console.log(` ${DIM}compiling...${RESET}`);
|
|
100
|
+
const compileResult = compileSketch(transpileResult.sketchDir, config.buildTarget);
|
|
101
|
+
if (!compileResult.success) {
|
|
102
|
+
return errorResult(filePath, compileResult.error ?? 'Compilation failed', startTime);
|
|
103
|
+
}
|
|
104
|
+
// Step 4: Upload
|
|
105
|
+
console.log(` ${DIM}uploading to ${config.test.port}...${RESET}`);
|
|
106
|
+
const uploadResult = uploadSketch(transpileResult.sketchDir, config.buildTarget, config.test.port);
|
|
107
|
+
if (!uploadResult.success) {
|
|
108
|
+
return errorResult(filePath, uploadResult.error ?? 'Upload failed', startTime);
|
|
109
|
+
}
|
|
110
|
+
// Step 5: Read serial output
|
|
111
|
+
console.log(` ${DIM}reading serial output...${RESET}`);
|
|
112
|
+
const serialResult = await readSerialOutput(config.test.port, config.test.baudRate, config.test.timeout, config.test.serialOpenDelay, { resetAfterOpen: config.test.resetAfterOpen ?? config.target === 'esp32' });
|
|
113
|
+
if (serialResult.error && !serialResult.completed) {
|
|
114
|
+
return errorResult(filePath, serialResult.error, startTime);
|
|
115
|
+
}
|
|
116
|
+
// Step 6: Parse protocol lines
|
|
117
|
+
const describes = parseProtocolLines(serialResult.protocolLines);
|
|
118
|
+
const durationMs = Date.now() - startTime;
|
|
119
|
+
const passed = describes.every(d => d.passed);
|
|
120
|
+
return {
|
|
121
|
+
filePath: relativePath,
|
|
122
|
+
describes,
|
|
123
|
+
passed,
|
|
124
|
+
durationMs,
|
|
125
|
+
debugOutput: serialResult.debugLines,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Internal — Helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
function errorResult(filePath, error, startTime) {
|
|
132
|
+
return {
|
|
133
|
+
filePath,
|
|
134
|
+
describes: [],
|
|
135
|
+
passed: false,
|
|
136
|
+
durationMs: Date.now() - startTime,
|
|
137
|
+
debugOutput: [],
|
|
138
|
+
error,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function skippedResult(filePath, reason, startTime) {
|
|
142
|
+
return {
|
|
143
|
+
filePath,
|
|
144
|
+
describes: [],
|
|
145
|
+
passed: true,
|
|
146
|
+
durationMs: Date.now() - startTime,
|
|
147
|
+
debugOutput: [],
|
|
148
|
+
skipped: true,
|
|
149
|
+
skipReason: reason,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function aggregateResults(files, durationMs) {
|
|
153
|
+
let totalTests = 0;
|
|
154
|
+
let totalPassed = 0;
|
|
155
|
+
let totalFailed = 0;
|
|
156
|
+
let totalErrors = 0;
|
|
157
|
+
let totalSkipped = 0;
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (file.skipped) {
|
|
160
|
+
totalSkipped++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const desc of file.describes) {
|
|
164
|
+
for (const test of desc.tests) {
|
|
165
|
+
totalTests++;
|
|
166
|
+
if (test.passed)
|
|
167
|
+
totalPassed++;
|
|
168
|
+
else
|
|
169
|
+
totalFailed++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (file.error) {
|
|
173
|
+
totalErrors++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
files,
|
|
178
|
+
totalTests,
|
|
179
|
+
totalPassed,
|
|
180
|
+
totalFailed,
|
|
181
|
+
totalErrors,
|
|
182
|
+
totalSkipped,
|
|
183
|
+
durationMs,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function getSkipReason(source, relativePath, config) {
|
|
187
|
+
const excludedByConfig = config.test.exclude?.find(pattern => matchesTestPattern(relativePath, pattern));
|
|
188
|
+
if (excludedByConfig) {
|
|
189
|
+
return `matched test.exclude pattern '${excludedByConfig}'`;
|
|
190
|
+
}
|
|
191
|
+
const skipTarget = findTargetDirective(source, 'typecad-skip-target');
|
|
192
|
+
if (skipTarget && targetListMatches(skipTarget.targets, config)) {
|
|
193
|
+
return skipTarget.reason ?? `skipped for target '${targetLabel(config)}'`;
|
|
194
|
+
}
|
|
195
|
+
const onlyTarget = findTargetDirective(source, 'typecad-only-target');
|
|
196
|
+
if (onlyTarget && !targetListMatches(onlyTarget.targets, config)) {
|
|
197
|
+
return onlyTarget.reason ?? `requires target '${onlyTarget.targets.join(', ')}'`;
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
function findTargetDirective(source, directive) {
|
|
202
|
+
const re = new RegExp(`@${directive}\\s+([^:\\r\\n]+)(?::\\s*(.*))?`, 'i');
|
|
203
|
+
const match = source.match(re);
|
|
204
|
+
if (!match)
|
|
205
|
+
return undefined;
|
|
206
|
+
const targets = match[1]
|
|
207
|
+
.split(/[,\s]+/)
|
|
208
|
+
.map(s => s.trim().toLowerCase())
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
if (targets.length === 0)
|
|
211
|
+
return undefined;
|
|
212
|
+
return { targets, reason: match[2]?.trim() || undefined };
|
|
213
|
+
}
|
|
214
|
+
function targetListMatches(targets, config) {
|
|
215
|
+
const tokens = new Set();
|
|
216
|
+
if (config.target)
|
|
217
|
+
tokens.add(config.target.toLowerCase());
|
|
218
|
+
if (config.buildTarget) {
|
|
219
|
+
const buildTarget = config.buildTarget.toLowerCase();
|
|
220
|
+
tokens.add(buildTarget);
|
|
221
|
+
const parts = buildTarget.split(':');
|
|
222
|
+
if (parts[0])
|
|
223
|
+
tokens.add(parts[0]);
|
|
224
|
+
if (parts[1])
|
|
225
|
+
tokens.add(parts[1]);
|
|
226
|
+
if (parts[2])
|
|
227
|
+
tokens.add(parts[2]);
|
|
228
|
+
}
|
|
229
|
+
if (config.board) {
|
|
230
|
+
const board = config.board.toLowerCase();
|
|
231
|
+
tokens.add(board);
|
|
232
|
+
const lastSegment = board.split('/').pop();
|
|
233
|
+
if (lastSegment)
|
|
234
|
+
tokens.add(lastSegment);
|
|
235
|
+
}
|
|
236
|
+
return targets.some(target => target === '*' || tokens.has(target.toLowerCase()));
|
|
237
|
+
}
|
|
238
|
+
function targetLabel(config) {
|
|
239
|
+
return config.buildTarget || config.target || config.board || 'unknown';
|
|
240
|
+
}
|
|
241
|
+
function matchesTestPattern(relativePath, pattern) {
|
|
242
|
+
const normalizedPath = relativePath.replace(/\\/g, '/');
|
|
243
|
+
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
244
|
+
if (!normalizedPattern.includes('*') && !normalizedPattern.includes('?')) {
|
|
245
|
+
return normalizedPath === normalizedPattern;
|
|
246
|
+
}
|
|
247
|
+
return globToRegex(normalizedPattern).test(normalizedPath);
|
|
248
|
+
}
|
|
249
|
+
function globToRegex(glob) {
|
|
250
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
251
|
+
const pattern = escaped
|
|
252
|
+
.replace(/\?/g, '[^/]')
|
|
253
|
+
.replace(/\*\*\//g, '(.+/)?')
|
|
254
|
+
.replace(/\*/g, '[^/]*');
|
|
255
|
+
return new RegExp(`^${pattern}$`);
|
|
256
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SerialReadResult {
|
|
2
|
+
/** Lines starting with [TC: — the protocol data. */
|
|
3
|
+
protocolLines: string[];
|
|
4
|
+
/** All other serial output — debug prints, boot messages, etc. */
|
|
5
|
+
debugLines: string[];
|
|
6
|
+
/** Whether SUITE_END was received before timeout. */
|
|
7
|
+
completed: boolean;
|
|
8
|
+
/** If the read was cut short by an error. */
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SerialReadOptions {
|
|
12
|
+
/** Toggle ESP32-style DTR/RTS reset after the port is open and listeners are attached. */
|
|
13
|
+
resetAfterOpen?: boolean;
|
|
14
|
+
/** How long to hold reset active when resetAfterOpen is enabled. */
|
|
15
|
+
resetPulseMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read serial output from the device and separate protocol lines from debug.
|
|
19
|
+
*
|
|
20
|
+
* Uses the `serialport` npm package. The port is opened, data is accumulated
|
|
21
|
+
* line-by-line. Reading stops when a `[TC:SUITE_END]` line is received or
|
|
22
|
+
* the timeout expires.
|
|
23
|
+
*
|
|
24
|
+
* @param port Serial port path (e.g. `'COM3'`, `'/dev/ttyACM0'`).
|
|
25
|
+
* @param baudRate Baud rate — must match what the preprocessor emits.
|
|
26
|
+
* @param timeoutMs How long to wait for SUITE_END.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readSerialOutput(port: string, baudRate: number, timeoutMs: number, serialOpenDelay?: number, options?: SerialReadOptions): Promise<SerialReadResult>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Serial reader
|
|
3
|
+
//
|
|
4
|
+
// Opens a serial port after firmware upload, reads lines until [TC:SUITE_END]
|
|
5
|
+
// or timeout. Filters protocol lines from debug output.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import { PROTOCOL_PREFIX } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Read serial output from the device and separate protocol lines from debug.
|
|
10
|
+
*
|
|
11
|
+
* Uses the `serialport` npm package. The port is opened, data is accumulated
|
|
12
|
+
* line-by-line. Reading stops when a `[TC:SUITE_END]` line is received or
|
|
13
|
+
* the timeout expires.
|
|
14
|
+
*
|
|
15
|
+
* @param port Serial port path (e.g. `'COM3'`, `'/dev/ttyACM0'`).
|
|
16
|
+
* @param baudRate Baud rate — must match what the preprocessor emits.
|
|
17
|
+
* @param timeoutMs How long to wait for SUITE_END.
|
|
18
|
+
*/
|
|
19
|
+
export async function readSerialOutput(port, baudRate, timeoutMs, serialOpenDelay = 500, options = {}) {
|
|
20
|
+
// Dynamic import — serialport is a native dependency
|
|
21
|
+
const { SerialPort } = await import('serialport');
|
|
22
|
+
const protocolLines = [];
|
|
23
|
+
const debugLines = [];
|
|
24
|
+
let completed = false;
|
|
25
|
+
let pending = '';
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let resolved = false;
|
|
28
|
+
const cleanup = (callback) => {
|
|
29
|
+
sp.removeAllListeners();
|
|
30
|
+
if (sp.isOpen) {
|
|
31
|
+
sp.close(() => callback());
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
try {
|
|
35
|
+
sp.close();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// ignore close errors for unopened ports
|
|
39
|
+
}
|
|
40
|
+
callback();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const finish = (error) => {
|
|
44
|
+
if (resolved)
|
|
45
|
+
return;
|
|
46
|
+
resolved = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
cleanup(() => resolve({ protocolLines, debugLines, completed, error }));
|
|
49
|
+
};
|
|
50
|
+
const handleLine = (line) => {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (trimmed.startsWith(PROTOCOL_PREFIX)) {
|
|
53
|
+
protocolLines.push(trimmed);
|
|
54
|
+
if (trimmed.includes('SUITE_END')) {
|
|
55
|
+
completed = true;
|
|
56
|
+
// Give a brief delay for any trailing output
|
|
57
|
+
setTimeout(() => finish(), 200);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (trimmed.length > 0) {
|
|
61
|
+
debugLines.push(trimmed);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const handleChunk = (chunk) => {
|
|
65
|
+
pending += chunk.toString();
|
|
66
|
+
while (true) {
|
|
67
|
+
const lf = pending.indexOf('\n');
|
|
68
|
+
const cr = pending.indexOf('\r');
|
|
69
|
+
const indexes = [lf, cr].filter(i => i >= 0);
|
|
70
|
+
if (indexes.length === 0)
|
|
71
|
+
break;
|
|
72
|
+
const next = Math.min(...indexes);
|
|
73
|
+
const delimiter = pending[next];
|
|
74
|
+
const line = pending.slice(0, next);
|
|
75
|
+
pending = pending.slice(next + 1);
|
|
76
|
+
// Treat CRLF as one newline.
|
|
77
|
+
if (delimiter === '\r' && pending.startsWith('\n')) {
|
|
78
|
+
pending = pending.slice(1);
|
|
79
|
+
}
|
|
80
|
+
handleLine(line);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const resetAfterOpen = options.resetAfterOpen ?? false;
|
|
84
|
+
const resetPulseMs = options.resetPulseMs ?? 100;
|
|
85
|
+
const resetBoard = () => {
|
|
86
|
+
if (!resetAfterOpen)
|
|
87
|
+
return;
|
|
88
|
+
// ESP32 DevKit boards use RTS for EN/reset and DTR for GPIO0/boot.
|
|
89
|
+
// Keep GPIO0 high (DTR false), pulse EN low (RTS true), then release.
|
|
90
|
+
sp.set({ dtr: false, rts: true }, (assertErr) => {
|
|
91
|
+
if (assertErr) {
|
|
92
|
+
finish(`Failed to assert serial reset on ${port}: ${assertErr.message}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
sp.set({ dtr: false, rts: false }, (releaseErr) => {
|
|
97
|
+
if (releaseErr) {
|
|
98
|
+
finish(`Failed to release serial reset on ${port}: ${releaseErr.message}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}, resetPulseMs);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
// Open serial port
|
|
105
|
+
const sp = new SerialPort({ path: port, baudRate, autoOpen: false });
|
|
106
|
+
// Timeout guard
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
if (pending.trim().length > 0) {
|
|
109
|
+
handleLine(pending);
|
|
110
|
+
pending = '';
|
|
111
|
+
}
|
|
112
|
+
finish(completed ? undefined : `Timeout after ${timeoutMs}ms — no [TC:SUITE_END] received`);
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
sp.on('data', handleChunk);
|
|
115
|
+
sp.on('error', (err) => {
|
|
116
|
+
finish(`Serial error: ${err.message}`);
|
|
117
|
+
});
|
|
118
|
+
// Give the device time to reset after upload (Arduino resets on serial open)
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
sp.open((err) => {
|
|
121
|
+
if (err) {
|
|
122
|
+
finish(`Failed to open ${port}: ${err.message}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
resetBoard();
|
|
126
|
+
});
|
|
127
|
+
}, serialOpenDelay);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** The prefix that every protocol line starts with. */
|
|
2
|
+
export declare const PROTOCOL_PREFIX = "[TC:";
|
|
3
|
+
export declare const PROTOCOL_SUFFIX = "]";
|
|
4
|
+
/**
|
|
5
|
+
* All protocol line types emitted by firmware.
|
|
6
|
+
*/
|
|
7
|
+
export type ProtocolTag = 'SUITE_START' | 'DESCRIBE' | 'IT' | 'EXPECT' | 'SUITE_END';
|
|
8
|
+
/**
|
|
9
|
+
* A single parsed protocol line.
|
|
10
|
+
*/
|
|
11
|
+
export interface ProtocolLine {
|
|
12
|
+
tag: ProtocolTag;
|
|
13
|
+
/** Payload segments (split on `:` after the tag). */
|
|
14
|
+
fields: string[];
|
|
15
|
+
/** The original raw line from serial. */
|
|
16
|
+
raw: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* All supported matcher names.
|
|
20
|
+
*/
|
|
21
|
+
export type MatcherName = 'toBe' | 'toBeGreaterThan' | 'toBeGreaterThanOrEqual' | 'toBeLessThan' | 'toBeLessThanOrEqual' | 'toBeCloseTo' | 'toBeWithinRange' | 'toBeTruthy' | 'toBeFalsy' | 'toNotBe' | 'toContain' | 'toHaveLength';
|
|
22
|
+
/**
|
|
23
|
+
* A single assertion result after host-side evaluation.
|
|
24
|
+
*/
|
|
25
|
+
export interface AssertionResult {
|
|
26
|
+
matcher: MatcherName;
|
|
27
|
+
/** Expected value(s) as raw string from serial. */
|
|
28
|
+
expected: string;
|
|
29
|
+
/** Actual value as raw string from serial. */
|
|
30
|
+
actual: string;
|
|
31
|
+
/** Whether the assertion passed. */
|
|
32
|
+
passed: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A single `it(...)` test case result.
|
|
36
|
+
*/
|
|
37
|
+
export interface TestResult {
|
|
38
|
+
name: string;
|
|
39
|
+
assertions: AssertionResult[];
|
|
40
|
+
/** Whether all assertions passed. */
|
|
41
|
+
passed: boolean;
|
|
42
|
+
/** Time between IT start and next IT / describe end (ms). */
|
|
43
|
+
durationMs: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A single `describe(...)` group result.
|
|
47
|
+
*/
|
|
48
|
+
export interface DescribeResult {
|
|
49
|
+
name: string;
|
|
50
|
+
tests: TestResult[];
|
|
51
|
+
/** Whether all tests passed. */
|
|
52
|
+
passed: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Result of running one test file.
|
|
56
|
+
*/
|
|
57
|
+
export interface FileResult {
|
|
58
|
+
filePath: string;
|
|
59
|
+
describes: DescribeResult[];
|
|
60
|
+
passed: boolean;
|
|
61
|
+
durationMs: number;
|
|
62
|
+
/** Non-test serial output (lines without [TC: prefix). */
|
|
63
|
+
debugOutput: string[];
|
|
64
|
+
/** True when the file was intentionally skipped before compile/upload. */
|
|
65
|
+
skipped?: boolean;
|
|
66
|
+
/** Human-readable skip reason. */
|
|
67
|
+
skipReason?: string;
|
|
68
|
+
/** If the file didn't compile or upload. */
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Result of the entire test run.
|
|
73
|
+
*/
|
|
74
|
+
export interface RunResult {
|
|
75
|
+
files: FileResult[];
|
|
76
|
+
totalTests: number;
|
|
77
|
+
totalPassed: number;
|
|
78
|
+
totalFailed: number;
|
|
79
|
+
/** Pipeline errors (compile/upload failures) — tracked separately from test failures. */
|
|
80
|
+
totalErrors: number;
|
|
81
|
+
/** Files intentionally skipped before compile/upload. */
|
|
82
|
+
totalSkipped: number;
|
|
83
|
+
durationMs: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Test-specific configuration — the `test` section of `cuttlefish.config.ts`.
|
|
87
|
+
*/
|
|
88
|
+
export interface TestConfig {
|
|
89
|
+
/** Glob patterns for test files. Default: `['tests/**\/*.test.ts']`. */
|
|
90
|
+
include: string[];
|
|
91
|
+
/** Glob patterns for test files to skip after discovery. */
|
|
92
|
+
exclude?: string[];
|
|
93
|
+
/** Serial port (e.g. `'COM3'`, `'/dev/ttyACM0'`). */
|
|
94
|
+
port: string;
|
|
95
|
+
/** Serial baud rate. Default: `115200`. */
|
|
96
|
+
baudRate: number;
|
|
97
|
+
/** Timeout in ms waiting for SUITE_END. Default: `30000`. */
|
|
98
|
+
timeout: number;
|
|
99
|
+
/** Delay in ms before opening serial port (after board reset). Default: `500`. */
|
|
100
|
+
serialOpenDelay?: number;
|
|
101
|
+
/** Toggle ESP32-style DTR/RTS reset after opening the serial port. */
|
|
102
|
+
resetAfterOpen?: boolean;
|
|
103
|
+
/** Framework-specific build target override. */
|
|
104
|
+
buildTarget?: string;
|
|
105
|
+
/** Board package override. */
|
|
106
|
+
board?: string;
|
|
107
|
+
/** Show debug serial output and passing assertion details. */
|
|
108
|
+
verbose?: boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolved configuration used by the runner.
|
|
112
|
+
*/
|
|
113
|
+
export interface ResolvedConfig {
|
|
114
|
+
test: TestConfig;
|
|
115
|
+
/** From cuttlefish.config.ts root. */
|
|
116
|
+
buildTarget: string;
|
|
117
|
+
board: string;
|
|
118
|
+
target: string;
|
|
119
|
+
/** Absolute path to project root. */
|
|
120
|
+
projectRoot: string;
|
|
121
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Host-side types
|
|
3
|
+
//
|
|
4
|
+
// Internal type definitions used by the host runner (Node.js process) to
|
|
5
|
+
// represent parsed serial output, assertion results, and test reports.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Serial protocol
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/** The prefix that every protocol line starts with. */
|
|
11
|
+
export const PROTOCOL_PREFIX = '[TC:';
|
|
12
|
+
export const PROTOCOL_SUFFIX = ']';
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Barrel re-export
|
|
3
|
+
//
|
|
4
|
+
// Users import from this package:
|
|
5
|
+
// import { describe, done } from '@typecad/expect';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export { describe, done } from './stubs.js';
|
package/dist/stubs.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Suite } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Open a describe group. Returns a `Suite` to chain `.it()` / `.expect()`.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* describe("A0 analog read")
|
|
8
|
+
* .it("reads zero").expect(A0.readAnalog()).toBe(0);
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare function describe(_name: string): Suite;
|
|
12
|
+
/**
|
|
13
|
+
* Mark the end of test execution. Must be the last statement in every
|
|
14
|
+
* test file. Emits the `[TC:SUITE_END]` sentinel and enters an idle loop
|
|
15
|
+
* so the host runner knows the firmware is done.
|
|
16
|
+
*/
|
|
17
|
+
export declare function done(): void;
|
package/dist/stubs.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Firmware stubs
|
|
3
|
+
//
|
|
4
|
+
// These provide types to the user's IDE. The test preprocessor rewrites every
|
|
5
|
+
// call into Serial protocol statements before the cuttlefish transpiler ever
|
|
6
|
+
// sees them, so the runtime bodies are no-ops (only reached if the preprocessor
|
|
7
|
+
// is skipped, e.g. type-checking in the IDE).
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Open a describe group. Returns a `Suite` to chain `.it()` / `.expect()`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* describe("A0 analog read")
|
|
15
|
+
* .it("reads zero").expect(A0.readAnalog()).toBe(0);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function describe(_name) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Mark the end of test execution. Must be the last statement in every
|
|
23
|
+
* test file. Emits the `[TC:SUITE_END]` sentinel and enters an idle loop
|
|
24
|
+
* so the host runner knows the firmware is done.
|
|
25
|
+
*/
|
|
26
|
+
export function done() { }
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric assertion — provides matchers that compare a numeric `actual` value
|
|
3
|
+
* against an `expected` value. Each matcher returns the parent `Suite` so
|
|
4
|
+
* the chain can continue with `.it()` or another `.expect()`.
|
|
5
|
+
*/
|
|
6
|
+
export interface Expectation {
|
|
7
|
+
/** Exact equality: `actual === expected`. */
|
|
8
|
+
toBe(expected: number): Suite;
|
|
9
|
+
/** Strict greater-than: `actual > expected`. */
|
|
10
|
+
toBeGreaterThan(n: number): Suite;
|
|
11
|
+
/** Greater-than or equal: `actual >= expected`. */
|
|
12
|
+
toBeGreaterThanOrEqual(n: number): Suite;
|
|
13
|
+
/** Strict less-than: `actual < expected`. */
|
|
14
|
+
toBeLessThan(n: number): Suite;
|
|
15
|
+
/** Less-than or equal: `actual <= expected`. */
|
|
16
|
+
toBeLessThanOrEqual(n: number): Suite;
|
|
17
|
+
/**
|
|
18
|
+
* Approximate equality: `|actual − expected| < 10^(−precision)`.
|
|
19
|
+
* @param n The expected value.
|
|
20
|
+
* @param precision Number of decimal digits (default 2 → 0.01 tolerance).
|
|
21
|
+
*/
|
|
22
|
+
toBeCloseTo(n: number, precision?: number): Suite;
|
|
23
|
+
/** Range check: `actual >= min && actual <= max`. */
|
|
24
|
+
toBeWithinRange(min: number, max: number): Suite;
|
|
25
|
+
/** Truthy: `actual !== 0`. */
|
|
26
|
+
toBeTruthy(): Suite;
|
|
27
|
+
/** Falsy: `actual === 0`. */
|
|
28
|
+
toBeFalsy(): Suite;
|
|
29
|
+
/** Not-equal: `actual !== expected`. */
|
|
30
|
+
toNotBe(expected: number): Suite;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* String assertion — provides matchers that compare a string `actual` value.
|
|
34
|
+
*/
|
|
35
|
+
export interface StringExpectation {
|
|
36
|
+
/** Exact string equality. */
|
|
37
|
+
toBe(expected: string): Suite;
|
|
38
|
+
/** Substring check. */
|
|
39
|
+
toContain(substring: string): Suite;
|
|
40
|
+
/** Length check. */
|
|
41
|
+
toHaveLength(n: number): Suite;
|
|
42
|
+
/** Not-equal. */
|
|
43
|
+
toNotBe(expected: string): Suite;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A test suite — the return value of `describe()`. Supports fluent chaining
|
|
47
|
+
* of `.it()` and `.expect()` calls.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* describe("A0 reads")
|
|
52
|
+
* .it("reads zero when grounded")
|
|
53
|
+
* .expect(A0.readAnalog()).toBe(0)
|
|
54
|
+
* .it("reads less than 100")
|
|
55
|
+
* .expect(A0.readAnalog()).toBeLessThan(100);
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export interface Suite {
|
|
59
|
+
/** Start a new test case within the current describe group. */
|
|
60
|
+
it(name: string): Suite;
|
|
61
|
+
/** Assert a numeric value (or a function that returns one). */
|
|
62
|
+
expect(actual: number | (() => number)): Expectation;
|
|
63
|
+
/** Assert a string value (or a function that returns one). */
|
|
64
|
+
expectString(actual: string | (() => string)): StringExpectation;
|
|
65
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Firmware-side types
|
|
3
|
+
//
|
|
4
|
+
// These interfaces define the fluent API that users write in test files.
|
|
5
|
+
// They exist purely for TypeScript IntelliSense — the preprocessor rewrites
|
|
6
|
+
// all calls to Serial protocol statements before transpilation.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export {};
|