@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.
@@ -46,6 +46,8 @@ export function loadConfig(projectRoot, overrides = {}, explicitConfigPath) {
46
46
  include: overrides.include ?? testFromFile.include ?? DEFAULT_TEST_CONFIG.include,
47
47
  exclude: overrides.exclude ?? testFromFile.exclude,
48
48
  port: overrides.port ?? testFromFile.port ?? DEFAULT_TEST_CONFIG.port,
49
+ // A CLI --port override disables USB discovery outright (explicit wins).
50
+ usb: overrides.port ? undefined : (testFromFile.usb ?? overrides.usb),
49
51
  baudRate: overrides.baudRate ?? testFromFile.baudRate ?? DEFAULT_TEST_CONFIG.baudRate,
50
52
  timeout: overrides.timeout ?? testFromFile.timeout ?? DEFAULT_TEST_CONFIG.timeout,
51
53
  serialOpenDelay: overrides.serialOpenDelay ?? testFromFile.serialOpenDelay ?? DEFAULT_TEST_CONFIG.serialOpenDelay,
@@ -56,11 +58,13 @@ export function loadConfig(projectRoot, overrides = {}, explicitConfigPath) {
56
58
  };
57
59
  return {
58
60
  test,
59
- buildTarget: test.buildTarget ?? raw.frameworkData?.buildTarget ?? 'arduino:avr:uno',
60
- board: test.board ?? raw.board ?? '@typecad/board-arduino-uno',
61
- target: raw.target ?? 'avr',
61
+ // Board: is the source of truth for west's -b target (mirrors the
62
+ // cuttlefish config-loader: frameworkData.buildTarget is the board-less
63
+ // custom-board form). An explicit test.buildTarget overrides both.
64
+ buildTarget: test.buildTarget ?? raw.board ?? raw.frameworkData?.buildTarget ?? '',
65
+ board: test.board ?? raw.board ?? 'xiao_ble/nrf52840',
66
+ target: raw.target ?? 'zephyr',
62
67
  framework: raw.framework,
63
- toolchainType: raw.toolchain?.type === 'west' ? 'west' : 'arduino-cli',
64
68
  zephyrConfig: raw.zephyr,
65
69
  projectRoot,
66
70
  // The config file these values came from. writeBuildConfig re-reads it to
@@ -266,13 +270,6 @@ function extractConfigProperties(obj, out) {
266
270
  }
267
271
  break;
268
272
  }
269
- case 'console': {
270
- const init = unwrapExpr(prop.initializer);
271
- if (ts.isObjectLiteralExpression(init)) {
272
- out.console = extractConsoleConfig(init);
273
- }
274
- break;
275
- }
276
273
  }
277
274
  }
278
275
  }
@@ -291,6 +288,24 @@ function extractTestConfig(obj) {
291
288
  result.port = v;
292
289
  break;
293
290
  }
291
+ case 'usb': {
292
+ const init = unwrapExpr(prop.initializer);
293
+ if (ts.isObjectLiteralExpression(init)) {
294
+ const sub = {};
295
+ for (const subProp of init.properties) {
296
+ if (!ts.isPropertyAssignment(subProp))
297
+ continue;
298
+ const subKey = propName(subProp);
299
+ const v = stringLikeText(subProp.initializer);
300
+ if (subKey !== undefined && v !== undefined)
301
+ sub[subKey] = v;
302
+ }
303
+ if (sub.vid && sub.pid) {
304
+ result.usb = { vid: sub.vid, pid: sub.pid, ...(sub.serial ? { serial: sub.serial } : {}) };
305
+ }
306
+ }
307
+ break;
308
+ }
294
309
  case 'baudRate': {
295
310
  const v = numericValue(prop.initializer);
296
311
  if (v !== undefined)
@@ -369,16 +384,3 @@ function extractOutputConfig(obj) {
369
384
  }
370
385
  return result;
371
386
  }
372
- function extractConsoleConfig(obj) {
373
- const result = {};
374
- for (const prop of obj.properties) {
375
- if (!ts.isPropertyAssignment(prop))
376
- continue;
377
- if (propName(prop) === 'baudRate') {
378
- const v = numericValue(prop.initializer);
379
- if (v !== undefined)
380
- result.baudRate = v;
381
- }
382
- }
383
- return result;
384
- }
@@ -2,7 +2,7 @@
2
2
  * Find all test files matching the include glob patterns.
