@typecad/expect 0.1.0-alpha.1

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.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // @typecad/expect — CLI entry point
4
+ //
5
+ // Usage:
6
+ // cuttlefish-test [options] [files...]
7
+ //
8
+ // Options:
9
+ // --port <port> Serial port (e.g. COM3, /dev/ttyACM0)
10
+ // --board <board> Board package override
11
+ // --build-target <id> Framework-specific build target override
12
+ // --baud <rate> Serial baud rate (default: 115200)
13
+ // --timeout <ms> Serial read timeout in ms (default: 30000)
14
+ // --include <glob> Test file glob pattern (repeatable)
15
+ // --exclude <glob> Test file glob pattern to skip (repeatable)
16
+ // --verbose Show debug serial output and assertion details
17
+ // --help Show this help
18
+ //
19
+ // If no files are given, discovers tests via config include patterns.
20
+ // ---------------------------------------------------------------------------
21
+ import { loadConfig } from './config.js';
22
+ import { run } from './runner.js';
23
+ import path from 'node:path';
24
+ function parseArgs(argv) {
25
+ const result = { files: [] };
26
+ const args = argv.slice(2); // skip node + script
27
+ for (let i = 0; i < args.length; i++) {
28
+ const arg = args[i];
29
+ switch (arg) {
30
+ case '--port':
31
+ case '-p':
32
+ result.port = args[++i];
33
+ break;
34
+ case '--board':
35
+ case '-b':
36
+ result.board = args[++i];
37
+ break;
38
+ case '--build-target':
39
+ result.buildTarget = args[++i];
40
+ break;
41
+ case '--baud': {
42
+ const val = args[++i];
43
+ const num = parseInt(val, 10);
44
+ if (isNaN(num)) {
45
+ console.error(`--baud requires a number, got "${val}"`);
46
+ process.exit(2);
47
+ }
48
+ result.baudRate = num;
49
+ break;
50
+ }
51
+ case '--timeout':
52
+ case '-t': {
53
+ const val = args[++i];
54
+ const num = parseInt(val, 10);
55
+ if (isNaN(num)) {
56
+ console.error(`--timeout requires a number, got "${val}"`);
57
+ process.exit(2);
58
+ }
59
+ result.timeout = num;
60
+ break;
61
+ }
62
+ case '--include':
63
+ case '-i':
64
+ if (!result.include)
65
+ result.include = [];
66
+ result.include.push(args[++i]);
67
+ break;
68
+ case '--exclude':
69
+ case '-x':
70
+ if (!result.exclude)
71
+ result.exclude = [];
72
+ result.exclude.push(args[++i]);
73
+ break;
74
+ case '--verbose':
75
+ case '-v':
76
+ result.verbose = true;
77
+ break;
78
+ case '--help':
79
+ case '-h':
80
+ result.help = true;
81
+ break;
82
+ default:
83
+ if (!arg.startsWith('-')) {
84
+ result.files.push(arg);
85
+ }
86
+ else {
87
+ console.error(`Unknown option: ${arg}`);
88
+ process.exit(2);
89
+ }
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Help text
96
+ // ---------------------------------------------------------------------------
97
+ const HELP = `
98
+ \x1b[1m\x1b[36m cuttlefish-test\x1b[0m — Hardware test runner for TypeCAD
99
+
100
+ \x1b[1mUsage:\x1b[0m
101
+ cuttlefish-test [options] [files...]
102
+
103
+ \x1b[1mOptions:\x1b[0m
104
+ --port, -p <port> Serial port (e.g. COM4, /dev/ttyACM0)
105
+ --board, -b <board> Board package override
106
+ --build-target <id> Framework-specific build target override
107
+ --baud <rate> Serial baud rate (default: 115200)
108
+ --timeout, -t <ms> Serial read timeout (default: 30000)
109
+ --include, -i <glob> Test file pattern (repeatable)
110
+ --exclude, -x <glob> Test file pattern to skip (repeatable)
111
+ --verbose, -v Show debug serial output
112
+ --help, -h Show this help
113
+
114
+ \x1b[1mExamples:\x1b[0m
115
+ cuttlefish-test --port COM4
116
+ cuttlefish-test --port /dev/ttyACM0 tests/my-test.test.ts
117
+ cuttlefish-test -p COM4 -v
118
+
119
+ \x1b[1mConfiguration:\x1b[0m
120
+ Add a \`test\` section to your cuttlefish.config.ts:
121
+
122
+ const config = {
123
+ board: '@typecad/board-arduino-uno',
124
+ test: {
125
+ buildTarget: 'arduino:avr:uno',
126
+ port: 'COM4',
127
+ include: ['tests/**/*.test.ts'],
128
+ baudRate: 115200,
129
+ timeout: 30000,
130
+ },
131
+ };
132
+ `.trim();
133
+ // ---------------------------------------------------------------------------
134
+ // Main
135
+ // ---------------------------------------------------------------------------
136
+ async function main() {
137
+ const args = parseArgs(process.argv);
138
+ if (args.help) {
139
+ console.log(HELP);
140
+ process.exit(0);
141
+ }
142
+ const projectRoot = process.cwd();
143
+ // Build config overrides from CLI args
144
+ const overrides = {};
145
+ if (args.port)
146
+ overrides.port = args.port;
147
+ if (args.board)
148
+ overrides.board = args.board;
149
+ if (args.buildTarget)
150
+ overrides.buildTarget = args.buildTarget;
151
+ if (args.baudRate)
152
+ overrides.baudRate = args.baudRate;
153
+ if (args.timeout)
154
+ overrides.timeout = args.timeout;
155
+ if (args.verbose)
156
+ overrides.verbose = args.verbose;
157
+ if (args.exclude)
158
+ overrides.exclude = args.exclude;
159
+ // If files are specified directly, use them as include patterns
160
+ if (args.files.length > 0) {
161
+ overrides.include = args.files.map(f => {
162
+ // Convert relative paths to be relative to project root
163
+ return path.isAbsolute(f) ? path.relative(projectRoot, f) : f;
164
+ });
165
+ }
166
+ else if (args.include) {
167
+ overrides.include = args.include;
168
+ }
169
+ // Load config
170
+ let config;
171
+ try {
172
+ config = loadConfig(projectRoot, overrides);
173
+ }
174
+ catch (e) {
175
+ console.error(`\x1b[31m${e.message}\x1b[0m`);
176
+ process.exit(2);
177
+ }
178
+ // Run tests
179
+ const exitCode = await run(config);
180
+ process.exit(exitCode);
181
+ }
182
+ main().catch((e) => {
183
+ console.error(`\x1b[31mFatal error: ${e.message}\x1b[0m`);
184
+ process.exit(2);
185
+ });
@@ -0,0 +1,27 @@
1
+ export interface CompileResult {
2
+ success: boolean;
3
+ sketchDir: string;
4
+ sketchPath: string;
5
+ output: string;
6
+ error?: string;
7
+ }
8
+ export interface UploadResult {
9
+ success: boolean;
10
+ output: string;
11
+ error?: string;
12
+ }
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.
18
+ */
19
+ export declare function transpileTestFile(preprocessedSource: string, originalFilePath: string, projectRoot: string, buildTarget: string): CompileResult;
20
+ /**
21
+ * Compile the Arduino sketch using arduino-cli.
22
+ */
23
+ export declare function compileSketch(sketchDir: string, buildTarget: string): CompileResult;
24
+ /**
25
+ * Upload the compiled sketch to the board.
26
+ */
27
+ export declare function uploadSketch(sketchDir: string, buildTarget: string, port: string): UploadResult;
@@ -0,0 +1,250 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Compiler
3
+ //
4
+ // Wraps the cuttlefish transpiler + arduino-cli compile/upload cycle.
5
+ // Takes preprocessed TypeScript source, transpiles to C++, compiles, uploads.
6
+ // ---------------------------------------------------------------------------
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { parseConfigAST } from './config.js';
11
+ /**
12
+ * Transpile preprocessed TypeScript source to a C++ Arduino sketch.
13
+ *
14
+ * Writes the preprocessed source to a temp file, invokes the cuttlefish
15
+ * transpiler, and returns the path to the generated .ino file.
16
+ */
17
+ export function transpileTestFile(preprocessedSource, originalFilePath, projectRoot, buildTarget) {
18
+ // Create a build directory for this test file
19
+ const baseName = path.basename(originalFilePath, '.test.ts').replace(/[^a-zA-Z0-9_]/g, '_');
20
+ const buildDir = path.join(projectRoot, '.build', 'expect', baseName);
21
+ try {
22
+ fs.rmSync(buildDir, { recursive: true, force: true });
23
+ fs.mkdirSync(buildDir, { recursive: true });
24
+ }
25
+ catch {
26
+ return { success: false, sketchDir: buildDir, sketchPath: '', output: '', error: `Failed to create build dir: ${buildDir}` };
27
+ }
28
+ const rewrittenSource = rewriteRelativeImports(preprocessedSource, originalFilePath, buildDir);
29
+ // Write the preprocessed source as a .ts file
30
+ const tsPath = path.join(buildDir, `${baseName}.ts`);
31
+ fs.writeFileSync(tsPath, rewrittenSource, 'utf8');
32
+ // Invoke the cuttlefish transpiler
33
+ // We call it as a CLI command rather than importing to avoid coupling
34
+ const cuttlefishCmd = resolveCuttlefishCmd(projectRoot);
35
+ const useBuildMode = hasRelativeImports(rewrittenSource);
36
+ if (useBuildMode) {
37
+ writeBuildConfig(buildDir, projectRoot, path.basename(tsPath), buildTarget);
38
+ }
39
+ const result = spawnSync(process.execPath, useBuildMode
40
+ ? [cuttlefishCmd, 'build', '--skip-type-check', '--force']
41
+ : [cuttlefishCmd, tsPath, '--skip-type-check', '--force'], {
42
+ encoding: 'utf8',
43
+ cwd: useBuildMode ? buildDir : projectRoot,
44
+ timeout: 60000,
45
+ env: { ...process.env },
46
+ });
47
+ const transpileOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
48
+ if (result.status !== 0) {
49
+ return {
50
+ success: false,
51
+ sketchDir: buildDir,
52
+ sketchPath: '',
53
+ output: transpileOutput,
54
+ error: `Transpilation failed:\n${transpileOutput}`,
55
+ };
56
+ }
57
+ // Find the generated .ino file
58
+ const outDir = findOutputDir(buildDir, baseName, projectRoot);
59
+ const inoPath = findInoFile(outDir);
60
+ if (!inoPath) {
61
+ return {
62
+ success: false,
63
+ sketchDir: outDir,
64
+ sketchPath: '',
65
+ output: transpileOutput,
66
+ error: `No .ino file found in ${outDir} after transpilation`,
67
+ };
68
+ }
69
+ return {
70
+ success: true,
71
+ sketchDir: path.dirname(inoPath),
72
+ sketchPath: inoPath,
73
+ output: transpileOutput,
74
+ };
75
+ }
76
+ /**
77
+ * Compile the Arduino sketch using arduino-cli.
78
+ */
79
+ export function compileSketch(sketchDir, buildTarget) {
80
+ const result = spawnSync('arduino-cli', ['compile', '--fqbn', buildTarget, sketchDir], { encoding: 'utf8', timeout: 120000 });
81
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
82
+ return {
83
+ success: result.status === 0,
84
+ sketchDir,
85
+ sketchPath: '',
86
+ output,
87
+ error: result.status !== 0 ? `Compilation failed:\n${output}` : undefined,
88
+ };
89
+ }
90
+ /**
91
+ * Upload the compiled sketch to the board.
92
+ */
93
+ export function uploadSketch(sketchDir, buildTarget, port) {
94
+ const result = spawnSync('arduino-cli', ['upload', '--fqbn', buildTarget, '--port', port, sketchDir], { encoding: 'utf8', timeout: 60000 });
95
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
96
+ return {
97
+ success: result.status === 0,
98
+ output,
99
+ error: result.status !== 0 ? `Upload failed:\n${output}` : undefined,
100
+ };
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Internal
104
+ // ---------------------------------------------------------------------------
105
+ function resolveCuttlefishCmd(projectRoot) {
106
+ // Try to find cuttlefish CLI in the monorepo (current dir and parent dirs).
107
+ // We prefer the JS entry point over the .bin shims because the shell wrapper
108
+ // (no extension) cannot be passed to `node process.execPath` on Windows, and
109
+ // invoking it via the shell would require platform-specific handling.
110
+ let searchDir = projectRoot;
111
+ for (let i = 0; i < 5; i++) {
112
+ const candidates = [
113
+ path.join(searchDir, 'packages', 'cuttlefish', 'dist', 'cli.js'),
114
+ path.join(searchDir, 'node_modules', '@typecad', 'cuttlefish', 'dist', 'cli.js'),
115
+ path.join(searchDir, 'node_modules', 'cuttlefish', 'dist', 'cli.js'),
116
+ ];
117
+ for (const c of candidates) {
118
+ if (fs.existsSync(c))
119
+ return c;
120
+ }
121
+ const parent = path.dirname(searchDir);
122
+ if (parent === searchDir)
123
+ break; // reached root
124
+ searchDir = parent;
125
+ }
126
+ // Fallback: assume it's on PATH
127
+ return 'cuttlefish';
128
+ }
129
+ function findOutputDir(buildDir, baseName, projectRoot) {
130
+ // The cuttlefish transpiler writes output next to the source by default,
131
+ // or to the configured outDir. Check common locations.
132
+ const candidates = [
133
+ buildDir,
134
+ path.join(buildDir, baseName),
135
+ path.join(buildDir, 'out', baseName),
136
+ path.join(buildDir, 'out'),
137
+ path.join(projectRoot, 'out'),
138
+ path.join(projectRoot, '.build', 'expect', baseName, baseName),
139
+ ];
140
+ for (const c of candidates) {
141
+ if (fs.existsSync(c) && hasInoFile(c))
142
+ return c;
143
+ }
144
+ // Last resort: walk the buildDir tree recursively to find any .ino
145
+ const found = findInoFileRecursive(buildDir);
146
+ if (found)
147
+ return path.dirname(found);
148
+ return buildDir;
149
+ }
150
+ function hasInoFile(dir) {
151
+ if (!fs.existsSync(dir))
152
+ return false;
153
+ return fs.readdirSync(dir).some(f => f.endsWith('.ino'));
154
+ }
155
+ function findInoFile(dir) {
156
+ if (!fs.existsSync(dir))
157
+ return undefined;
158
+ for (const entry of fs.readdirSync(dir)) {
159
+ if (entry.endsWith('.ino')) {
160
+ return path.join(dir, entry);
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+ function findInoFileRecursive(dir) {
166
+ if (!fs.existsSync(dir))
167
+ return undefined;
168
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
169
+ if (entry.name.endsWith('.ino')) {
170
+ return path.join(dir, entry.name);
171
+ }
172
+ if (entry.isDirectory()) {
173
+ const result = findInoFileRecursive(path.join(dir, entry.name));
174
+ if (result)
175
+ return result;
176
+ }
177
+ }
178
+ return undefined;
179
+ }
180
+ function hasRelativeImports(source) {
181
+ return /from\s+['"]\.\.?\//.test(source);
182
+ }
183
+ function rewriteRelativeImports(source, originalFilePath, buildDir) {
184
+ const originalDir = path.dirname(originalFilePath);
185
+ return source.replace(/from\s+(['"])(\.\.?\/[^'"]+)\1/g, (_match, quote, specifier) => {
186
+ const resolvedPath = path.resolve(originalDir, specifier);
187
+ let relativePath = path.relative(buildDir, resolvedPath).replace(/\\/g, '/');
188
+ if (!relativePath.startsWith('.')) {
189
+ relativePath = `./${relativePath}`;
190
+ }
191
+ return `from ${quote}${relativePath}${quote}`;
192
+ });
193
+ }
194
+ function writeBuildConfig(buildDir, projectRoot, entryFileName, buildTarget) {
195
+ const baseConfigPath = path.join(projectRoot, 'cuttlefish.config.ts');
196
+ const buildConfigPath = path.join(buildDir, 'cuttlefish.config.ts');
197
+ if (fs.existsSync(baseConfigPath)) {
198
+ // Parse base config via AST to extract scalar values, then inline them.
199
+ // This avoids spreads (...baseConfig) which the config loader cannot evaluate.
200
+ const baseValues = parseConfigAST(baseConfigPath);
201
+ const lines = [
202
+ `import type { CuttlefishConfig } from '@typecad/hal';`,
203
+ '',
204
+ 'const config: CuttlefishConfig = {',
205
+ ` entry: './${entryFileName}',`,
206
+ ];
207
+ if (baseValues.target)
208
+ lines.push(` target: '${baseValues.target}',`);
209
+ if (baseValues.board)
210
+ lines.push(` board: '${baseValues.board}',`);
211
+ if (baseValues.framework)
212
+ lines.push(` framework: '${baseValues.framework}',`);
213
+ if (baseValues.frameworkData?.buildTarget)
214
+ lines.push(` frameworkData: { buildTarget: '${baseValues.frameworkData.buildTarget}' },`);
215
+ lines.push(' output: {');
216
+ if (baseValues.output?.framework)
217
+ lines.push(` framework: '${baseValues.output?.framework}',`);
218
+ if (baseValues.output?.optimize)
219
+ lines.push(` optimize: '${baseValues.output?.optimize}',`);
220
+ lines.push(` outDir: './out',`);
221
+ lines.push(' },');
222
+ if (baseValues.console?.baudRate) {
223
+ lines.push(' console: {');
224
+ lines.push(` baudRate: ${baseValues.console?.baudRate},`);
225
+ lines.push(' },');
226
+ }
227
+ lines.push('};');
228
+ lines.push('');
229
+ lines.push('export default config;');
230
+ lines.push('');
231
+ fs.writeFileSync(buildConfigPath, lines.join('\n'), 'utf8');
232
+ return;
233
+ }
234
+ fs.writeFileSync(buildConfigPath, [
235
+ `import type { CuttlefishConfig } from '@typecad/hal';`,
236
+ '',
237
+ 'const config: CuttlefishConfig = {',
238
+ ` entry: './${entryFileName}',`,
239
+ ` target: 'avr',`,
240
+ ` frameworkData: { buildTarget: '${buildTarget}' },`,
241
+ ' output: {',
242
+ ` framework: 'arduino',`,
243
+ ` outDir: './out',`,
244
+ ' },',
245
+ '};',
246
+ '',
247
+ 'export default config;',
248
+ '',
249
+ ].join('\n'), 'utf8');
250
+ }
@@ -0,0 +1,29 @@
1
+ import type { ResolvedConfig, TestConfig } from './types.js';
2
+ /**
3
+ * Load configuration from `cuttlefish.config.ts` in the given project root.
4
+ *
5
+ * Parses the config file via the TypeScript AST (no dynamic import) to
6
+ * extract scalar properties — consistent with how the cuttlefish CLI does it.
7
+ *
8
+ * @param projectRoot Absolute path to the project root.
9
+ * @param overrides CLI flag overrides for test config.
10
+ */
11
+ export declare function loadConfig(projectRoot: string, overrides?: Partial<TestConfig>): ResolvedConfig;
12
+ export interface RawConfig {
13
+ target?: string;
14
+ board?: string;
15
+ frameworkData?: {
16
+ buildTarget?: string;
17
+ };
18
+ framework?: string;
19
+ test?: Partial<TestConfig>;
20
+ output?: {
21
+ framework?: string;
22
+ optimize?: string;
23
+ outDir?: string;
24
+ };
25
+ console?: {
26
+ baudRate?: number;
27
+ };
28
+ }
29
+ export declare function parseConfigAST(configPath: string): RawConfig;