@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,218 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Configuration loader
|
|
3
|
+
//
|
|
4
|
+
// Reads the `cuttlefish.config.ts` file to extract both the base transpiler
|
|
5
|
+
// configuration and the optional `test` section for test-specific settings.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Defaults
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const DEFAULT_TEST_CONFIG = {
|
|
14
|
+
include: ['tests/**/*.test.ts'],
|
|
15
|
+
port: '', // Must be provided by user or CLI flag
|
|
16
|
+
baudRate: 115200,
|
|
17
|
+
timeout: 30000,
|
|
18
|
+
serialOpenDelay: 500,
|
|
19
|
+
};
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Public API
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/**
|
|
24
|
+
* Load configuration from `cuttlefish.config.ts` in the given project root.
|
|
25
|
+
*
|
|
26
|
+
* Parses the config file via the TypeScript AST (no dynamic import) to
|
|
27
|
+
* extract scalar properties — consistent with how the cuttlefish CLI does it.
|
|
28
|
+
*
|
|
29
|
+
* @param projectRoot Absolute path to the project root.
|
|
30
|
+
* @param overrides CLI flag overrides for test config.
|
|
31
|
+
*/
|
|
32
|
+
export function loadConfig(projectRoot, overrides = {}) {
|
|
33
|
+
const configPath = findConfigFile(projectRoot);
|
|
34
|
+
if (!configPath) {
|
|
35
|
+
throw new Error(`No cuttlefish.config.ts found in ${projectRoot}. ` +
|
|
36
|
+
`Create one or specify --port and --board on the command line.`);
|
|
37
|
+
}
|
|
38
|
+
const raw = parseConfigAST(configPath);
|
|
39
|
+
// Merge test config: defaults < config file < CLI overrides
|
|
40
|
+
const testFromFile = raw.test ?? {};
|
|
41
|
+
const test = {
|
|
42
|
+
include: overrides.include ?? testFromFile.include ?? DEFAULT_TEST_CONFIG.include,
|
|
43
|
+
exclude: overrides.exclude ?? testFromFile.exclude,
|
|
44
|
+
port: overrides.port ?? testFromFile.port ?? DEFAULT_TEST_CONFIG.port,
|
|
45
|
+
baudRate: overrides.baudRate ?? testFromFile.baudRate ?? DEFAULT_TEST_CONFIG.baudRate,
|
|
46
|
+
timeout: overrides.timeout ?? testFromFile.timeout ?? DEFAULT_TEST_CONFIG.timeout,
|
|
47
|
+
serialOpenDelay: overrides.serialOpenDelay ?? testFromFile.serialOpenDelay ?? DEFAULT_TEST_CONFIG.serialOpenDelay,
|
|
48
|
+
resetAfterOpen: overrides.resetAfterOpen ?? testFromFile.resetAfterOpen ?? DEFAULT_TEST_CONFIG.resetAfterOpen,
|
|
49
|
+
buildTarget: overrides.buildTarget ?? testFromFile.buildTarget,
|
|
50
|
+
board: overrides.board ?? testFromFile.board,
|
|
51
|
+
verbose: overrides.verbose ?? testFromFile.verbose,
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
test,
|
|
55
|
+
buildTarget: test.buildTarget ?? raw.frameworkData?.buildTarget ?? 'arduino:avr:uno',
|
|
56
|
+
board: test.board ?? raw.board ?? '@typecad/board-arduino-uno',
|
|
57
|
+
target: raw.target ?? 'avr',
|
|
58
|
+
projectRoot,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Internal — Config file discovery
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
function findConfigFile(projectRoot) {
|
|
65
|
+
const candidates = [
|
|
66
|
+
path.join(projectRoot, 'cuttlefish.config.ts'),
|
|
67
|
+
path.join(projectRoot, 'cuttlefish.config.js'),
|
|
68
|
+
];
|
|
69
|
+
return candidates.find(c => fs.existsSync(c));
|
|
70
|
+
}
|
|
71
|
+
export function parseConfigAST(configPath) {
|
|
72
|
+
const text = fs.readFileSync(configPath, 'utf8');
|
|
73
|
+
const sf = ts.createSourceFile(configPath, text, ts.ScriptTarget.Latest, true);
|
|
74
|
+
const result = {};
|
|
75
|
+
for (const stmt of sf.statements) {
|
|
76
|
+
// `const config: CuttlefishConfig = { ... };`
|
|
77
|
+
if (ts.isVariableStatement(stmt)) {
|
|
78
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
79
|
+
if (decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
|
|
80
|
+
extractConfigProperties(decl.initializer, result);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// `export default { ... };`
|
|
85
|
+
if (ts.isExportAssignment(stmt) && ts.isObjectLiteralExpression(stmt.expression)) {
|
|
86
|
+
extractConfigProperties(stmt.expression, result);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function extractConfigProperties(obj, out) {
|
|
92
|
+
for (const prop of obj.properties) {
|
|
93
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
94
|
+
continue;
|
|
95
|
+
const name = prop.name.text;
|
|
96
|
+
switch (name) {
|
|
97
|
+
case 'target':
|
|
98
|
+
if (ts.isStringLiteral(prop.initializer))
|
|
99
|
+
out.target = prop.initializer.text;
|
|
100
|
+
break;
|
|
101
|
+
case 'board':
|
|
102
|
+
if (ts.isStringLiteral(prop.initializer))
|
|
103
|
+
out.board = prop.initializer.text;
|
|
104
|
+
break;
|
|
105
|
+
case 'frameworkData':
|
|
106
|
+
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
107
|
+
out.frameworkData = {};
|
|
108
|
+
for (const fProp of prop.initializer.properties) {
|
|
109
|
+
if (ts.isPropertyAssignment(fProp) && ts.isIdentifier(fProp.name) && fProp.name.text === 'buildTarget') {
|
|
110
|
+
if (ts.isStringLiteral(fProp.initializer))
|
|
111
|
+
out.frameworkData.buildTarget = fProp.initializer.text;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
case 'framework':
|
|
117
|
+
if (ts.isStringLiteral(prop.initializer))
|
|
118
|
+
out.framework = prop.initializer.text;
|
|
119
|
+
break;
|
|
120
|
+
case 'test':
|
|
121
|
+
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
122
|
+
out.test = extractTestConfig(prop.initializer);
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
case 'output':
|
|
126
|
+
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
127
|
+
out.output = extractOutputConfig(prop.initializer);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
case 'console':
|
|
131
|
+
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
132
|
+
out.console = extractConsoleConfig(prop.initializer);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function extractTestConfig(obj) {
|
|
139
|
+
const result = {};
|
|
140
|
+
for (const prop of obj.properties) {
|
|
141
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
142
|
+
continue;
|
|
143
|
+
const name = prop.name.text;
|
|
144
|
+
switch (name) {
|
|
145
|
+
case 'port':
|
|
146
|
+
if (ts.isStringLiteral(prop.initializer))
|
|
147
|
+
result.port = prop.initializer.text;
|
|
148
|
+
break;
|
|
149
|
+
case 'baudRate':
|
|
150
|
+
if (ts.isNumericLiteral(prop.initializer))
|
|
151
|
+
result.baudRate = parseInt(prop.initializer.text, 10);
|
|
152
|
+
break;
|
|
153
|
+
case 'timeout':
|
|
154
|
+
if (ts.isNumericLiteral(prop.initializer))
|
|
155
|
+
result.timeout = parseInt(prop.initializer.text, 10);
|
|
156
|
+
break;
|
|
157
|
+
case 'serialOpenDelay':
|
|
158
|
+
if (ts.isNumericLiteral(prop.initializer))
|
|
159
|
+
result.serialOpenDelay = parseInt(prop.initializer.text, 10);
|
|
160
|
+
break;
|
|
161
|
+
case 'buildTarget':
|
|
162
|
+
if (ts.isStringLiteral(prop.initializer))
|
|
163
|
+
result.buildTarget = prop.initializer.text;
|
|
164
|
+
break;
|
|
165
|
+
case 'resetAfterOpen':
|
|
166
|
+
if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword)
|
|
167
|
+
result.resetAfterOpen = true;
|
|
168
|
+
if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword)
|
|
169
|
+
result.resetAfterOpen = false;
|
|
170
|
+
break;
|
|
171
|
+
case 'board':
|
|
172
|
+
if (ts.isStringLiteral(prop.initializer))
|
|
173
|
+
result.board = prop.initializer.text;
|
|
174
|
+
break;
|
|
175
|
+
case 'include':
|
|
176
|
+
if (ts.isArrayLiteralExpression(prop.initializer)) {
|
|
177
|
+
result.include = prop.initializer.elements
|
|
178
|
+
.filter(ts.isStringLiteral)
|
|
179
|
+
.map(el => el.text);
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
case 'exclude':
|
|
183
|
+
if (ts.isArrayLiteralExpression(prop.initializer)) {
|
|
184
|
+
result.exclude = prop.initializer.elements
|
|
185
|
+
.filter(ts.isStringLiteral)
|
|
186
|
+
.map(el => el.text);
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
function extractOutputConfig(obj) {
|
|
194
|
+
const result = {};
|
|
195
|
+
for (const prop of obj.properties) {
|
|
196
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
197
|
+
continue;
|
|
198
|
+
const name = prop.name.text;
|
|
199
|
+
if (name === 'framework' && ts.isStringLiteral(prop.initializer))
|
|
200
|
+
result.framework = prop.initializer.text;
|
|
201
|
+
if (name === 'optimize' && ts.isStringLiteral(prop.initializer))
|
|
202
|
+
result.optimize = prop.initializer.text;
|
|
203
|
+
if (name === 'outDir' && ts.isStringLiteral(prop.initializer))
|
|
204
|
+
result.outDir = prop.initializer.text;
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
function extractConsoleConfig(obj) {
|
|
209
|
+
const result = {};
|
|
210
|
+
for (const prop of obj.properties) {
|
|
211
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
212
|
+
continue;
|
|
213
|
+
if (prop.name.text === 'baudRate' && ts.isNumericLiteral(prop.initializer)) {
|
|
214
|
+
result.baudRate = parseInt(prop.initializer.text, 10);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MatcherName } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Evaluate a single assertion on the host side.
|
|
4
|
+
*
|
|
5
|
+
* @param matcher The matcher name (e.g. `'toBe'`, `'toBeLessThan'`).
|
|
6
|
+
* @param expected The expected value(s) as a raw string from the serial protocol.
|
|
7
|
+
* @param actual The actual value as a raw string from the serial protocol.
|
|
8
|
+
* @returns `true` if the assertion passes.
|
|
9
|
+
*/
|
|
10
|
+
export declare function evaluate(matcher: MatcherName, expected: string, actual: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Return a human-readable description of what the matcher checks.
|
|
13
|
+
*/
|
|
14
|
+
export declare function describeExpected(matcher: MatcherName, expected: string): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Assertion evaluator
|
|
3
|
+
//
|
|
4
|
+
// All assertion logic runs on the host (computer) side. The firmware only
|
|
5
|
+
// sends raw actual/expected values over serial — the host does the math.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Public API
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/**
|
|
11
|
+
* Evaluate a single assertion on the host side.
|
|
12
|
+
*
|
|
13
|
+
* @param matcher The matcher name (e.g. `'toBe'`, `'toBeLessThan'`).
|
|
14
|
+
* @param expected The expected value(s) as a raw string from the serial protocol.
|
|
15
|
+
* @param actual The actual value as a raw string from the serial protocol.
|
|
16
|
+
* @returns `true` if the assertion passes.
|
|
17
|
+
*/
|
|
18
|
+
export function evaluate(matcher, expected, actual) {
|
|
19
|
+
switch (matcher) {
|
|
20
|
+
case 'toBe':
|
|
21
|
+
return evaluateToBe(expected, actual);
|
|
22
|
+
case 'toNotBe':
|
|
23
|
+
return !evaluateToBe(expected, actual);
|
|
24
|
+
case 'toBeGreaterThan':
|
|
25
|
+
return toNum(actual) > toNum(expected);
|
|
26
|
+
case 'toBeGreaterThanOrEqual':
|
|
27
|
+
return toNum(actual) >= toNum(expected);
|
|
28
|
+
case 'toBeLessThan':
|
|
29
|
+
return toNum(actual) < toNum(expected);
|
|
30
|
+
case 'toBeLessThanOrEqual':
|
|
31
|
+
return toNum(actual) <= toNum(expected);
|
|
32
|
+
case 'toBeCloseTo': {
|
|
33
|
+
// expected format: "value,precision" e.g. "3.14,2"
|
|
34
|
+
const parts = expected.split(',');
|
|
35
|
+
const expVal = toNum(parts[0] ?? '0');
|
|
36
|
+
const precision = parts.length > 1 ? parseInt(parts[1], 10) : 2;
|
|
37
|
+
const tolerance = Math.pow(10, -precision);
|
|
38
|
+
return Math.abs(toNum(actual) - expVal) < tolerance;
|
|
39
|
+
}
|
|
40
|
+
case 'toBeWithinRange': {
|
|
41
|
+
// expected format: "min,max"
|
|
42
|
+
const parts = expected.split(',');
|
|
43
|
+
const min = toNum(parts[0] ?? '0');
|
|
44
|
+
const max = toNum(parts[1] ?? '0');
|
|
45
|
+
const val = toNum(actual);
|
|
46
|
+
return val >= min && val <= max;
|
|
47
|
+
}
|
|
48
|
+
case 'toBeTruthy':
|
|
49
|
+
return toNum(actual) !== 0;
|
|
50
|
+
case 'toBeFalsy':
|
|
51
|
+
return toNum(actual) === 0;
|
|
52
|
+
case 'toContain':
|
|
53
|
+
return actual.includes(expected);
|
|
54
|
+
case 'toHaveLength':
|
|
55
|
+
return actual.length === parseInt(expected, 10);
|
|
56
|
+
default: {
|
|
57
|
+
const _exhaustive = matcher;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Public — Human-readable descriptions
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
/**
|
|
66
|
+
* Return a human-readable description of what the matcher checks.
|
|
67
|
+
*/
|
|
68
|
+
export function describeExpected(matcher, expected) {
|
|
69
|
+
switch (matcher) {
|
|
70
|
+
case 'toBe': return `to be ${expected}`;
|
|
71
|
+
case 'toNotBe': return `to not be ${expected}`;
|
|
72
|
+
case 'toBeGreaterThan': return `to be > ${expected}`;
|
|
73
|
+
case 'toBeGreaterThanOrEqual': return `to be >= ${expected}`;
|
|
74
|
+
case 'toBeLessThan': return `to be < ${expected}`;
|
|
75
|
+
case 'toBeLessThanOrEqual': return `to be <= ${expected}`;
|
|
76
|
+
case 'toBeCloseTo': {
|
|
77
|
+
const parts = expected.split(',');
|
|
78
|
+
const precision = parts[1] ?? '2';
|
|
79
|
+
return `to be close to ${parts[0]} (±${Math.pow(10, -parseInt(precision, 10))})`;
|
|
80
|
+
}
|
|
81
|
+
case 'toBeWithinRange': {
|
|
82
|
+
const parts = expected.split(',');
|
|
83
|
+
return `to be within ${parts[0]}–${parts[1]}`;
|
|
84
|
+
}
|
|
85
|
+
case 'toBeTruthy': return 'to be truthy (≠ 0)';
|
|
86
|
+
case 'toBeFalsy': return 'to be falsy (= 0)';
|
|
87
|
+
case 'toContain': return `to contain "${expected}"`;
|
|
88
|
+
case 'toHaveLength': return `to have length ${expected}`;
|
|
89
|
+
default: {
|
|
90
|
+
const _exhaustive = matcher;
|
|
91
|
+
return `${matcher}(${expected})`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Internal
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
function evaluateToBe(expected, actual) {
|
|
99
|
+
// Try numeric comparison first
|
|
100
|
+
// Use Number() instead of parseFloat() so hex literals like "0x10" are
|
|
101
|
+
// correctly parsed (parseFloat stops at the "x" and returns 0).
|
|
102
|
+
const numExp = Number(expected);
|
|
103
|
+
const numAct = Number(actual);
|
|
104
|
+
if (!isNaN(numExp) && !isNaN(numAct)) {
|
|
105
|
+
return numAct === numExp;
|
|
106
|
+
}
|
|
107
|
+
// Fall back to string comparison
|
|
108
|
+
return actual === expected;
|
|
109
|
+
}
|
|
110
|
+
function toNum(s) {
|
|
111
|
+
const n = Number(s);
|
|
112
|
+
return isNaN(n) ? 0 : n;
|
|
113
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find all test files matching the include glob patterns.
|
|
3
|
+
*
|
|
4
|
+
* Supports patterns like `tests/**\/*.test.ts`, `src/**\/*.test.ts`, or exact
|
|
5
|
+
* file paths like `src/sketch.ts`. Only files ending in `.test.ts` are
|
|
6
|
+
* returned.
|
|
7
|
+
*
|
|
8
|
+
* @param projectRoot Absolute path to the project root.
|
|
9
|
+
* @param include Glob patterns to match.
|
|
10
|
+
* @returns Absolute paths to found test files, sorted alphabetically.
|
|
11
|
+
*/
|
|
12
|
+
export declare function findTestFiles(projectRoot: string, include: string[]): string[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Test file finder
|
|
3
|
+
//
|
|
4
|
+
// Globs for test files matching the configured include patterns.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Public API
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* Find all test files matching the include glob patterns.
|
|
13
|
+
*
|
|
14
|
+
* Supports patterns like `tests/**\/*.test.ts`, `src/**\/*.test.ts`, or exact
|
|
15
|
+
* file paths like `src/sketch.ts`. Only files ending in `.test.ts` are
|
|
16
|
+
* returned.
|
|
17
|
+
*
|
|
18
|
+
* @param projectRoot Absolute path to the project root.
|
|
19
|
+
* @param include Glob patterns to match.
|
|
20
|
+
* @returns Absolute paths to found test files, sorted alphabetically.
|
|
21
|
+
*/
|
|
22
|
+
export function findTestFiles(projectRoot, include) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const pattern of include) {
|
|
25
|
+
for (const f of expandPattern(projectRoot, pattern)) {
|
|
26
|
+
if (f.endsWith('.test.ts') && !seen.has(f)) {
|
|
27
|
+
seen.add(f);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...seen].sort();
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Internal
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function expandPattern(projectRoot, pattern) {
|
|
37
|
+
const normalized = pattern.replace(/\\/g, '/');
|
|
38
|
+
// Exact file path (no wildcards)
|
|
39
|
+
if (!normalized.includes('*')) {
|
|
40
|
+
const abs = path.resolve(projectRoot, normalized);
|
|
41
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile())
|
|
42
|
+
return [abs];
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
// Split on the first ** or * to get directory prefix
|
|
46
|
+
const starIdx = normalized.indexOf('*');
|
|
47
|
+
const dirPart = normalized.slice(0, starIdx).replace(/\/$/, '');
|
|
48
|
+
const absDir = path.resolve(projectRoot, dirPart || '.');
|
|
49
|
+
if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory())
|
|
50
|
+
return [];
|
|
51
|
+
// Use recursive readdir, then match each relative path against the full pattern
|
|
52
|
+
const regex = globToRegex(normalized.slice(dirPart ? dirPart.length + 1 : 0));
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const entry of fs.readdirSync(absDir, { recursive: true })) {
|
|
55
|
+
const rel = entry.toString().replace(/\\/g, '/');
|
|
56
|
+
if (regex.test(rel) && rel.endsWith('.test.ts')) {
|
|
57
|
+
const abs = path.resolve(absDir, rel);
|
|
58
|
+
if (fs.statSync(abs, { throwIfNoEntry: false })?.isFile()) {
|
|
59
|
+
results.push(abs);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
function globToRegex(glob) {
|
|
66
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
// **/ matches zero or more path segments, * matches within a single segment
|
|
68
|
+
const pattern = escaped.replace(/\?/g, '[^/]').replace(/\*\*\//g, '(.+/)?').replace(/\*/g, '[^/]*');
|
|
69
|
+
return new RegExp(`^${pattern}$`);
|
|
70
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Protocol parser
|
|
3
|
+
//
|
|
4
|
+
// Parses protocol lines from serial output into structured test results.
|
|
5
|
+
//
|
|
6
|
+
// Protocol format:
|
|
7
|
+
// [TC:SUITE_START]
|
|
8
|
+
// [TC:DESCRIBE:name]
|
|
9
|
+
// [TC:IT:name]
|
|
10
|
+
// [TC:EXPECT:matcher:expected:actual]
|
|
11
|
+
// [TC:SUITE_END]
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
import { evaluate } from './evaluator.js';
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Public API
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/**
|
|
18
|
+
* Parse raw protocol lines into structured describe/test/assertion results.
|
|
19
|
+
*/
|
|
20
|
+
export function parseProtocolLines(rawLines) {
|
|
21
|
+
const lines = rawLines.map(parseSingleLine).filter(Boolean);
|
|
22
|
+
return buildResults(lines);
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Internal — Line parsing
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/**
|
|
28
|
+
* Parse a single protocol line into its tag and fields.
|
|
29
|
+
*
|
|
30
|
+
* Format: `[TC:TAG:field1:field2:...]`
|
|
31
|
+
*
|
|
32
|
+
* The last field of EXPECT lines may contain colons (e.g. string values),
|
|
33
|
+
* so we parse carefully.
|
|
34
|
+
*/
|
|
35
|
+
function parseSingleLine(raw) {
|
|
36
|
+
// Strip brackets: "[TC:..." → "TC:..."
|
|
37
|
+
const inner = raw.replace(/^\[/, '').replace(/\]$/, '');
|
|
38
|
+
if (!inner.startsWith('TC:'))
|
|
39
|
+
return null;
|
|
40
|
+
// Split on first `:` after `TC` to get the tag
|
|
41
|
+
const withoutPrefix = inner.slice(3); // remove "TC:"
|
|
42
|
+
const colonIdx = withoutPrefix.indexOf(':');
|
|
43
|
+
let tag;
|
|
44
|
+
let rest;
|
|
45
|
+
if (colonIdx === -1) {
|
|
46
|
+
tag = withoutPrefix;
|
|
47
|
+
rest = '';
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
tag = withoutPrefix.slice(0, colonIdx);
|
|
51
|
+
rest = withoutPrefix.slice(colonIdx + 1);
|
|
52
|
+
}
|
|
53
|
+
if (!isValidTag(tag))
|
|
54
|
+
return null;
|
|
55
|
+
const fields = rest.length > 0 ? splitFields(tag, rest) : [];
|
|
56
|
+
return { tag: tag, fields, raw };
|
|
57
|
+
}
|
|
58
|
+
function isValidTag(tag) {
|
|
59
|
+
return ['SUITE_START', 'DESCRIBE', 'IT', 'EXPECT', 'SUITE_END'].includes(tag);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Split field string into segments.
|
|
63
|
+
*
|
|
64
|
+
* For EXPECT lines, the format is `matcher:expected:actual` where the actual
|
|
65
|
+
* value is everything after the last colon-separated segment.
|
|
66
|
+
* For DESCRIBE/IT, there's only one field (the name).
|
|
67
|
+
*/
|
|
68
|
+
function splitFields(tag, rest) {
|
|
69
|
+
if (tag === 'DESCRIBE' || tag === 'IT') {
|
|
70
|
+
return [rest]; // name may contain colons — treat as single field
|
|
71
|
+
}
|
|
72
|
+
if (tag === 'EXPECT') {
|
|
73
|
+
// Format: matcher:expected:actual
|
|
74
|
+
// matcher is always a single word, expected may contain commas (for range)
|
|
75
|
+
// actual is the last segment (everything after the last unescaped colon)
|
|
76
|
+
return splitExpectFields(rest);
|
|
77
|
+
}
|
|
78
|
+
return rest.split(':');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Split EXPECT fields: `toBe:0:11` → ['toBe', '0', '11']
|
|
82
|
+
*
|
|
83
|
+
* We know the structure: matcher is first, then expected value(s), then actual
|
|
84
|
+
* value as the final segment.
|
|
85
|
+
*/
|
|
86
|
+
function splitExpectFields(rest) {
|
|
87
|
+
const parts = rest.split(':');
|
|
88
|
+
if (parts.length < 3)
|
|
89
|
+
return parts;
|
|
90
|
+
// First part is the matcher name
|
|
91
|
+
const matcher = parts[0];
|
|
92
|
+
// Last part is the actual value
|
|
93
|
+
const actual = parts[parts.length - 1];
|
|
94
|
+
// Everything in between is the expected value (may have been split on : if
|
|
95
|
+
// it contained colons, so rejoin)
|
|
96
|
+
const expected = parts.slice(1, -1).join(':');
|
|
97
|
+
return [matcher, expected, actual];
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Internal — Result building
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
function buildResults(lines) {
|
|
103
|
+
const describes = [];
|
|
104
|
+
let currentDescribe = null;
|
|
105
|
+
let currentTest = null;
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
switch (line.tag) {
|
|
108
|
+
case 'SUITE_START':
|
|
109
|
+
// No-op — we already know we're starting
|
|
110
|
+
break;
|
|
111
|
+
case 'DESCRIBE': {
|
|
112
|
+
// Close previous test + describe if open
|
|
113
|
+
if (currentTest && currentDescribe) {
|
|
114
|
+
finalizeTest(currentTest);
|
|
115
|
+
currentDescribe.tests.push(currentTest);
|
|
116
|
+
currentTest = null;
|
|
117
|
+
}
|
|
118
|
+
if (currentDescribe) {
|
|
119
|
+
finalizeDescribe(currentDescribe);
|
|
120
|
+
describes.push(currentDescribe);
|
|
121
|
+
}
|
|
122
|
+
currentDescribe = {
|
|
123
|
+
name: line.fields[0] ?? 'unnamed',
|
|
124
|
+
tests: [],
|
|
125
|
+
passed: true,
|
|
126
|
+
};
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'IT': {
|
|
130
|
+
// Close previous test if open
|
|
131
|
+
if (currentTest && currentDescribe) {
|
|
132
|
+
finalizeTest(currentTest);
|
|
133
|
+
currentDescribe.tests.push(currentTest);
|
|
134
|
+
}
|
|
135
|
+
currentTest = {
|
|
136
|
+
name: line.fields[0] ?? 'unnamed',
|
|
137
|
+
assertions: [],
|
|
138
|
+
passed: true,
|
|
139
|
+
durationMs: 0,
|
|
140
|
+
};
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case 'EXPECT': {
|
|
144
|
+
if (!currentTest) {
|
|
145
|
+
// EXPECT outside of IT — create an implicit test
|
|
146
|
+
currentTest = {
|
|
147
|
+
name: '(implicit)',
|
|
148
|
+
assertions: [],
|
|
149
|
+
passed: true,
|
|
150
|
+
durationMs: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const [matcher, expected, actual] = line.fields;
|
|
154
|
+
if (matcher) {
|
|
155
|
+
const assertion = {
|
|
156
|
+
matcher: matcher,
|
|
157
|
+
expected: expected ?? '',
|
|
158
|
+
actual: actual ?? '',
|
|
159
|
+
passed: evaluate(matcher, expected ?? '', actual ?? ''),
|
|
160
|
+
};
|
|
161
|
+
currentTest.assertions.push(assertion);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'SUITE_END': {
|
|
166
|
+
if (currentTest && currentDescribe) {
|
|
167
|
+
finalizeTest(currentTest);
|
|
168
|
+
currentDescribe.tests.push(currentTest);
|
|
169
|
+
currentTest = null;
|
|
170
|
+
}
|
|
171
|
+
if (currentDescribe) {
|
|
172
|
+
finalizeDescribe(currentDescribe);
|
|
173
|
+
describes.push(currentDescribe);
|
|
174
|
+
currentDescribe = null;
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Handle unterminated groups
|
|
181
|
+
if (currentTest && currentDescribe) {
|
|
182
|
+
finalizeTest(currentTest);
|
|
183
|
+
currentDescribe.tests.push(currentTest);
|
|
184
|
+
}
|
|
185
|
+
if (currentDescribe) {
|
|
186
|
+
finalizeDescribe(currentDescribe);
|
|
187
|
+
describes.push(currentDescribe);
|
|
188
|
+
}
|
|
189
|
+
return describes;
|
|
190
|
+
}
|
|
191
|
+
function finalizeTest(test) {
|
|
192
|
+
test.passed = test.assertions.every(a => a.passed);
|
|
193
|
+
}
|
|
194
|
+
function finalizeDescribe(desc) {
|
|
195
|
+
desc.passed = desc.tests.every(t => t.passed);
|
|
196
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface PreprocessorOptions {
|
|
2
|
+
/** Wrap string literals in Arduino F() macro to save SRAM on AVR. */
|
|
3
|
+
isAvr?: boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Preprocess a test file's TypeScript source.
|
|
7
|
+
*
|
|
8
|
+
* 1. Strips `import { ... } from '@typecad/expect'`
|
|
9
|
+
* 2. Walks top-level expression-statements looking for `describe(...)...` chains
|
|
10
|
+
* 3. Replaces `done()` with the suite-end sentinel + idle loop
|
|
11
|
+
* 4. Hoists hardware expressions out of `expect()` into `const` declarations
|
|
12
|
+
* 5. Wraps everything with Serial.initialize + SUITE_START preamble
|
|
13
|
+
*/
|
|
14
|
+
export declare function preprocess(source: string, fileName?: string, options?: PreprocessorOptions): string;
|
|
15
|
+
export declare class PreprocessorContext {
|
|
16
|
+
private lines;
|
|
17
|
+
private varCounter;
|
|
18
|
+
private fnCounter;
|
|
19
|
+
private preambleEmitted;
|
|
20
|
+
private readonly baudRate;
|
|
21
|
+
readonly isAvr: boolean;
|
|
22
|
+
constructor(isAvr: boolean);
|
|
23
|
+
/** Wrap a string literal in F() on AVR to keep it in flash. */
|
|
24
|
+
flash(s: string): string;
|
|
25
|
+
/** Emit a line of TypeScript output. */
|
|
26
|
+
emit(line: string): void;
|
|
27
|
+
/** Generate a unique temporary variable name. */
|
|
28
|
+
nextVar(): string;
|
|
29
|
+
/** Generate a unique extracted function name. */
|
|
30
|
+
nextFn(): string;
|
|
31
|
+
private emitPreamble;
|
|
32
|
+
build(): string;
|
|
33
|
+
}
|