3
3
  *
4
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
5
+ * file paths like `src/main.ts`. Only files ending in `.test.ts` are
6
6
  * returned.
7
7
  *
8
8
  * @param projectRoot Absolute path to the project root.
@@ -12,7 +12,7 @@ import fs from 'node:fs';
12
12
  * Find all test files matching the include glob patterns.
13
13
  *
14
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
15
+ * file paths like `src/main.ts`. Only files ending in `.test.ts` are
16
16
  * returned.
17
17
  *
18
18
  * @param projectRoot Absolute path to the project root.
@@ -0,0 +1,55 @@
1
+ /** A USB identity to match attached serial ports against. */
2
+ export interface UsbIdentity {
3
+ /** Vendor ID, 4 hex digits (with or without 0x, case-insensitive). */
4
+ vid: string;
5
+ /** Product ID, 4 hex digits (with or without 0x, case-insensitive). */
6
+ pid: string;
7
+ /** Optional exact serial-number match — disambiguates identical devices. */
8
+ serial?: string;
9
+ }
10
+ /** One attached USB serial port, normalized from serialport's listing. */
11
+ export interface UsbSerialPort {
12
+ path: string;
13
+ vid: string;
14
+ pid: string;
15
+ serialNumber?: string;
16
+ manufacturer?: string;
17
+ }
18
+ /** Normalize a hex id: strip 0x, lowercase, pad to 4 digits. */
19
+ export declare function normalizeHexId(value: string): string;
20
+ /** Normalize an identity's fields (returns a new object). */
21
+ export declare function normalizeUsbIdentity(identity: UsbIdentity): UsbIdentity;
22
+ /** Human-readable one-line form: `2FE3:0001 (serial DF7A…)` / `2FE3:0001`. */
23
+ export declare function formatUsbIdentity(identity: UsbIdentity): string;
24
+ /** Format the attached-port table for diagnostics (nightly logs). */
25
+ export declare function formatPortTable(ports: UsbSerialPort[]): string;
26
+ /**
27
+ * Enumerate attached USB serial ports (ports without a USB identity, such as
28
+ * legacy motherboard COM ports, are skipped). Uses a dynamic import so the
29
+ * native serialport dependency loads only when discovery runs.
30
+ */
31
+ export declare function listUsbSerialPorts(): Promise<UsbSerialPort[]>;
32
+ /**
33
+ * Pure matcher: which of `ports` match `identity`. VID/PID must match; when
34
+ * the identity carries a serial, it must match exactly (the disambiguator
35
+ * for several identical bridge chips).
36
+ */
37
+ export declare function matchUsbPorts(ports: UsbSerialPort[], identity: UsbIdentity): UsbSerialPort[];
38
+ export interface ResolveResult {
39
+ /** Resolved port path on success. */
40
+ port?: string;
41
+ /** Failure reason when no port could be resolved. */
42
+ error?: string;
43
+ }
44
+ /**
45
+ * Resolve the port for an identity: exactly one match, or an error naming
46
+ * the problem AND the full attached-port table (a nightly log should show
47
+ * what IS connected, not just that something is missing).
48
+ */
49
+ export declare function resolveUsbPort(identity: UsbIdentity): Promise<ResolveResult>;
50
+ /**
51
+ * Wait for a port matching the identity to (re-)appear — CDC consoles
52
+ * re-enumerate after a flash and may come back under a different COM
53
+ * number. Polls after an initial settle delay; returns undefined on timeout.
54
+ */
55
+ export declare function waitForUsbPort(identity: UsbIdentity, settleMs: number, pollMs?: number, maxWaitMs?: number): Promise<string | undefined>;
@@ -0,0 +1,122 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — USB identity port discovery
3
+ //
4
+ // Multi-board rigs (e.g. a nightly test box) cannot track COM/tty numbers:
5
+ // they reshuffle on every replug and every CDC re-enumeration after a flash.
6
+ // Boards are instead identified by their USB VID/PID (+ optional serial
7
+ // number), declared per board in test-pins.json (`usb: { vid, pid, serial? }`)
8
+ // or directly in the config's `test.usb`. Zephyr CDC consoles all enumerate
9
+ // at the Zephyr-test default identity 0x2FE3:0001 (per-board PIDs are no
10
+ // longer assigned), so vid/pid alone cannot distinguish several attached CDC
11
+ // boards — run one CDC board at a time, or disambiguate via `serial` when the
12
+ // descriptor provides one.
13
+ //
14
+ // UART-bridge boards identify by their bridge chip instead: the Uno's 16U2
15
+ // (2341:0043, CH340 clones report 1A86:7523) and the ESP32 DevKitC's CP2102
16
+ // (10C4:EA60). Those bridges report real unique serial numbers, so several
17
+ // identical devkits disambiguate via `serial`.
18
+ // ---------------------------------------------------------------------------
19
+ /** Normalize a hex id: strip 0x, lowercase, pad to 4 digits. */
20
+ export function normalizeHexId(value) {
21
+ const stripped = value.trim().toLowerCase().replace(/^0x/, '');
22
+ return stripped.padStart(4, '0');
23
+ }
24
+ /** Normalize an identity's fields (returns a new object). */
25
+ export function normalizeUsbIdentity(identity) {
26
+ return {
27
+ vid: normalizeHexId(identity.vid),
28
+ pid: normalizeHexId(identity.pid),
29
+ ...(identity.serial ? { serial: identity.serial } : {}),
30
+ };
31
+ }
32
+ /** Human-readable one-line form: `2FE3:0001 (serial DF7A…)` / `2FE3:0001`. */
33
+ export function formatUsbIdentity(identity) {
34
+ const n = normalizeUsbIdentity(identity);
35
+ return `${n.vid.toUpperCase()}:${n.pid.toUpperCase()}${n.serial ? ` serial ${n.serial}` : ''}`;
36
+ }
37
+ /** Format the attached-port table for diagnostics (nightly logs). */
38
+ export function formatPortTable(ports) {
39
+ if (ports.length === 0)
40
+ return ' (no USB serial ports found)';
41
+ const rows = ports.map((p) => {
42
+ const id = `${p.vid.toUpperCase()}:${p.pid.toUpperCase()}`;
43
+ const serial = p.serialNumber ? ` serial ${p.serialNumber}` : '';
44
+ const mfr = p.manufacturer ? ` [${p.manufacturer}]` : '';
45
+ return ` ${p.path} ${id}${serial}${mfr}`;
46
+ });
47
+ return rows.join('\n');
48
+ }
49
+ /**
50
+ * Enumerate attached USB serial ports (ports without a USB identity, such as
51
+ * legacy motherboard COM ports, are skipped). Uses a dynamic import so the
52
+ * native serialport dependency loads only when discovery runs.
53
+ */
54
+ export async function listUsbSerialPorts() {
55
+ const { SerialPort } = await import('serialport');
56
+ const ports = await SerialPort.list();
57
+ const result = [];
58
+ for (const p of ports) {
59
+ if (!p.vendorId || !p.productId || !p.path)
60
+ continue;
61
+ result.push({
62
+ path: p.path,
63
+ vid: normalizeHexId(p.vendorId),
64
+ pid: normalizeHexId(p.productId),
65
+ ...(p.serialNumber ? { serialNumber: p.serialNumber } : {}),
66
+ ...(p.manufacturer ? { manufacturer: p.manufacturer } : {}),
67
+ });
68
+ }
69
+ return result;
70
+ }
71
+ /**
72
+ * Pure matcher: which of `ports` match `identity`. VID/PID must match; when
73
+ * the identity carries a serial, it must match exactly (the disambiguator
74
+ * for several identical bridge chips).
75
+ */
76
+ export function matchUsbPorts(ports, identity) {
77
+ const n = normalizeUsbIdentity(identity);
78
+ return ports.filter((p) => p.vid === n.vid
79
+ && p.pid === n.pid
80
+ && (n.serial === undefined || p.serialNumber === n.serial));
81
+ }
82
+ /**
83
+ * Resolve the port for an identity: exactly one match, or an error naming
84
+ * the problem AND the full attached-port table (a nightly log should show
85
+ * what IS connected, not just that something is missing).
86
+ */
87
+ export async function resolveUsbPort(identity) {
88
+ const ports = await listUsbSerialPorts();
89
+ const matches = matchUsbPorts(ports, identity);
90
+ if (matches.length === 1)
91
+ return { port: matches[0].path };
92
+ const wanted = formatUsbIdentity(identity);
93
+ if (matches.length === 0) {
94
+ return {
95
+ error: `No USB serial port matches ${wanted}. Attached ports:\n${formatPortTable(ports)}`,
96
+ };
97
+ }
98
+ return {
99
+ error: `Multiple ports match ${wanted} — add a serial number to disambiguate. Matches:\n${formatPortTable(matches)}\nAll attached ports:\n${formatPortTable(ports)}`,
100
+ };
101
+ }
102
+ /**
103
+ * Wait for a port matching the identity to (re-)appear — CDC consoles
104
+ * re-enumerate after a flash and may come back under a different COM
105
+ * number. Polls after an initial settle delay; returns undefined on timeout.
106
+ */
107
+ export async function waitForUsbPort(identity, settleMs, pollMs = 500, maxWaitMs = 10_000) {
108
+ if (settleMs > 0)
109
+ await sleep(settleMs);
110
+ const deadline = Date.now() + Math.max(maxWaitMs - settleMs, 0);
111
+ for (;;) {
112
+ const result = await resolveUsbPort(identity);
113
+ if (result.port)
114
+ return result.port;
115
+ if (Date.now() >= deadline)
116
+ return undefined;
117
+ await sleep(pollMs);
118
+ }
119
+ }
120
+ function sleep(ms) {
121
+ return new Promise((resolve) => setTimeout(resolve, ms));
122
+ }
@@ -1,40 +1,49 @@
1
+ import type { TestPinsSubstitutions } from './test-pins.js';
1
2
  /**
2
3
  * Describes how the test protocol emits output for a given framework.
3
4
  * Each framework provides its own shim so the preprocessor doesn't hardcode
4
- * Serial.* (which would force the Arduino core to be linked).
5
+ * a specific console API.
5
6
  */
