@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.
@@ -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, serialShim, zephyrShim } from './preprocessor.js';
11
- import { transpileTestFile, compileSketch, uploadSketch } from './compiler.js';
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
- // 2. Process each file sequentially (one compile/upload cycle per file)
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
- const result = await processTestFile(filePath, config);
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
- // 3. Aggregate results
89
+ // 4. Aggregate results
54
90
  const runResult = aggregateResults(fileResults, Date.now() - startTime);
55
- // 4. Final summary
91
+ // 5. Final summary
56
92
  reportSummary(runResult, {
57
93
  board: config.board,
58
- port: config.test.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
- // Validate port only for files that will actually compile/upload. This lets
81
- // target-incompatible files be skipped without requiring hardware to be
82
- // connected, and lets --dry-run run without any port (it stops after compile).
83
- if (!config.test.port && !config.dryRun) {
84
- return errorResult(filePath, 'No serial port specified. Use --port <port> or set test.port in cuttlefish.config.ts', startTime);
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
- isAvr: config.target === 'avr' || config.target === 'megaavr',
93
- shim: config.toolchainType === 'west' ? zephyrShim : serialShim,
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.toolchainType, config.configPath);
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 the configured toolchain (arduino-cli or west)
154
+ // Step 3: Compile via west (through the Zephyr Toolchain)
106
155
  console.log(` ${DIM}compiling...${RESET}`);
107
- const compileResult = compileSketch(transpileResult.sketchDir, config.buildTarget, config.framework, config.toolchainType, config.zephyrConfig);
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
- console.log(` ${DIM}uploading to ${config.test.port}...${RESET}`);
120
- const uploadResult = uploadSketch(transpileResult.sketchDir, config.buildTarget, config.test.port, config.framework, config.toolchainType, config.zephyrConfig);
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(config.test.port, config.test.baudRate, config.test.timeout, config.test.serialOpenDelay, { resetAfterOpen: config.test.resetAfterOpen ?? config.target === 'esp32' });
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) {
@@ -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
- 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
+ // 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
- finish(`Failed to open ${port}: ${err.message}`);
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
- resetBoard();
144
+ finish(`Failed to open ${port}: ${err.message}`);
126
145
  });
127
- }, serialOpenDelay);
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
+ }
@@ -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-avr". */
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, Expectation, StringExpectation } from './types.js';
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.14",
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/arduino-cli": "1.0.0-alpha.14",
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.14"
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"