@typecad/expect 1.0.0-alpha.13 → 1.0.0-alpha.15
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 +7 -8
package/dist/host/config.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
}
|
package/dist/host/finder.d.ts
CHANGED
|
@@ -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/
|
|
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.
|
package/dist/host/finder.js
CHANGED
|
@@ -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/
|
|
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
|
-
*
|
|
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. "
|
|
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
|
|
20
|
-
* both string and numeric (double) output via printf. The Zephyr
|
|
21
|
-
* shimLines emits these helper definitions. k_msleep replaces
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// while (true) {
|
|
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
|
|
36
|
-
* both string and numeric (double) output via printf. The Zephyr
|
|
37
|
-
* shimLines emits these helper definitions. k_msleep replaces
|
|
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
|
|
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?.
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
72
|
+
ctx.emit(`${ctx.shim.print(ctx.quote(':'))};`);
|
|
73
73
|
ctx.emit(`${ctx.shim.print(actualVar)};`);
|
|
74
|
-
ctx.emit(`${ctx.shim.println(ctx.
|
|
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.
|
|
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.
|
|
80
|
+
ctx.emit(`${ctx.shim.println(ctx.quote(']'))};`);
|
|
81
81
|
}
|
|
82
82
|
// ---------------------------------------------------------------------------
|
|
83
83
|
// Expression classification helpers
|
package/dist/host/reporter.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// Expected: 20–25
|
|
22
22
|
//
|
|
23
23
|
// Tests 3 passed | 1 failed (4)
|
|
24
|
-
// Board
|
|
24
|
+
// Board Black Pill @ COM3
|
|
25
25
|
// Time 8.42s
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
import { describeExpected } from './evaluator.js';
|
package/dist/host/runner.d.ts
CHANGED