@typecad/expect 1.0.0-alpha.14 → 1.0.0-alpha.16
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/README.md +417 -345
- package/dist/host/cli.js +80 -36
- package/dist/host/compiler.d.ts +11 -11
- package/dist/host/compiler.js +87 -135
- package/dist/host/config.d.ts +0 -3
- package/dist/host/config.js +26 -24
- package/dist/host/finder.d.ts +1 -1
- package/dist/host/finder.js +1 -1
- package/dist/host/port-discovery.d.ts +55 -0
- package/dist/host/port-discovery.js +122 -0
- package/dist/host/preprocessor.d.ts +37 -18
- package/dist/host/preprocessor.js +63 -33
- package/dist/host/project-root.d.ts +8 -0
- package/dist/host/project-root.js +24 -0
- package/dist/host/protocol-emitter.js +7 -7
- package/dist/host/reporter.js +1 -1
- package/dist/host/runner.d.ts +0 -5
- package/dist/host/runner.js +139 -31
- package/dist/host/serial.js +30 -9
- package/dist/host/test-pins.d.ts +37 -0
- package/dist/host/test-pins.js +105 -0
- package/dist/host/types.d.ts +12 -3
- package/dist/index.d.ts +1 -1
- package/package.json +5 -6
package/dist/host/runner.js
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { findTestFiles } from './finder.js';
|
|
10
|
-
import { preprocess,
|
|
11
|
-
import { transpileTestFile,
|
|
10
|
+
import { preprocess, zephyrShim } from './preprocessor.js';
|
|
11
|
+
import { transpileTestFile, compileProgram, uploadProgram } from './compiler.js';
|
|
12
12
|
import { readSerialOutput } from './serial.js';
|
|
13
13
|
import { parseProtocolLines } from './parser.js';
|
|
14
14
|
import { reportFileResult, reportSummary } from './reporter.js';
|
|
15
|
+
import { boardTestPins, testPinsRolesOf, buildTestPinsSubstitutions } from './test-pins.js';
|
|
16
|
+
import { resolveUsbPort, waitForUsbPort, formatUsbIdentity, } from './port-discovery.js';
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
// ANSI codes (for inline progress messages)
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
@@ -19,14 +21,6 @@ const DIM = '\x1b[2m';
|
|
|
19
21
|
const RESET = '\x1b[0m';
|
|
20
22
|
const CYAN = '\x1b[36m';
|
|
21
23
|
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
24
|
export async function run(config) {
|
|
31
25
|
const startTime = Date.now();
|
|
32
26
|
// 1. Discover test files
|
|
@@ -36,11 +30,53 @@ export async function run(config) {
|
|
|
36
30
|
return 0;
|
|
37
31
|
}
|
|
38
32
|
console.log(`${DIM}Found ${testFiles.length} test file${testFiles.length !== 1 ? 's' : ''}${RESET}`);
|
|
33
|
+
// 2. Resolve the board's USB identity and an initial upload port.
|
|
34
|
+
const ctx = {
|
|
35
|
+
boardPins: boardTestPins(config.board, config.projectRoot, config.configPath),
|
|
36
|
+
usbIdentity: config.test.usb ?? ctxBoardUsb(config),
|
|
37
|
+
uploadPort: config.test.port,
|
|
38
|
+
};
|
|
39
|
+
if (ctx.usbIdentity) {
|
|
40
|
+
console.log(`${DIM}usb identity ${formatUsbIdentity(ctx.usbIdentity)}${RESET}`);
|
|
41
|
+
const resolved = await resolveUsbPort(ctx.usbIdentity);
|
|
42
|
+
if (resolved.port) {
|
|
43
|
+
if (resolved.port !== ctx.uploadPort) {
|
|
44
|
+
console.log(`${DIM}console port resolved: ${resolved.port}${RESET}`);
|
|
45
|
+
}
|
|
46
|
+
ctx.uploadPort = resolved.port;
|
|
47
|
+
}
|
|
48
|
+
else if (config.test.port) {
|
|
49
|
+
// Bootstrap/fallback: the currently-flashed firmware may predate this
|
|
50
|
+
// board's PID assignment (or a clone bridge may report a different
|
|
51
|
+
// VID/PID). The post-upload re-resolve will pick the identity up.
|
|
52
|
+
console.log(`${YELLOW}${resolved.error}${RESET}`);
|
|
53
|
+
console.log(`${YELLOW}falling back to configured port ${config.test.port}${RESET}`);
|
|
54
|
+
}
|
|
55
|
+
// west + CDC boards flash via a debug probe, not the console port — the
|
|
56
|
+
// port arrives from the post-flash re-enumeration below.
|
|
57
|
+
}
|
|
39
58
|
console.log();
|
|
40
|
-
//
|
|
59
|
+
// 3. Process each file sequentially (one compile/upload cycle per file)
|
|
41
60
|
const fileResults = [];
|
|
61
|
+
const MAX_FILE_RETRIES = 2;
|
|
42
62
|
for (const filePath of testFiles) {
|
|
43
|
-
|
|
63
|
+
let result = await processTestFile(filePath, config, ctx);
|
|
64
|
+
// Nightly-rig hardening: transient hardware glitches fail a file even
|
|
65
|
+
// though the board is fine — a USB console dropout mid-read loses the
|
|
66
|
+
// protocol lines, debug-probe flashes occasionally fail target
|
|
67
|
+
// examination (OpenOCD "Failed to read memory at 0xe000ed04" under
|
|
68
|
+
// repeated SWD cycles), and a port open right after a failed-flash retry
|
|
69
|
+
// can hit a briefly held handle ("access denied"). A fresh
|
|
70
|
+
// compile/upload/read cycle per file recovers all of these without
|
|
71
|
+
// masking persistent failures (those fail every retry).
|
|
72
|
+
const transientPattern = /Timeout after|Serial error|did not re-appear|west flash failed|Upload failed|Failed to open|Access denied/;
|
|
73
|
+
for (let tries = 0; tries < MAX_FILE_RETRIES; tries++) {
|
|
74
|
+
if (!result.error || !transientPattern.test(result.error) || !ctx.usbIdentity)
|
|
75
|
+
break;
|
|
76
|
+
const cause = result.error.split('\n').find((l) => l.trim().length > 0) ?? result.error;
|
|
77
|
+
console.log(`${YELLOW}transient console loss (${cause.trim()}) — retrying ${path.relative(config.projectRoot, filePath)} (${tries + 1}/${MAX_FILE_RETRIES})${RESET}`);
|
|
78
|
+
result = await processTestFile(filePath, config, ctx);
|
|
79
|
+
}
|
|
44
80
|
fileResults.push(result);
|
|
45
81
|
reportFileResult(result, { verbose: config.test.verbose });
|
|
46
82
|
// --bail: stop after the first file that fails to compile/upload or has a
|
|
@@ -50,19 +86,24 @@ export async function run(config) {
|
|
|
50
86
|
break;
|
|
51
87
|
}
|
|
52
88
|
}
|
|
53
|
-
//
|
|
89
|
+
// 4. Aggregate results
|
|
54
90
|
const runResult = aggregateResults(fileResults, Date.now() - startTime);
|
|
55
|
-
//
|
|
91
|
+
// 5. Final summary
|
|
56
92
|
reportSummary(runResult, {
|
|
57
93
|
board: config.board,
|
|
58
|
-
port:
|
|
94
|
+
port: ctx.uploadPort,
|
|
59
95
|
});
|
|
60
96
|
return (runResult.totalFailed > 0 || runResult.totalErrors > 0) ? 1 : 0;
|
|
61
97
|
}
|
|
98
|
+
/** The board's test-pins.json usb block, when present. */
|
|
99
|
+
function ctxBoardUsb(config) {
|
|
100
|
+
const usb = boardTestPins(config.board, config.projectRoot, config.configPath)?.usb;
|
|
101
|
+
return usb ? { ...usb } : undefined;
|
|
102
|
+
}
|
|
62
103
|
// ---------------------------------------------------------------------------
|
|
63
104
|
// Internal — Process a single test file through the full pipeline
|
|
64
105
|
// ---------------------------------------------------------------------------
|
|
65
|
-
async function processTestFile(filePath, config) {
|
|
106
|
+
async function processTestFile(filePath, config, ctx) {
|
|
66
107
|
const startTime = Date.now();
|
|
67
108
|
const relativePath = path.relative(config.projectRoot, filePath);
|
|
68
109
|
// Read source
|
|
@@ -77,20 +118,28 @@ async function processTestFile(filePath, config) {
|
|
|
77
118
|
if (skipReason) {
|
|
78
119
|
return skippedResult(relativePath, skipReason, startTime);
|
|
79
120
|
}
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
// The board's test-pins data: role gating for the skip check,
|
|
122
|
+
// substitutions for the preprocessor.
|
|
123
|
+
const testPinsData = ctx.boardPins;
|
|
124
|
+
const missingRolesReason = checkRequiredRoles(source, config, testPinsData);
|
|
125
|
+
if (missingRolesReason) {
|
|
126
|
+
return skippedResult(relativePath, missingRolesReason, startTime);
|
|
127
|
+
}
|
|
128
|
+
// Validate port only for files that will actually compile/upload. A USB
|
|
129
|
+
// identity satisfies this (the port resolves after upload for CDC boards);
|
|
130
|
+
// target-incompatible files still skip without hardware; --dry-run stops
|
|
131
|
+
// after compile and needs no port at all.
|
|
132
|
+
if (!ctx.uploadPort && !ctx.usbIdentity && !config.dryRun) {
|
|
133
|
+
return errorResult(filePath, 'No serial port specified. Use --port <port>, set test.port, or set test.usb in cuttlefish.config.ts', startTime);
|
|
85
134
|
}
|
|
86
135
|
console.log(`${CYAN}●${RESET} ${relativePath}`);
|
|
87
|
-
// Step 1: Preprocess
|
|
136
|
+
// Step 1: Preprocess (with test-pin role substitution for this board)
|
|
88
137
|
console.log(` ${DIM}preprocessing...${RESET}`);
|
|
89
138
|
let preprocessed;
|
|
90
139
|
try {
|
|
91
140
|
preprocessed = preprocess(source, path.basename(filePath), {
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
shim: zephyrShim,
|
|
142
|
+
testPins: testPinsData ? buildTestPinsSubstitutions(testPinsData) : undefined,
|
|
94
143
|
});
|
|
95
144
|
}
|
|
96
145
|
catch (e) {
|
|
@@ -98,13 +147,13 @@ async function processTestFile(filePath, config) {
|
|
|
98
147
|
}
|
|
99
148
|
// Step 2: Transpile to C++
|
|
100
149
|
console.log(` ${DIM}transpiling...${RESET}`);
|
|
101
|
-
const transpileResult = transpileTestFile(preprocessed, filePath, config.projectRoot, config.buildTarget, config.
|
|
150
|
+
const transpileResult = transpileTestFile(preprocessed, filePath, config.projectRoot, config.buildTarget, config.configPath);
|
|
102
151
|
if (!transpileResult.success) {
|
|
103
152
|
return errorResult(filePath, transpileResult.error ?? 'Transpilation failed', startTime);
|
|
104
153
|
}
|
|
105
|
-
// Step 3: Compile via
|
|
154
|
+
// Step 3: Compile via west (through the Zephyr Toolchain)
|
|
106
155
|
console.log(` ${DIM}compiling...${RESET}`);
|
|
107
|
-
const compileResult =
|
|
156
|
+
const compileResult = compileProgram(transpileResult.projectDir, config.buildTarget, config.zephyrConfig);
|
|
108
157
|
if (!compileResult.success) {
|
|
109
158
|
return errorResult(filePath, compileResult.error ?? 'Compilation failed', startTime);
|
|
110
159
|
}
|
|
@@ -115,15 +164,49 @@ async function processTestFile(filePath, config) {
|
|
|
115
164
|
console.log(` ${DIM}compiled (dry-run, skipping upload and tests)${RESET}`);
|
|
116
165
|
return { filePath: relativePath, describes: [], passed: true, durationMs, debugOutput: [], compiled: true };
|
|
117
166
|
}
|
|
118
|
-
// Step 4: Upload via the configured toolchain
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
// Step 4: Upload via the configured toolchain. With a USB identity active,
|
|
168
|
+
// refresh the resolved port first — a mid-run dropout can leave the
|
|
169
|
+
// threaded port stale (matters for bridge uploads; probes ignore it).
|
|
170
|
+
if (ctx.usbIdentity && ctx.uploadPort) {
|
|
171
|
+
const fresh = await resolveUsbPort(ctx.usbIdentity);
|
|
172
|
+
if (fresh.port && fresh.port !== ctx.uploadPort) {
|
|
173
|
+
console.log(` ${DIM}console port re-resolved: ${fresh.port}${RESET}`);
|
|
174
|
+
ctx.uploadPort = fresh.port;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
console.log(` ${DIM}uploading${ctx.uploadPort ? ` to ${ctx.uploadPort}` : ''}...${RESET}`);
|
|
178
|
+
const uploadResult = uploadProgram(transpileResult.projectDir, config.buildTarget, ctx.uploadPort, config.zephyrConfig);
|
|
121
179
|
if (!uploadResult.success) {
|
|
122
180
|
return errorResult(filePath, uploadResult.error ?? 'Upload failed', startTime);
|
|
123
181
|
}
|
|
182
|
+
// Step 4b: Re-resolve the console port. CDC consoles re-enumerate after a
|
|
183
|
+
// flash and may return under a different COM/tty number; bridge boards
|
|
184
|
+
// (Uno/ESP32 devkits) keep their port. The identity-based lookup settles
|
|
185
|
+
// after serialOpenDelay and then polls briefly for the re-enumeration.
|
|
186
|
+
let readPort = ctx.uploadPort;
|
|
187
|
+
let readOpenDelay = config.test.serialOpenDelay;
|
|
188
|
+
if (ctx.usbIdentity) {
|
|
189
|
+
console.log(` ${DIM}waiting for ${formatUsbIdentity(ctx.usbIdentity)} to re-enumerate...${RESET}`);
|
|
190
|
+
const settled = await waitForUsbPort(ctx.usbIdentity, config.test.serialOpenDelay ?? 500);
|
|
191
|
+
if (settled) {
|
|
192
|
+
if (settled !== readPort) {
|
|
193
|
+
console.log(` ${DIM}console port re-resolved: ${settled}${RESET}`);
|
|
194
|
+
}
|
|
195
|
+
readPort = settled;
|
|
196
|
+
ctx.uploadPort = settled;
|
|
197
|
+
// The settle wait above already covered the open delay.
|
|
198
|
+
readOpenDelay = 250;
|
|
199
|
+
}
|
|
200
|
+
else if (readPort) {
|
|
201
|
+
console.log(`${YELLOW}USB port for ${formatUsbIdentity(ctx.usbIdentity)} did not re-appear — reading ${readPort}${RESET}`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
return errorResult(filePath, `USB port for ${formatUsbIdentity(ctx.usbIdentity)} did not re-appear after upload`, startTime);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
124
207
|
// Step 5: Read serial output
|
|
125
208
|
console.log(` ${DIM}reading serial output...${RESET}`);
|
|
126
|
-
const serialResult = await readSerialOutput(
|
|
209
|
+
const serialResult = await readSerialOutput(readPort ?? '', config.test.baudRate, config.test.timeout, readOpenDelay, { resetAfterOpen: config.test.resetAfterOpen ?? config.target === 'esp32' });
|
|
127
210
|
if (serialResult.error && !serialResult.completed) {
|
|
128
211
|
return errorResult(filePath, serialResult.error, startTime);
|
|
129
212
|
}
|
|
@@ -197,6 +280,31 @@ function aggregateResults(files, durationMs) {
|
|
|
197
280
|
durationMs,
|
|
198
281
|
};
|
|
199
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Skip files whose required test-pins roles are not provided by the
|
|
285
|
+
* configured board. Directive form (roles from test-pins.json schema):
|
|
286
|
+
* // @typecad-requires-roles pwm, pwmAlt
|
|
287
|
+
*/
|
|
288
|
+
function checkRequiredRoles(source, config, testPinsData) {
|
|
289
|
+
const match = source.match(/@typecad-requires-roles\s+([A-Za-z0-9_,\s]+)/);
|
|
290
|
+
if (!match)
|
|
291
|
+
return undefined;
|
|
292
|
+
const required = match[1]
|
|
293
|
+
.split(/[,\s]+/)
|
|
294
|
+
.map(r => r.trim())
|
|
295
|
+
.filter(Boolean);
|
|
296
|
+
if (required.length === 0)
|
|
297
|
+
return undefined;
|
|
298
|
+
if (!testPinsData) {
|
|
299
|
+
return `${config.board} ships no test-pins.json — cannot provide roles: ${required.join(', ')}`;
|
|
300
|
+
}
|
|
301
|
+
const roles = testPinsRolesOf(testPinsData);
|
|
302
|
+
const missing = required.filter(role => !roles.has(role));
|
|
303
|
+
if (missing.length > 0) {
|
|
304
|
+
return `board ${config.board} does not provide test-pins role(s): ${missing.join(', ')}`;
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
200
308
|
function getSkipReason(source, relativePath, config) {
|
|
201
309
|
const excludedByConfig = config.test.exclude?.find(pattern => matchesTestPattern(relativePath, pattern));
|
|
202
310
|
if (excludedByConfig) {
|
package/dist/host/serial.js
CHANGED
|
@@ -26,7 +26,12 @@ export async function readSerialOutput(port, baudRate, timeoutMs, serialOpenDela
|
|
|
26
26
|
return new Promise((resolve) => {
|
|
27
27
|
let resolved = false;
|
|
28
28
|
const cleanup = (callback) => {
|
|
29
|
+
// Strip the data/close listeners, but keep (re-attach) a no-op error
|
|
30
|
+
// handler: a USB dropout mid-close emits 'error' after the removal,
|
|
31
|
+
// and an 'error' event with no listener throws and kills the process
|
|
32
|
+
// — the exact failure a nightly rig must survive.
|
|
29
33
|
sp.removeAllListeners();
|
|
34
|
+
sp.on('error', () => { });
|
|
30
35
|
if (sp.isOpen) {
|
|
31
36
|
sp.close(() => callback());
|
|
32
37
|
}
|
|
@@ -112,18 +117,34 @@ export async function readSerialOutput(port, baudRate, timeoutMs, serialOpenDela
|
|
|
112
117
|
finish(completed ? undefined : `Timeout after ${timeoutMs}ms — no [TC:SUITE_END] received`);
|
|
113
118
|
}, timeoutMs);
|
|
114
119
|
sp.on('data', handleChunk);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
+
// A CDC console that just re-enumerated after a flash can be LISTED but
|
|
121
|
+
// not yet OPENABLE for several seconds — Windows usbser returns
|
|
122
|
+
// SetCommState error 31 while the PDO/driver state from the flash-cycle
|
|
123
|
+
// detach/attach settles. Retry the open generously (the firmware's boot
|
|
124
|
+
// DTR-wait holds all protocol output until the host opens, so a long
|
|
125
|
+
// window loses nothing).
|
|
126
|
+
const OPEN_RETRIES = 16;
|
|
127
|
+
const OPEN_RETRY_MS = 750;
|
|
128
|
+
let openRetriesLeft = OPEN_RETRIES;
|
|
129
|
+
const tryOpen = () => {
|
|
120
130
|
sp.open((err) => {
|
|
121
|
-
if (err) {
|
|
122
|
-
|
|
131
|
+
if (!err) {
|
|
132
|
+
// Only now can open-lifecycle errors surface as events (open
|
|
133
|
+
// failures above arrive via the callback, not 'error').
|
|
134
|
+
sp.on('error', (err) => {
|
|
135
|
+
finish(`Serial error: ${err.message}`);
|
|
136
|
+
});
|
|
137
|
+
resetBoard();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (openRetriesLeft-- > 0) {
|
|
141
|
+
setTimeout(tryOpen, OPEN_RETRY_MS);
|
|
123
142
|
return;
|
|
124
143
|
}
|
|
125
|
-
|
|
144
|
+
finish(`Failed to open ${port}: ${err.message}`);
|
|
126
145
|
});
|
|
127
|
-
}
|
|
146
|
+
};
|
|
147
|
+
// Give the device time to reset after upload (many boards reset on serial open)
|
|
148
|
+
setTimeout(tryOpen, serialOpenDelay);
|
|
128
149
|
});
|
|
129
150
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Pin role -> role const name used in test sources. */
|
|
2
|
+
export declare const PIN_ROLE_CONSTS: Record<string, string>;
|
|
3
|
+
/** Fact role -> fact const name used in test sources. */
|
|
4
|
+
export declare const FACT_ROLE_CONSTS: Record<string, string>;
|
|
5
|
+
export interface TestPinsData {
|
|
6
|
+
pins?: Record<string, string | string[]>;
|
|
7
|
+
facts?: Record<string, number>;
|
|
8
|
+
/**
|
|
9
|
+
* USB identity for port discovery — how a multi-board rig finds this
|
|
10
|
+
* board's console/upload port without tracking COM/tty numbers. Zephyr
|
|
11
|
+
* CDC boards carry their per-board PID here (matching zephyr.usb.vid/pid
|
|
12
|
+
* in the board package); UART-bridge boards carry the bridge chip's ID
|
|
13
|
+
* (Uno 16U2 2341:0043, ESP32 DevKitC CP2102 10C4:EA60).
|
|
14
|
+
*/
|
|
15
|
+
usb?: {
|
|
16
|
+
vid: string;
|
|
17
|
+
pid: string;
|
|
18
|
+
serial?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Substitution map handed to the preprocessor: role const name -> the
|
|
23
|
+
* TypeScript text that replaces it (a pin symbol, an array literal of pin
|
|
24
|
+
* symbols, or a numeric literal).
|
|
25
|
+
*/
|
|
26
|
+
export type TestPinsSubstitutions = Map<string, string>;
|
|
27
|
+
export declare function buildTestPinsSubstitutions(data: TestPinsData): TestPinsSubstitutions;
|
|
28
|
+
/** Roles (pin + fact keys) the data provides, for requires-roles gating. */
|
|
29
|
+
export declare function testPinsRolesOf(data: TestPinsData): Set<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Load the project's test-pins.json. A file co-located with the chosen
|
|
32
|
+
* cuttlefish.config.ts wins (boards/<name>/test-pins.json — one pins set per
|
|
33
|
+
* board config in a multi-board project); the project root is the fallback
|
|
34
|
+
* (and the location for single-board projects). Returns undefined when
|
|
35
|
+
* neither exists.
|
|
36
|
+
*/
|
|
37
|
+
export declare function boardTestPins(_board: string, projectRoot: string, configPath?: string): TestPinsData | undefined;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/expect — Board test-pins resolution
|
|
3
|
+
//
|
|
4
|
+
// Boards ship a declarative test-pins.json next to their package.json (see
|
|
5
|
+
// @typecad/cuttlefish src/transpile/test-pins.ts for the schema). Suites
|
|
6
|
+
// import stable role names from '@typecad/test-pins' and declare what they
|
|
7
|
+
// need with a `// @typecad-requires-roles pwm, cs` comment:
|
|
8
|
+
//
|
|
9
|
+
// - The runner skips files whose board cannot provide the required roles.
|
|
10
|
+
// - The preprocessor substitutes every role identifier in the test source
|
|
11
|
+
// with the board's real pin symbol (facts become numeric literals) and
|
|
12
|
+
// rewrites the import to '@typecad/board' — the exact lowering path
|
|
13
|
+
// hand-written per-board tests use, so the transpiler's HAL metadata
|
|
14
|
+
// resolution sees the same source it always has.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
/** Pin role -> role const name used in test sources. */
|
|
19
|
+
export const PIN_ROLE_CONSTS = {
|
|
20
|
+
gpioOut: 'GPIO_OUT',
|
|
21
|
+
gpioIn: 'GPIO_IN',
|
|
22
|
+
pwm: 'PWM_PIN',
|
|
23
|
+
pwmAlt: 'PWM_ALT',
|
|
24
|
+
adcPin: 'ADC_PIN',
|
|
25
|
+
adcPinAlt: 'ADC_PIN_ALT',
|
|
26
|
+
cs: 'CS_PIN',
|
|
27
|
+
interrupt: 'INT_PIN',
|
|
28
|
+
led: 'LED_PIN',
|
|
29
|
+
button: 'BUTTON_PIN',
|
|
30
|
+
i2cBus: 'I2C_BUS',
|
|
31
|
+
};
|
|
32
|
+
/** Fact role -> fact const name used in test sources. */
|
|
33
|
+
export const FACT_ROLE_CONSTS = {
|
|
34
|
+
adcMax: 'ADC_MAX',
|
|
35
|
+
};
|
|
36
|
+
export function buildTestPinsSubstitutions(data) {
|
|
37
|
+
const substitutions = new Map();
|
|
38
|
+
for (const [role, constName] of Object.entries(PIN_ROLE_CONSTS)) {
|
|
39
|
+
const value = data.pins?.[role];
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
if (value.length > 0)
|
|
44
|
+
substitutions.set(constName, `[${value.join(', ')}]`);
|
|
45
|
+
}
|
|
46
|
+
else if (typeof value === 'string') {
|
|
47
|
+
substitutions.set(constName, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const [role, constName] of Object.entries(FACT_ROLE_CONSTS)) {
|
|
51
|
+
const value = data.facts?.[role];
|
|
52
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
53
|
+
substitutions.set(constName, String(value));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return substitutions;
|
|
57
|
+
}
|
|
58
|
+
/** Roles (pin + fact keys) the data provides, for requires-roles gating. */
|
|
59
|
+
export function testPinsRolesOf(data) {
|
|
60
|
+
const roles = new Set();
|
|
61
|
+
for (const key of Object.keys(data.pins ?? {}))
|
|
62
|
+
roles.add(key);
|
|
63
|
+
for (const key of Object.keys(data.facts ?? {}))
|
|
64
|
+
roles.add(key);
|
|
65
|
+
return roles;
|
|
66
|
+
}
|
|
67
|
+
/** Locate a workspace/npm package directory by walking node_modules upward. */
|
|
68
|
+
function findPackageDir(fromDir, packageName) {
|
|
69
|
+
let searchDir = path.resolve(fromDir);
|
|
70
|
+
for (let i = 0; i < 8; i++) {
|
|
71
|
+
const candidate = path.join(searchDir, 'node_modules', ...packageName.split('/'));
|
|
72
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
const parent = path.dirname(searchDir);
|
|
76
|
+
if (parent === searchDir)
|
|
77
|
+
break;
|
|
78
|
+
searchDir = parent;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Load the project's test-pins.json. A file co-located with the chosen
|
|
84
|
+
* cuttlefish.config.ts wins (boards/<name>/test-pins.json — one pins set per
|
|
85
|
+
* board config in a multi-board project); the project root is the fallback
|
|
86
|
+
* (and the location for single-board projects). Returns undefined when
|
|
87
|
+
* neither exists.
|
|
88
|
+
*/
|
|
89
|
+
export function boardTestPins(_board, projectRoot, configPath) {
|
|
90
|
+
void _board;
|
|
91
|
+
const candidates = configPath
|
|
92
|
+
? [path.join(path.dirname(configPath), 'test-pins.json'), path.join(projectRoot, 'test-pins.json')]
|
|
93
|
+
: [path.join(projectRoot, 'test-pins.json')];
|
|
94
|
+
for (const jsonPath of candidates) {
|
|
95
|
+
if (!fs.existsSync(jsonPath))
|
|
96
|
+
continue;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Malformed JSON — try the next candidate.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -94,6 +94,17 @@ export interface TestConfig {
|
|
|
94
94
|
exclude?: string[];
|
|
95
95
|
/** Serial port (e.g. `'COM3'`, `'/dev/ttyACM0'`). */
|
|
96
96
|
port: string;
|
|
97
|
+
/**
|
|
98
|
+
* USB identity for port discovery — resolves the port by VID/PID (+
|
|
99
|
+
* optional serial) instead of a hardcoded path. When set, the port is
|
|
100
|
+
* re-resolved after every upload, surviving CDC re-enumeration. The
|
|
101
|
+
* board's test-pins.json may carry the same block; the config wins.
|
|
102
|
+
*/
|
|
103
|
+
usb?: {
|
|
104
|
+
vid: string;
|
|
105
|
+
pid: string;
|
|
106
|
+
serial?: string;
|
|
107
|
+
};
|
|
97
108
|
/** Serial baud rate. Default: `115200`. */
|
|
98
109
|
baudRate: number;
|
|
99
110
|
/** Timeout in ms waiting for SUITE_END. Default: `30000`. */
|
|
@@ -118,10 +129,8 @@ export interface ResolvedConfig {
|
|
|
118
129
|
buildTarget: string;
|
|
119
130
|
board: string;
|
|
120
131
|
target: string;
|
|
121
|
-
/** Framework package name, e.g. "@typecad/framework-
|
|
132
|
+
/** Framework package name, e.g. "@typecad/framework-zephyr". */
|
|
122
133
|
framework?: string;
|
|
123
|
-
/** Toolchain type from config: 'arduino-cli' (default) or 'west' (Zephyr). */
|
|
124
|
-
toolchainType: 'arduino-cli' | 'west';
|
|
125
134
|
/** Zephyr-specific config (kconfig, etc.) from cuttlefish.config.ts. */
|
|
126
135
|
zephyrConfig?: Record<string, unknown>;
|
|
127
136
|
/** Absolute path to project root. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { Suite
|
|
1
|
+
export type { Suite } from './types.js';
|
|
2
2
|
export { describe, done } from './stubs.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typecad/expect",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.16",
|
|
4
4
|
"description": "Hardware test framework for TypeCAD — vitest-style assertions over serial",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -40,11 +40,10 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"typescript": "^5.7.3",
|
|
42
42
|
"serialport": "^13.0.0",
|
|
43
|
-
"@typecad/
|
|
44
|
-
"@typecad/hal": "1.0.0-alpha.14"
|
|
43
|
+
"@typecad/hal": "1.0.0-alpha.16"
|
|
45
44
|
},
|
|
46
45
|
"optionalDependencies": {
|
|
47
|
-
"@typecad/framework-zephyr": "1.0.0-alpha.
|
|
46
|
+
"@typecad/framework-zephyr": "1.0.0-alpha.16"
|
|
48
47
|
},
|
|
49
48
|
"devDependencies": {
|
|
50
49
|
"@types/node": "^22.10.7"
|
|
@@ -63,7 +62,6 @@
|
|
|
63
62
|
"url": "https://github.com/justind000/typecode/issues"
|
|
64
63
|
},
|
|
65
64
|
"keywords": [
|
|
66
|
-
"arduino",
|
|
67
65
|
"cpp",
|
|
68
66
|
"cuttlefish",
|
|
69
67
|
"embedded",
|
|
@@ -74,7 +72,8 @@
|
|
|
74
72
|
"test-framework",
|
|
75
73
|
"testing",
|
|
76
74
|
"typecad",
|
|
77
|
-
"typescript"
|
|
75
|
+
"typescript",
|
|
76
|
+
"zephyr"
|
|
78
77
|
],
|
|
79
78
|
"engines": {
|
|
80
79
|
"node": ">=22.11.0"
|