6
7
  export interface OutputShim {
7
- /** The init call emitted in the preamble, e.g. "Serial.begin(115200)". */
8
+ /** The init call emitted in the preamble, e.g. "Serial.begin(115200)". Empty when the console self-initializes. */
8
9
  begin: string;
9
10
  /** Print without newline — receives a fully-formed argument expression. */
10
11
  print: (expr: string) => string;
11
12
  /** Print with newline — receives a fully-formed argument expression. */
12
13
  println: (expr: string) => string;
13
- /** The idle-loop delay call after SUITE_END, e.g. "delay(1000)". */
14
+ /** The idle-loop delay call after SUITE_END, e.g. "k_msleep(1000)". */
14
15
  delay: string;
15
16
  }
16
- /** Default shim: Arduino HardwareSerial. */
17
- export declare const serialShim: OutputShim;
18
17
  /**
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().
18
+ * Zephyr shim (the default): uses overloaded __tc_print/__tc_println helpers
19
+ * that handle both string and numeric (double) output via printf. The Zephyr
20
+ * strategy's shimLines emits these helper definitions. k_msleep replaces
21
+ * delay().
22
22
  */
23
23
  export declare const zephyrShim: OutputShim;
24
24
  export interface PreprocessorOptions {
25
- /** Wrap string literals in Arduino F() macro to save SRAM on AVR. */
26
- isAvr?: boolean;
27
- /** Output shim — defaults to serialShim (Arduino HardwareSerial). */
25
+ /** Output shim defaults to the Zephyr __tc_print helpers. */
28
26
  shim?: OutputShim;
27
+ /**
28
+ * Board test-pins substitutions (role const -> replacement text). When
29
+ * present, every role identifier in the source is replaced with the
30
+ * board's real pin symbol (or numeric fact), and a synthesized
31
+ * `import { <used pins> } from '@typecad/board'` is prepended. The
32
+ * '@typecad/test-pins' import itself is stripped — the transpiler never
33
+ * sees the virtual specifier.
34
+ */
35
+ testPins?: TestPinsSubstitutions;
29
36
  }
