@typecad/expect 1.0.0-alpha.6 → 1.0.0-alpha.8

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/dist/host/cli.js CHANGED
@@ -27,6 +27,9 @@ function parseArgs(argv) {
27
27
  for (let i = 0; i < args.length; i++) {
28
28
  const arg = args[i];
29
29
  switch (arg) {
30
+ case '--config':
31
+ result.config = args[++i];
32
+ break;
30
33
  case '--port':
31
34
  case '-p':
32
35
  result.port = args[++i];
@@ -75,6 +78,12 @@ function parseArgs(argv) {
75
78
  case '-v':
76
79
  result.verbose = true;
77
80
  break;
81
+ case '--dry-run':
82
+ result.dryRun = true;
83
+ break;
84
+ case '--bail':
85
+ result.bail = true;
86
+ break;
78
87
  case '--help':
79
88
  case '-h':
80
89
  result.help = true;
@@ -169,12 +178,18 @@ async function main() {
169
178
  // Load config
170
179
  let config;
171
180
  try {
172
- config = loadConfig(projectRoot, overrides);
181
+ const configPath = args.config
182
+ ? (path.isAbsolute(args.config) ? args.config : path.resolve(projectRoot, args.config))
183
+ : undefined;
184
+ config = loadConfig(projectRoot, overrides, configPath);
173
185
  }
174
186
  catch (e) {
175
187
  console.error(`\x1b[31m${e.message}\x1b[0m`);
176
188
  process.exit(2);
177
189
  }
190
+ // CLI mode flags (not config-file settings).
191
+ config.dryRun = args.dryRun;
192
+ config.bail = args.bail;
178
193
  // Run tests
179
194
  const exitCode = await run(config);
180
195
  process.exit(exitCode);
@@ -11,17 +11,17 @@ export interface UploadResult {
11
11
  error?: string;
12
12
  }
13
13
  /**
14
- * Transpile preprocessed TypeScript source to a C++ Arduino sketch.
15
- *
16
- * Writes the preprocessed source to a temp file, invokes the cuttlefish
17
- * transpiler, and returns the path to the generated .ino file.
14
+ * Transpile preprocessed TypeScript source to a C++ Arduino sketch (or Zephyr
15
+ * project). Writes the preprocessed source to a temp file, invokes the cuttlefish
16
+ * transpiler, and returns the path to the generated .ino (Arduino) or .cpp
17
+ * (Zephyr) entry file.
18
18
  */
19
- export declare function transpileTestFile(preprocessedSource: string, originalFilePath: string, projectRoot: string, buildTarget: string): CompileResult;
19
+ export declare function transpileTestFile(preprocessedSource: string, originalFilePath: string, projectRoot: string, buildTarget: string, toolchainType?: 'arduino-cli' | 'west', configPath?: string): CompileResult;
20
20
  /**
21
- * Compile the Arduino sketch using arduino-cli.
21
+ * Compile the sketch/project via the configured toolchain (arduino-cli or west).
22
22
  */
23
- export declare function compileSketch(sketchDir: string, buildTarget: string): CompileResult;
23
+ export declare function compileSketch(sketchDir: string, buildTarget: string, framework?: string, toolchainType?: 'arduino-cli' | 'west', zephyrConfig?: Record<string, unknown>): CompileResult;
24
24
  /**
25
- * Upload the compiled sketch to the board.
25
+ * Upload the compiled sketch/project to the board via the configured toolchain.
26
26
  */
27
- export declare function uploadSketch(sketchDir: string, buildTarget: string, port: string): UploadResult;
27
+ export declare function uploadSketch(sketchDir: string, buildTarget: string, port: string, framework?: string, toolchainType?: 'arduino-cli' | 'west', zephyrConfig?: Record<string, unknown>): UploadResult;
@@ -7,8 +7,12 @@
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
9
  import { spawnSync } from 'node:child_process';
10
+ import { createRequire } from 'node:module';
10
11
  import { parseConfigAST } from './config.js';
11
12
  import { checkArduinoEnv } from '@typecad/arduino-cli';
13
+ // createRequire lets us use require() in an ESM module for the optional
14
+ // framework-zephyr dynamic import (avoids a hard dependency for Arduino users).
15
+ const require = createRequire(import.meta.url);
12
16
  /** Format a check failure into the `error` field used by CompileResult/UploadResult. */
13
17
  function formatEnvFailure(failure) {
14
18
  const lines = [...failure.messages];
@@ -17,16 +21,18 @@ function formatEnvFailure(failure) {
17
21
  return lines.join('\n');
18
22
  }
19
23
  /**
20
- * Transpile preprocessed TypeScript source to a C++ Arduino sketch.
21
- *
22
- * Writes the preprocessed source to a temp file, invokes the cuttlefish
23
- * transpiler, and returns the path to the generated .ino file.
24
+ * Transpile preprocessed TypeScript source to a C++ Arduino sketch (or Zephyr
25
+ * project). Writes the preprocessed source to a temp file, invokes the cuttlefish
26
+ * transpiler, and returns the path to the generated .ino (Arduino) or .cpp
27
+ * (Zephyr) entry file.
24
28
  */
25
- export function transpileTestFile(preprocessedSource, originalFilePath, projectRoot, buildTarget) {
26
- // Create a build directory for this test file
29
+ export function transpileTestFile(preprocessedSource, originalFilePath, projectRoot, buildTarget, toolchainType = 'arduino-cli', configPath) {
30
+ // Create a build directory for this test file. Per-file directories are
31
+ // used — arduino-cli compile has no incremental benefit from a shared dir.
27
32
  const baseName = path.basename(originalFilePath, '.test.ts').replace(/[^a-zA-Z0-9_]/g, '_');
28
33
  const buildDir = path.join(projectRoot, '.build', 'expect', baseName);
29
34
  try {
35
+ // Fresh dir for every transpile.
30
36
  fs.rmSync(buildDir, { recursive: true, force: true });
31
37
  fs.mkdirSync(buildDir, { recursive: true });
32
38
  }
@@ -40,9 +46,18 @@ export function transpileTestFile(preprocessedSource, originalFilePath, projectR
40
46
  // Invoke the cuttlefish transpiler
41
47
  // We call it as a CLI command rather than importing to avoid coupling
42
48
  const cuttlefishCmd = resolveCuttlefishCmd(projectRoot);
43
- const useBuildMode = hasRelativeImports(rewrittenSource);
49
+ // Use build mode (run `cuttlefish build` from a build dir that holds its own
50
+ // generated cuttlefish.config.ts) when the test source has relative imports
51
+ // OR when an explicit configPath was passed. The latter matters because
52
+ // direct-file mode discovers cuttlefish.config.ts from cwd (projectRoot),
53
+ // which is the DEFAULT config — so a project with several target-specific
54
+ // configs (e.g. tests/hardware/cuttlefish.config.ts vs ble-demo.config.ts)
55
+ // would always transpile against the default. Build mode writes a config
56
+ // derived from the chosen configPath into the build dir, so the right
57
+ // board/MCU/target is used.
58
+ const useBuildMode = hasRelativeImports(rewrittenSource) || !!configPath;
44
59
  if (useBuildMode) {
45
- writeBuildConfig(buildDir, projectRoot, path.basename(tsPath), buildTarget);
60
+ writeBuildConfig(buildDir, projectRoot, path.basename(tsPath), buildTarget, configPath);
46
61
  }
47
62
  const result = spawnSync(process.execPath, useBuildMode
48
63
  ? [cuttlefishCmd, 'build', '--skip-type-check', '--force']
@@ -62,29 +77,37 @@ export function transpileTestFile(preprocessedSource, originalFilePath, projectR
62
77
  error: `Transpilation failed:\n${transpileOutput}`,
63
78
  };
64
79
  }
65
- // Find the generated .ino file
66
- const outDir = findOutputDir(buildDir, baseName, projectRoot);
67
- const inoPath = findInoFile(outDir);
68
- if (!inoPath) {
80
+ // Find the generated entry file (.ino for Arduino, .cpp for Zephyr)
81
+ const outDir = findOutputDir(buildDir, baseName, projectRoot, toolchainType);
82
+ const entryPath = findEntryFile(outDir, toolchainType);
83
+ if (!entryPath) {
84
+ const ext = toolchainType === 'west' ? '.cpp' : '.ino';
69
85
  return {
70
86
  success: false,
71
87
  sketchDir: outDir,
72
88
  sketchPath: '',
73
89
  output: transpileOutput,
74
- error: `No .ino file found in ${outDir} after transpilation`,
90
+ error: `No ${ext} file found in ${outDir} after transpilation`,
75
91
  };
76
92
  }
77
93
  return {
78
94
  success: true,
79
- sketchDir: path.dirname(inoPath),
80
- sketchPath: inoPath,
95
+ sketchDir: path.dirname(entryPath),
96
+ sketchPath: entryPath,
81
97
  output: transpileOutput,
82
98
  };
83
99
  }
84
100
  /**
85
- * Compile the Arduino sketch using arduino-cli.
101
+ * Compile the sketch/project via the configured toolchain (arduino-cli or west).
86
102
  */
87
- export function compileSketch(sketchDir, buildTarget) {
103
+ export function compileSketch(sketchDir, buildTarget, framework, toolchainType = 'arduino-cli', zephyrConfig) {
104
+ if (toolchainType === 'west') {
105
+ return compileWestProject(sketchDir, buildTarget, zephyrConfig);
106
+ }
107
+ return compileArduinoSketch(sketchDir, buildTarget);
108
+ }
109
+ /** Compile via arduino-cli. */
110
+ function compileArduinoSketch(sketchDir, buildTarget) {
88
111
  // Hard gate: verify arduino-cli + core before spawning.
89
112
  {
90
113
  const gate = checkArduinoEnv(buildTarget);
@@ -104,9 +127,16 @@ export function compileSketch(sketchDir, buildTarget) {
104
127
  };
105
128
  }
106
129
  /**
107
- * Upload the compiled sketch to the board.
130
+ * Upload the compiled sketch/project to the board via the configured toolchain.
108
131
  */
109
- export function uploadSketch(sketchDir, buildTarget, port) {
132
+ export function uploadSketch(sketchDir, buildTarget, port, framework, toolchainType = 'arduino-cli', zephyrConfig) {
133
+ if (toolchainType === 'west') {
134
+ return uploadWestProject(sketchDir, buildTarget, port, zephyrConfig);
135
+ }
136
+ return uploadArduinoSketch(sketchDir, buildTarget, port);
137
+ }
138
+ /** Upload via arduino-cli. */
139
+ function uploadArduinoSketch(sketchDir, buildTarget, port) {
110
140
  // Hard gate: verify arduino-cli + core before spawning.
111
141
  {
112
142
  const gate = checkArduinoEnv(buildTarget);
@@ -124,6 +154,84 @@ export function uploadSketch(sketchDir, buildTarget, port) {
124
154
  };
125
155
  }
126
156
  // ---------------------------------------------------------------------------
157
+ // Zephyr (west) toolchain
158
+ // ---------------------------------------------------------------------------
159
+ /**
160
+ * Compile a Zephyr project via `west build`. The Zephyr Toolchain (from
161
+ * @typecad/framework-zephyr) handles west discovery, ZEPHYR_BASE, scaffolding,
162
+ * and the board target. We call it via dynamic import to avoid a hard
163
+ * dependency on framework-zephyr (the Arduino path doesn't need it).
164
+ */
165
+ function compileWestProject(sketchDir, buildTarget, zephyrConfig) {
166
+ // sketchDir for Zephyr is the project root containing src/, app/, build/.
167
+ // The transpiler emits src/src.cpp; the west project root is the parent of src/.
168
+ const srcDir = path.join(sketchDir, 'src');
169
+ const projectRoot = fs.existsSync(srcDir) ? sketchDir : path.dirname(sketchDir);
170
+ const sourcePath = fs.existsSync(path.join(srcDir, 'src.cpp'))
171
+ ? path.join(srcDir, 'src.cpp')
172
+ : path.join(sketchDir, 'src.cpp');
173
+ const outputDir = fs.existsSync(srcDir) ? srcDir : sketchDir;
174
+ try {
175
+ // Dynamic import — framework-zephyr is an optional dependency (only present
176
+ // for Zephyr projects). The Toolchain object has compile()/upload().
177
+ const mod = require('@typecad/framework-zephyr');
178
+ const Toolchain = mod.Toolchain;
179
+ if (!Toolchain || typeof Toolchain.compile !== 'function') {
180
+ return { success: false, sketchDir, sketchPath: '', output: '', error: '@typecad/framework-zephyr did not export a usable Toolchain.compile().' };
181
+ }
182
+ const result = Toolchain.compile({
183
+ outputDir,
184
+ sourcePath,
185
+ buildTarget,
186
+ zephyrConfig,
187
+ });
188
+ return {
189
+ success: result.success,
190
+ sketchDir: projectRoot,
191
+ sketchPath: sourcePath,
192
+ output: result.output,
193
+ error: result.success ? undefined : `west build failed:\n${result.output}`,
194
+ };
195
+ }
196
+ catch (e) {
197
+ return { success: false, sketchDir, sketchPath: '', output: '', error: `Failed to compile via west: ${e.message}` };
198
+ }
199
+ }
200
+ /**
201
+ * Upload (flash) a Zephyr project via `west flash`. For ESP32 boards, west
202
+ * uses the esptool runner; for nRF boards, nrfjprog. The port is forwarded.
203
+ */
204
+ function uploadWestProject(sketchDir, buildTarget, port, zephyrConfig) {
205
+ const srcDir = path.join(sketchDir, 'src');
206
+ const projectRoot = fs.existsSync(srcDir) ? sketchDir : path.dirname(sketchDir);
207
+ const sourcePath = fs.existsSync(path.join(srcDir, 'src.cpp'))
208
+ ? path.join(srcDir, 'src.cpp')
209
+ : path.join(sketchDir, 'src.cpp');
210
+ const outputDir = fs.existsSync(srcDir) ? srcDir : sketchDir;
211
+ try {
212
+ const mod = require('@typecad/framework-zephyr');
213
+ const Toolchain = mod.Toolchain;
214
+ if (!Toolchain || typeof Toolchain.upload !== 'function') {
215
+ return { success: false, output: '', error: '@typecad/framework-zephyr did not export a usable Toolchain.upload().' };
216
+ }
217
+ const result = Toolchain.upload({
218
+ outputDir,
219
+ sourcePath,
220
+ buildTarget,
221
+ port,
222
+ zephyrConfig,
223
+ });
224
+ return {
225
+ success: result.success,
226
+ output: result.output,
227
+ error: result.success ? undefined : `west flash failed:\n${result.output}`,
228
+ };
229
+ }
230
+ catch (e) {
231
+ return { success: false, output: '', error: `Failed to flash via west: ${e.message}` };
232
+ }
233
+ }
234
+ // ---------------------------------------------------------------------------
127
235
  // Internal
128
236
  // ---------------------------------------------------------------------------
129
237
  function resolveCuttlefishCmd(projectRoot) {
@@ -150,7 +258,7 @@ function resolveCuttlefishCmd(projectRoot) {
150
258
  // Fallback: assume it's on PATH
151
259
  return 'cuttlefish';
152
260
  }
153
- function findOutputDir(buildDir, baseName, projectRoot) {
261
+ function findOutputDir(buildDir, baseName, projectRoot, toolchainType = 'arduino-cli') {
154
262
  // The cuttlefish transpiler writes output next to the source by default,
155
263
  // or to the configured outDir. Check common locations.
156
264
  const candidates = [
@@ -162,39 +270,59 @@ function findOutputDir(buildDir, baseName, projectRoot) {
162
270
  path.join(projectRoot, '.build', 'expect', baseName, baseName),
163
271
  ];
164
272
  for (const c of candidates) {
165
- if (fs.existsSync(c) && hasInoFile(c))
273
+ if (fs.existsSync(c) && hasEntryFile(c, toolchainType))
166
274
  return c;
167
275
  }
168
- // Last resort: walk the buildDir tree recursively to find any .ino
169
- const found = findInoFileRecursive(buildDir);
276
+ // Last resort: walk the buildDir tree recursively to find any entry file
277
+ const found = findEntryFileRecursive(buildDir, toolchainType);
170
278
  if (found)
171
279
  return path.dirname(found);
172
280
  return buildDir;
173
281
  }
174
- function hasInoFile(dir) {
282
+ /** Check for a .ino (Arduino) or .cpp (Zephyr) entry file in a directory. */
283
+ function hasEntryFile(dir, toolchainType = 'arduino-cli') {
175
284
  if (!fs.existsSync(dir))
176
285
  return false;
177
- return fs.readdirSync(dir).some(f => f.endsWith('.ino'));
286
+ return fs.readdirSync(dir).some(f => isEntryFileName(f, toolchainType));
287
+ }
288
+ /** True if the filename is a valid entry file for the toolchain. */
289
+ function isEntryFileName(name, toolchainType) {
290
+ if (toolchainType === 'west') {
291
+ return name.endsWith('.cpp') || name.endsWith('.cc');
292
+ }
293
+ return name.endsWith('.ino') || name.endsWith('.cc');
178
294
  }
179
- function findInoFile(dir) {
295
+ /** Find the entry file (.ino for Arduino, .cpp for Zephyr) in a directory. */
296
+ function findEntryFile(dir, toolchainType = 'arduino-cli') {
180
297
  if (!fs.existsSync(dir))
181
298
  return undefined;
182
299
  for (const entry of fs.readdirSync(dir)) {
183
- if (entry.endsWith('.ino')) {
300
+ if (isEntryFileName(entry, toolchainType)) {
184
301
  return path.join(dir, entry);
185
302
  }
186
303
  }
304
+ // For Zephyr, the entry may be in a src/ subdirectory
305
+ if (toolchainType === 'west') {
306
+ const srcDir = path.join(dir, 'src');
307
+ if (fs.existsSync(srcDir)) {
308
+ for (const entry of fs.readdirSync(srcDir)) {
309
+ if (isEntryFileName(entry, toolchainType)) {
310
+ return path.join(srcDir, entry);
311
+ }
312
+ }
313
+ }
314
+ }
187
315
  return undefined;
188
316
  }
189
- function findInoFileRecursive(dir) {
317
+ function findEntryFileRecursive(dir, toolchainType = 'arduino-cli') {
190
318
  if (!fs.existsSync(dir))
191
319
  return undefined;
192
320
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
193
- if (entry.name.endsWith('.ino')) {
321
+ if (isEntryFileName(entry.name, toolchainType)) {
194
322
  return path.join(dir, entry.name);
195
323
  }
196
324
  if (entry.isDirectory()) {
197
- const result = findInoFileRecursive(path.join(dir, entry.name));
325
+ const result = findEntryFileRecursive(path.join(dir, entry.name), toolchainType);
198
326
  if (result)
199
327
  return result;
200
328
  }
@@ -215,8 +343,14 @@ function rewriteRelativeImports(source, originalFilePath, buildDir) {
215
343
  return `from ${quote}${relativePath}${quote}`;
216
344
  });
217
345
  }
218
- function writeBuildConfig(buildDir, projectRoot, entryFileName, buildTarget) {
219
- const baseConfigPath = path.join(projectRoot, 'cuttlefish.config.ts');
346
+ function writeBuildConfig(buildDir, projectRoot, entryFileName, buildTarget, configPath) {
347
+ // Resolve the source config: an explicit --config path wins; otherwise fall
348
+ // back to cuttlefish.config.ts in the project root. The transpile runs from
349
+ // buildDir, so we inline the scalar fields the cuttlefish transpiler needs
350
+ // (target/board/mcu/framework/buildTarget/toolchain/zephyr) into a generated
351
+ // cuttlefish.config.ts there — the config loader can't evaluate spreads or
352
+ // relative imports, and discovery from buildDir would find no config.
353
+ const baseConfigPath = configPath ?? path.join(projectRoot, 'cuttlefish.config.ts');
220
354
  const buildConfigPath = path.join(buildDir, 'cuttlefish.config.ts');
221
355
  if (fs.existsSync(baseConfigPath)) {
222
356
  // Parse base config via AST to extract scalar values, then inline them.
@@ -232,10 +366,14 @@ function writeBuildConfig(buildDir, projectRoot, entryFileName, buildTarget) {
232
366
  lines.push(` target: '${baseValues.target}',`);
233
367
  if (baseValues.board)
234
368
  lines.push(` board: '${baseValues.board}',`);
369
+ if (baseValues.mcu)
370
+ lines.push(` mcu: '${baseValues.mcu}',`);
235
371
  if (baseValues.framework)
236
372
  lines.push(` framework: '${baseValues.framework}',`);
237
373
  if (baseValues.frameworkData?.buildTarget)
238
374
  lines.push(` frameworkData: { buildTarget: '${baseValues.frameworkData.buildTarget}' },`);
375
+ if (baseValues.toolchain?.type)
376
+ lines.push(` toolchain: { type: '${baseValues.toolchain.type}' },`);
239
377
  lines.push(' output: {');
240
378
  if (baseValues.output?.framework)
241
379
  lines.push(` framework: '${baseValues.output?.framework}',`);
@@ -248,6 +386,16 @@ function writeBuildConfig(buildDir, projectRoot, entryFileName, buildTarget) {
248
386
  lines.push(` baudRate: ${baseValues.console?.baudRate},`);
249
387
  lines.push(' },');
250
388
  }
389
+ // Pass the zephyr section (kconfig, runner) through verbatim — the Zephyr
390
+ // toolchain reads runner from it (e.g. zephyr.runner: 'uf2'). Serialize the
391
+ // parsed object as a minimal object literal (string values only; sufficient
392
+ // for the scalar fields the toolchain reads).
393
+ if (baseValues.zephyr && Object.keys(baseValues.zephyr).length > 0) {
394
+ const parts = Object.entries(baseValues.zephyr)
395
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
396
+ .join(', ');
397
+ lines.push(` zephyr: { ${parts} },`);
398
+ }
251
399
  lines.push('};');
252
400
  lines.push('');
253
401
  lines.push('export default config;');
@@ -8,14 +8,19 @@ import type { ResolvedConfig, TestConfig } from './types.js';
8
8
  * @param projectRoot Absolute path to the project root.
9
9
  * @param overrides CLI flag overrides for test config.
10
10
  */
11
- export declare function loadConfig(projectRoot: string, overrides?: Partial<TestConfig>): ResolvedConfig;
11
+ export declare function loadConfig(projectRoot: string, overrides?: Partial<TestConfig>, explicitConfigPath?: string): ResolvedConfig;
12
12
  export interface RawConfig {
13
13
  target?: string;
14
14
  board?: string;
15
+ mcu?: string;
15
16
  frameworkData?: {
16
17
  buildTarget?: string;
17
18
  };
18
19
  framework?: string;
20
+ toolchain?: {
21
+ type?: string;
22
+ };
23
+ zephyr?: Record<string, unknown>;
19
24
  test?: Partial<TestConfig>;
20
25
  output?: {
21
26
  framework?: string;
@@ -29,8 +29,12 @@ const DEFAULT_TEST_CONFIG = {
29
29
  * @param projectRoot Absolute path to the project root.
30
30
  * @param overrides CLI flag overrides for test config.
31
31
  */
32
- export function loadConfig(projectRoot, overrides = {}) {
33
- const configPath = findConfigFile(projectRoot);
32
+ export function loadConfig(projectRoot, overrides = {}, explicitConfigPath) {
33
+ // An explicit --config path wins; otherwise discover cuttlefish.config.ts in
34
+ // the project root. The explicit path lets a project hold several configs
35
+ // (e.g. one per target board) and select one at run time instead of keeping
36
+ // a single cuttlefish.config.ts as the only entry.
37
+ const configPath = explicitConfigPath ?? findConfigFile(projectRoot);
34
38
  if (!configPath) {
35
39
  throw new Error(`No cuttlefish.config.ts found in ${projectRoot}. ` +
36
40
  `Create one or specify --port and --board on the command line.`);
@@ -56,7 +60,13 @@ export function loadConfig(projectRoot, overrides = {}) {
56
60
  board: test.board ?? raw.board ?? '@typecad/board-arduino-uno',
57
61
  target: raw.target ?? 'avr',
58
62
  framework: raw.framework,
63
+ toolchainType: raw.toolchain?.type === 'west' ? 'west' : 'arduino-cli',
64
+ zephyrConfig: raw.zephyr,
59
65
  projectRoot,
66
+ // The config file these values came from. writeBuildConfig re-reads it to
67
+ // extract board/MCU for the transpile, so it must point at the same file
68
+ // loadConfig parsed (not always the default cuttlefish.config.ts).
69
+ configPath,
60
70
  };
61
71
  }
62
72
  // ---------------------------------------------------------------------------
@@ -103,6 +113,10 @@ function extractConfigProperties(obj, out) {
103
113
  if (ts.isStringLiteral(prop.initializer))
104
114
  out.board = prop.initializer.text;
105
115
  break;
116
+ case 'mcu':
117
+ if (ts.isStringLiteral(prop.initializer))
118
+ out.mcu = prop.initializer.text;
119
+ break;
106
120
  case 'frameworkData':
107
121
  if (ts.isObjectLiteralExpression(prop.initializer)) {
108
122
  out.frameworkData = {};
@@ -118,6 +132,41 @@ function extractConfigProperties(obj, out) {
118
132
  if (ts.isStringLiteral(prop.initializer))
119
133
  out.framework = prop.initializer.text;
120
134
  break;
135
+ case 'toolchain':
136
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
137
+ out.toolchain = {};
138
+ for (const tProp of prop.initializer.properties) {
139
+ if (ts.isPropertyAssignment(tProp) && tProp.name.getText() === 'type' && ts.isStringLiteral(tProp.initializer)) {
140
+ out.toolchain.type = tProp.initializer.text;
141
+ }
142
+ }
143
+ }
144
+ break;
145
+ case 'zephyr':
146
+ // Parse the zephyr section (kconfig, runner, cmakeArgs) as a generic
147
+ // object so it can be passed through to the Zephyr toolchain.
148
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
149
+ out.zephyr = {};
150
+ for (const zProp of prop.initializer.properties) {
151
+ if (ts.isPropertyAssignment(zProp)) {
152
+ const key = zProp.name.getText();
153
+ if (ts.isObjectLiteralExpression(zProp.initializer)) {
154
+ // kconfig: { 'CONFIG_X': 'y' }
155
+ const sub = {};
156
+ for (const subProp of zProp.initializer.properties) {
157
+ if (ts.isPropertyAssignment(subProp) && ts.isStringLiteral(subProp.initializer)) {
158
+ sub[subProp.name.getText().replace(/['"]/g, '')] = subProp.initializer.text;
159
+ }
160
+ }
161
+ out.zephyr[key] = sub;
162
+ }
163
+ else if (ts.isStringLiteral(zProp.initializer)) {
164
+ out.zephyr[key] = zProp.initializer.text;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ break;
121
170
  case 'test':
122
171
  if (ts.isObjectLiteralExpression(prop.initializer)) {
123
172
  out.test = extractTestConfig(prop.initializer);
@@ -15,8 +15,12 @@ export interface OutputShim {
15
15
  }
16
16
  /** Default shim: Arduino HardwareSerial. */
17
17
  export declare const serialShim: OutputShim;
18
- /** AVR native UART shim: routes through framework-avr's _uart_* helpers. */
19
- export declare const avrUartShim: OutputShim;
18
+ /**
19
+ * Zephyr shim: uses overloaded __tc_print/__tc_println helpers that handle
20
+ * both string and numeric (double) output via printf. The Zephyr strategy's
21
+ * shimLines emits these helper definitions. k_msleep replaces delay().
22
+ */
23
+ export declare const zephyrShim: OutputShim;
20
24
  export interface PreprocessorOptions {
21
25
  /** Wrap string literals in Arduino F() macro to save SRAM on AVR. */
22
26
  isAvr?: boolean;
@@ -42,8 +46,7 @@ export declare class PreprocessorContext {
42
46
  readonly shim: OutputShim;
43
47
  constructor(isAvr: boolean, shim?: OutputShim);
44
48
  /** Wrap a string literal in F() on AVR to keep it in flash.
45
- * Only applies when using the serialShim (Arduino core provides F()).
46
- * The avrUartShim runs without the core, so F() is undefined — plain strings. */
49
+ * Only applies when using the serialShim (Arduino core provides F()). */
47
50
  flash(s: string): string;
48
51
  /** Emit a line of TypeScript output. */
49
52
  emit(line: string): void;
@@ -31,12 +31,16 @@ export const serialShim = {
31
31
  println: (e) => `Serial.println(${e})`,
32
32
  delay: 'delay(1000)',
33
33
  };
34
- /** AVR native UART shim: routes through framework-avr's _uart_* helpers. */
35
- export const avrUartShim = {
36
- begin: '_uart_init(115200)',
37
- print: (e) => `_uart_print_expr(${e})`,
38
- println: (e) => `_uart_println_expr(${e})`,
39
- delay: '_native_delay_ms(1000)',
34
+ /**
35
+ * Zephyr shim: uses overloaded __tc_print/__tc_println helpers that handle
36
+ * both string and numeric (double) output via printf. The Zephyr strategy's
37
+ * shimLines emits these helper definitions. k_msleep replaces delay().
38
+ */
39
+ export const zephyrShim = {
40
+ begin: '', // Zephyr console auto-initializes via DT; no explicit begin needed
41
+ print: (e) => `__tc_print(${e})`,
42
+ println: (e) => `__tc_println(${e})`,
43
+ delay: 'k_msleep(1000)',
40
44
  };
41
45
  /**
42
46
  * Preprocess a test file's TypeScript source.
@@ -79,11 +83,8 @@ export class PreprocessorContext {
79
83
  this.shim = shim;
80
84
  }
81
85
  /** Wrap a string literal in F() on AVR to keep it in flash.
82
- * Only applies when using the serialShim (Arduino core provides F()).
83
- * The avrUartShim runs without the core, so F() is undefined — plain strings. */
86
+ * Only applies when using the serialShim (Arduino core provides F()). */
84
87
  flash(s) {
85
- if (this.shim === avrUartShim)
86
- return `"${s}"`;
87
88
  return this.isAvr ? `F("${s}")` : `"${s}"`;
88
89
  }
89
90
  /** Emit a line of TypeScript output. */
@@ -82,6 +82,11 @@ function reportFile(file, verbose) {
82
82
  console.log();
83
83
  return;
84
84
  }
85
+ if (file.compiled) {
86
+ console.log(` ${PASS_ICON} ${BOLD}${file.filePath}${RESET} ${GREEN}(compiled, dry-run)${RESET}`);
87
+ console.log();
88
+ return;
89
+ }
85
90
  for (const desc of file.describes) {
86
91
  reportDescribe(desc, verbose);
87
92
  }
@@ -7,7 +7,7 @@
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, avrUartShim } from './preprocessor.js';
10
+ import { preprocess, serialShim, zephyrShim } from './preprocessor.js';
11
11
  import { transpileTestFile, compileSketch, uploadSketch } from './compiler.js';
12
12
  import { readSerialOutput } from './serial.js';
13
13
  import { parseProtocolLines } from './parser.js';
@@ -43,6 +43,12 @@ export async function run(config) {
43
43
  const result = await processTestFile(filePath, config);
44
44
  fileResults.push(result);
45
45
  reportFileResult(result, { verbose: config.test.verbose });
46
+ // --bail: stop after the first file that fails to compile/upload or has a
47
+ // failing test (skipped/compiled files don't count as failures).
48
+ if (config.bail && (result.error || (result.describes.length > 0 && !result.passed))) {
49
+ console.log(`${YELLOW}--bail: stopping after first failure${RESET}`);
50
+ break;
51
+ }
46
52
  }
47
53
  // 3. Aggregate results
48
54
  const runResult = aggregateResults(fileResults, Date.now() - startTime);
@@ -73,8 +79,8 @@ async function processTestFile(filePath, config) {
73
79
  }
74
80
  // Validate port only for files that will actually compile/upload. This lets
75
81
  // target-incompatible files be skipped without requiring hardware to be
76
- // connected.
77
- if (!config.test.port) {
82
+ // connected, and lets --dry-run run without any port (it stops after compile).
83
+ if (!config.test.port && !config.dryRun) {
78
84
  return errorResult(filePath, 'No serial port specified. Use --port <port> or set test.port in cuttlefish.config.ts', startTime);
79
85
  }
80
86
  console.log(`${CYAN}●${RESET} ${relativePath}`);
@@ -84,7 +90,7 @@ async function processTestFile(filePath, config) {
84
90
  try {
85
91
  preprocessed = preprocess(source, path.basename(filePath), {
86
92
  isAvr: config.target === 'avr' || config.target === 'megaavr',
87
- shim: config.framework === '@typecad/framework-avr' ? avrUartShim : serialShim,
93
+ shim: config.toolchainType === 'west' ? zephyrShim : serialShim,
88
94
  });
89
95
  }
90
96
  catch (e) {
@@ -92,19 +98,26 @@ async function processTestFile(filePath, config) {
92
98
  }
93
99
  // Step 2: Transpile to C++
94
100
  console.log(` ${DIM}transpiling...${RESET}`);
95
- const transpileResult = transpileTestFile(preprocessed, filePath, config.projectRoot, config.buildTarget);
101
+ const transpileResult = transpileTestFile(preprocessed, filePath, config.projectRoot, config.buildTarget, config.toolchainType, config.configPath);
96
102
  if (!transpileResult.success) {
97
103
  return errorResult(filePath, transpileResult.error ?? 'Transpilation failed', startTime);
98
104
  }
99
- // Step 3: Compile with arduino-cli
105
+ // Step 3: Compile via the configured toolchain (arduino-cli or west)
100
106
  console.log(` ${DIM}compiling...${RESET}`);
101
- const compileResult = compileSketch(transpileResult.sketchDir, config.buildTarget);
107
+ const compileResult = compileSketch(transpileResult.sketchDir, config.buildTarget, config.framework, config.toolchainType, config.zephyrConfig);
102
108
  if (!compileResult.success) {
103
109
  return errorResult(filePath, compileResult.error ?? 'Compilation failed', startTime);
104
110
  }
105
- // Step 4: Upload
111
+ // --dry-run: stop after a successful compile. Skips upload + serial read so
112
+ // the pipeline can be verified without hardware attached.
113
+ if (config.dryRun) {
114
+ const durationMs = Date.now() - startTime;
115
+ console.log(` ${DIM}compiled (dry-run, skipping upload and tests)${RESET}`);
116
+ return { filePath: relativePath, describes: [], passed: true, durationMs, debugOutput: [], compiled: true };
117
+ }
118
+ // Step 4: Upload via the configured toolchain
106
119
  console.log(` ${DIM}uploading to ${config.test.port}...${RESET}`);
107
- const uploadResult = uploadSketch(transpileResult.sketchDir, config.buildTarget, config.test.port);
120
+ const uploadResult = uploadSketch(transpileResult.sketchDir, config.buildTarget, config.test.port, config.framework, config.toolchainType, config.zephyrConfig);
108
121
  if (!uploadResult.success) {
109
122
  return errorResult(filePath, uploadResult.error ?? 'Upload failed', startTime);
110
123
  }
@@ -67,6 +67,8 @@ export interface FileResult {
67
67
  skipReason?: string;
68
68
  /** If the file didn't compile or upload. */
69
69
  error?: string;
70
+ /** True under --dry-run: the file compiled but was not uploaded/run. */
71
+ compiled?: boolean;
70
72
  }
71
73
  /**
72
74
  * Result of the entire test run.
@@ -118,6 +120,16 @@ export interface ResolvedConfig {
118
120
  target: string;
119
121
  /** Framework package name, e.g. "@typecad/framework-avr". */
120
122
  framework?: string;
123
+ /** Toolchain type from config: 'arduino-cli' (default) or 'west' (Zephyr). */
124
+ toolchainType: 'arduino-cli' | 'west';
125
+ /** Zephyr-specific config (kconfig, etc.) from cuttlefish.config.ts. */
126
+ zephyrConfig?: Record<string, unknown>;
121
127
  /** Absolute path to project root. */
122
128
  projectRoot: string;
129
+ /** Absolute path to the cuttlefish config file these values were read from. */
130
+ configPath: string;
131
+ /** --dry-run: compile every file but skip upload and test execution. */
132
+ dryRun?: boolean;
133
+ /** --bail: stop after the first failing test file. */
134
+ bail?: boolean;
123
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typecad/expect",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Hardware test framework for TypeCAD — vitest-style assertions over serial",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,10 +40,10 @@
40
40
  "dependencies": {
41
41
  "typescript": "^5.7.3",
42
42
  "serialport": "^12.0.0",
43
- "@typecad/arduino-cli": "1.0.0-alpha.6"
43
+ "@typecad/arduino-cli": "1.0.0-alpha.8"
44
44
  },
45
45
  "devDependencies": {
46
- "@typecad/cuttlefish": "1.0.0-alpha.6",
46
+ "@typecad/cuttlefish": "1.0.0-alpha.8",
47
47
  "@types/node": "^22.10.7"
48
48
  },
49
49
  "license": "MIT",