30
37
  /**
31
38
  * Preprocess a test file's TypeScript source.
32
39
  *
33
- * 1. Strips `import { ... } from '@typecad/expect'`
40
+ * 1. Strips `import { ... } from '@typecad/expect'` and
41
+ * `import { ... } from '@typecad/test-pins'`
34
42
  * 2. Walks top-level expression-statements looking for `describe(...)...` chains
35
43
  * 3. Replaces `done()` with the suite-end sentinel + idle loop
36
44
  * 4. Hoists hardware expressions out of `expect()` into `const` declarations
37
- * 5. Wraps everything with Serial.initialize + SUITE_START preamble
45
+ * 5. Wraps everything with the console init + SUITE_START preamble
46
+ * 6. Substitutes test-pin role identifiers with the board's real pin symbols
38
47
  */
39
48
  export declare function preprocess(source: string, fileName?: string, options?: PreprocessorOptions): string;
40
49
  export declare class PreprocessorContext {
@@ -42,18 +51,28 @@ export declare class PreprocessorContext {
42
51
  private varCounter;
43
52
  private fnCounter;
44
53
  private preambleEmitted;
45
- readonly isAvr: boolean;
46
54
  readonly shim: OutputShim;
47
- constructor(isAvr: boolean, shim?: OutputShim);
48
- /** Wrap a string literal in F() on AVR to keep it in flash.
49
- * Only applies when using the serialShim (Arduino core provides F()). */
50
- flash(s: string): string;
55
+ /** Role const -> replacement text (board pin symbols / numeric facts). */
56
+ private readonly substitutions?;
57
+ /** Pin symbols seen in substitutions that actually replaced something. */
58
+ private readonly usedPins;
59
+ constructor(shim?: OutputShim, substitutions?: TestPinsSubstitutions);
60
+ /** Quote a protocol string literal for the active shim. */
61
+ quote(s: string): string;
51
62
  /** Emit a line of TypeScript output. */
52
63
  emit(line: string): void;
53
64
  /** Generate a unique temporary variable name. */
54
65
  nextVar(): string;
55
66
  /** Generate a unique extracted function name. */
56
67
  nextFn(): string;
68
+ /**
69
+ * Replace whole-word role identifiers with their board-specific text.
70
+ * Role const names are SCREAMING_SNAKE and never appear in protocol
71
+ * strings, so a word-boundary replace over the emitted statement text is
72
+ * safe. Pin symbols referenced by a used replacement are recorded so the
73
+ * synthesized board import covers them.
74
+ */
75
+ private substitute;
57
76
  private emitPreamble;
58
77
  build(): string;
59
78
  }
@@ -10,31 +10,24 @@
10
10
  // done();
11
11
  //
12
12
  // OUTPUT (preprocessed — fed to transpiler):
13
- // Serial.begin(115200);
14
- // Serial.println("[TC:SUITE_START]");
15
- // Serial.println("[TC:DESCRIBE:A0 analog read]");
16
- // Serial.println("[TC:IT:reads zero]");
13
+ // __tc_println("[TC:SUITE_START]");
14
+ // __tc_println("[TC:DESCRIBE:A0 analog read]");
15
+ // __tc_println("[TC:IT:reads zero]");
17
16
  // const __tc_v1: number = A0.readAnalog();
18
- // Serial.print("[TC:EXPECT:toBe:0:");
19
- // Serial.print(__tc_v1);
20
- // Serial.println("]");
21
- // Serial.println("[TC:SUITE_END]");
22
- // while (true) { delay(1000); }
17
+ // __tc_print("[TC:EXPECT:toBe:0:");
18
+ // __tc_print(__tc_v1);
19
+ // __tc_println("]");
20
+ // __tc_println("[TC:SUITE_END]");
21
+ // while (true) { k_msleep(1000); }
23
22
  // ---------------------------------------------------------------------------
24
23
  import ts from 'typescript';
25
24
  import { isDescribeChain, collectChainSegments } from './chain-collector.js';
26
25
  import { emitSegments } from './protocol-emitter.js';
27
- /** Default shim: Arduino HardwareSerial. */
28
- export const serialShim = {
29
- begin: 'Serial.begin(115200)',
30
- print: (e) => `Serial.print(${e})`,
31
- println: (e) => `Serial.println(${e})`,
32
- delay: 'delay(1000)',
33
- };
34
26
  /**
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().
27
+ * Zephyr shim (the default): uses overloaded __tc_print/__tc_println helpers
28
+ * that handle both string and numeric (double) output via printf. The Zephyr
29
+ * strategy's shimLines emits these helper definitions. k_msleep replaces
30
+ * delay().
38
31
  */
39
32
  export const zephyrShim = {
40
33
  begin: '', // Zephyr console auto-initializes via DT; no explicit begin needed
@@ -45,20 +38,24 @@ export const zephyrShim = {
45
38
  /**
46
39
  * Preprocess a test file's TypeScript source.
47
40
  *
48
- * 1. Strips `import { ... } from '@typecad/expect'`
41
+ * 1. Strips `import { ... } from '@typecad/expect'` and
42
+ * `import { ... } from '@typecad/test-pins'`
49
43
  * 2. Walks top-level expression-statements looking for `describe(...)...` chains
50
44
  * 3. Replaces `done()` with the suite-end sentinel + idle loop
51
45
  * 4. Hoists hardware expressions out of `expect()` into `const` declarations
52
- * 5. Wraps everything with Serial.initialize + SUITE_START preamble
46
+ * 5. Wraps everything with the console init + SUITE_START preamble
47
+ * 6. Substitutes test-pin role identifiers with the board's real pin symbols
53
48
  */
54
49
  export function preprocess(source, fileName = 'test.ts', options) {
55
50
  const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
56
- const ctx = new PreprocessorContext(options?.isAvr ?? false, options?.shim);
51
+ const ctx = new PreprocessorContext(options?.shim, options?.testPins);
57
52
  for (const stmt of sf.statements) {
58
53
  if (ts.isImportDeclaration(stmt)) {
59
54
  const moduleSpecifier = stmt.moduleSpecifier.text;
60
55
  if (moduleSpecifier === '@typecad/expect')
61
56
  continue; // strip
57
+ if (moduleSpecifier === '@typecad/test-pins')
58
+ continue; // stripped; roles are substituted inline
62
59
  ctx.emit(stmt.getText(sf));
63
60
  }
64
61
  else if (ts.isExpressionStatement(stmt)) {
@@ -74,24 +71,25 @@ export function preprocess(source, fileName = 'test.ts', options) {
74
71
  // PreprocessorContext — shared state threaded through sub-modules
75
72
  // ---------------------------------------------------------------------------
76
73
  export class PreprocessorContext {
77
- constructor(isAvr, shim = serialShim) {
74
+ constructor(shim = zephyrShim, substitutions) {
78
75
  this.lines = [];
79
76
  this.varCounter = 0;
80
77
  this.fnCounter = 0;
81
78
  this.preambleEmitted = false;
82
- this.isAvr = isAvr;
79
+ /** Pin symbols seen in substitutions that actually replaced something. */
80
+ this.usedPins = new Set();
83
81
  this.shim = shim;
82
+ this.substitutions = substitutions;
84
83
  }
85
- /** Wrap a string literal in F() on AVR to keep it in flash.
86
- * Only applies when using the serialShim (Arduino core provides F()). */
87
- flash(s) {
88
- return this.isAvr ? `F("${s}")` : `"${s}"`;
84
+ /** Quote a protocol string literal for the active shim. */
85
+ quote(s) {
86
+ return `"${s}"`;
89
87
  }
90
88
  /** Emit a line of TypeScript output. */
91
89
  emit(line) {
92
90
  if (!this.preambleEmitted)
93
91
  this.emitPreamble();
94
- this.lines.push(line);
92
+ this.lines.push(this.substitute(line));
95
93
  }
96
94
  /** Generate a unique temporary variable name. */
97
95
  nextVar() {
@@ -101,13 +99,45 @@ export class PreprocessorContext {
101
99
  nextFn() {
102
100
  return `__tc_fn${++this.fnCounter}`;
103
101
  }
102
+ /**
103
+ * Replace whole-word role identifiers with their board-specific text.
104
+ * Role const names are SCREAMING_SNAKE and never appear in protocol
105
+ * strings, so a word-boundary replace over the emitted statement text is
106
+ * safe. Pin symbols referenced by a used replacement are recorded so the
107
+ * synthesized board import covers them.
108
+ */
109
+ substitute(line) {
110
+ if (!this.substitutions || this.substitutions.size === 0)
111
+ return line;
112
+ let result = line;
113
+ for (const [role, replacement] of this.substitutions) {
114
+ const pattern = new RegExp(`\\b${role}\\b`, 'g');
115
+ if (!pattern.test(result))
116
+ continue;
117
+ result = result.replace(pattern, replacement);
118
+ for (const pin of replacement.matchAll(/[A-Za-z_$][\w$]*/g)) {
119
+ if (!/^\d/.test(pin[0]))
120
+ this.usedPins.add(pin[0]);
121
+ }
122
+ }
123
+ return result;
124
+ }
104
125
  emitPreamble() {
105
126
  this.preambleEmitted = true;
106
- this.lines.push(`${this.shim.begin};`);
107
- this.lines.push(`${this.shim.println(this.flash('[TC:SUITE_START]'))};`);
127
+ // Shims whose console self-initializes (Zephyr DT) have an empty begin.
128
+ if (this.shim.begin)
129
+ this.lines.push(`${this.shim.begin};`);
130
+ this.lines.push(`${this.shim.println(this.quote('[TC:SUITE_START]'))};`);
108
131
  }
109
132
  build() {
110
- return this.lines.join('\n') + '\n';
133
+ const parts = [];
134
+ // Synthesized import for the substituted pins — must be a top-level
135
+ // statement; placed first so the (untype-checked) source reads naturally.
136
+ if (this.usedPins.size > 0) {
137
+ parts.push(`import { ${[...this.usedPins].join(', ')} } from '@typecad/board';`);
138
+ }
139
+ parts.push(...this.lines);
140
+ return parts.join('\n') + '\n';
111
141
  }
112
142
  }
113
143
  // ---------------------------------------------------------------------------
@@ -116,7 +146,7 @@ export class PreprocessorContext {
116
146
  function processExpressionStatement(stmt, sf, ctx) {
117
147
  const expr = stmt.expression;
118
148
  if (isDoneCall(expr)) {
119
- ctx.emit(`${ctx.shim.println(ctx.flash('[TC:SUITE_END]'))};`);
149
+ ctx.emit(`${ctx.shim.println(ctx.quote('[TC:SUITE_END]'))};`);
120
150
  ctx.emit(`while (true) { ${ctx.shim.delay}; }`);
121
151
  return;
122
152
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Resolve the project root for a run.
3
+ *
4
+ * @param env The environment to consult (defaults to `process.env`).
5
+ * @param cwd The fallback directory (defaults to `process.cwd()`).
6
+ * @returns INIT_CWD when it names an existing directory, else cwd.
7
+ */
8
+ export declare function resolveProjectRoot(env?: NodeJS.ProcessEnv, cwd?: string): string;
@@ -0,0 +1,24 @@
1
+ // ---------------------------------------------------------------------------
2
+ // project-root.ts — resolve the project directory the test runner targets.
3
+ //
4
+ // `npx cuttlefish-test` (npm exec) resets the child process's cwd to the npm
5
+ // local prefix — the NEAREST package.json ancestor of the invocation dir.
6
+ // From a nested suite dir (e.g. packages/framework-zephyr/hal/esp32s3, which
7
+ // has no package.json of its own) that lands on the workspace package instead
8
+ // of the project under test, and file discovery finds nothing. npm stashes
9
+ // the real invocation directory in INIT_CWD (also set by `npm run` to the
10
+ // script's package dir, which is likewise the intended root) — prefer it when
11
+ // it names an existing directory.
12
+ // ---------------------------------------------------------------------------
13
+ import fs from 'node:fs';
14
+ /**
15
+ * Resolve the project root for a run.
16
+ *
17
+ * @param env The environment to consult (defaults to `process.env`).
18
+ * @param cwd The fallback directory (defaults to `process.cwd()`).
19
+ * @returns INIT_CWD when it names an existing directory, else cwd.
20
+ */
21
+ export function resolveProjectRoot(env = process.env, cwd = process.cwd()) {
22
+ const initCwd = env.INIT_CWD;
23
+ return initCwd !== undefined && initCwd !== '' && fs.existsSync(initCwd) ? initCwd : cwd;
24
+ }
@@ -22,10 +22,10 @@ export function emitSegments(segments, ctx) {
22
22
  for (const seg of segments) {
23
23
  switch (seg.kind) {
24
24
  case 'describe':
25
- ctx.emit(`${ctx.shim.println(ctx.flash(`[TC:DESCRIBE:${escapeProtocol(seg.name ?? '')}]`))};`);
25
+ ctx.emit(`${ctx.shim.println(ctx.quote(`[TC:DESCRIBE:${escapeProtocol(seg.name ?? '')}]`))};`);
26
26
  break;
27
27
  case 'it':
28
- ctx.emit(`${ctx.shim.println(ctx.flash(`[TC:IT:${escapeProtocol(seg.name ?? '')}]`))};`);
28
+ ctx.emit(`${ctx.shim.println(ctx.quote(`[TC:IT:${escapeProtocol(seg.name ?? '')}]`))};`);
29
29
  break;
30
30
  case 'expect': {
31
31
  if (!seg.matcher)
@@ -67,17 +67,17 @@ export function emitSegments(segments, ctx) {
67
67
  export function emitExpectProtocol(actualVar, matcher, matcherArgs, ctx, isString = false) {
68
68
  if (isString && matcher === 'toBe') {
69
69
  const rawExpected = (matcherArgs[0] ?? '').replace(/^["']|["']$/g, '');
70
- ctx.emit(`${ctx.shim.print(ctx.flash(`[TC:EXPECT:${matcher}:`))};`);
70
+ ctx.emit(`${ctx.shim.print(ctx.quote(`[TC:EXPECT:${matcher}:`))};`);
71
71
  ctx.emit(`${ctx.shim.print(`"${escapeProtocol(rawExpected)}"`)};`);
72
- ctx.emit(`${ctx.shim.print(ctx.flash(':'))};`);
72
+ ctx.emit(`${ctx.shim.print(ctx.quote(':'))};`);
73
73
  ctx.emit(`${ctx.shim.print(actualVar)};`);
74
- ctx.emit(`${ctx.shim.println(ctx.flash(']'))};`);
74
+ ctx.emit(`${ctx.shim.println(ctx.quote(']'))};`);
75
75
  return;
76
76
  }
77
77
  const expectedPart = matcherArgs.join(',');
78
- ctx.emit(`${ctx.shim.print(ctx.flash(`[TC:EXPECT:${matcher}:${expectedPart}:`))};`);
78
+ ctx.emit(`${ctx.shim.print(ctx.quote(`[TC:EXPECT:${matcher}:${expectedPart}:`))};`);
79
79
  ctx.emit(`${ctx.shim.print(actualVar)};`);
80
- ctx.emit(`${ctx.shim.println(ctx.flash(']'))};`);
80
+ ctx.emit(`${ctx.shim.println(ctx.quote(']'))};`);
81
81
  }
82
82
  // ---------------------------------------------------------------------------
83
83
  // Expression classification helpers
@@ -21,7 +21,7 @@
21
21
  // Expected: 20–25
22
22
  //
23
23
  // Tests 3 passed | 1 failed (4)
24
- // Board Arduino Uno @ COM3
24
+ // Board Black Pill @ COM3
25
25
  // Time 8.42s
26
26
  // ---------------------------------------------------------------------------
27
27
  import { describeExpected } from './evaluator.js';
@@ -1,7 +1,2 @@
1
1
  import type { ResolvedConfig } from './types.js';
2
- /**
3
- * Run all test files matching the configuration.
4
- *
5
- * @returns Exit code: 0 = all passed, 1 = failures, 2 = error.
6
- */
7
2
  export declare function run(config: ResolvedConfig): Promise<number>;