wyvrnpm 2.16.1 → 2.17.0

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 CHANGED
@@ -39,12 +39,6 @@ npm install -g wyvrnpm
39
39
 
40
40
  Requires Node.js >= 18.
41
41
 
42
- For S3 publishing, also install the AWS SDK:
43
-
44
- ```bash
45
- npm install -g @aws-sdk/client-s3 @aws-sdk/credential-providers
46
- ```
47
-
48
42
  ### Update notifier
49
43
 
50
44
  When a newer `wyvrnpm` is published to npm, the next interactive run prints a single-line banner on stderr:
@@ -391,15 +385,17 @@ After download, `install` also generates:
391
385
 
392
386
  The optional helper `WYVRN_APPLY_ARCH_SUFFIX(<target> [BASE_NAME <name>])` from the bundled `macros.cmake` applies the suffix to a target's `OUTPUT_NAME` so x64 and x86 builds of the same library coexist on disk without overwriting each other. Target linkage (`target_link_libraries`) is unaffected — only the produced filename changes.
393
387
 
394
- The bundled `variables.cmake` also suffixes the consumer's own output directories with `WYVRN_ARCH_SUFFIX` so multi-arch builds from one source tree don't clobber each other:
388
+ The bundled `variables.cmake` puts compiled artefacts inside the CMake binary dir so `wyvrnpm clean --all` (which sweeps `<binaryDirRoot>/wyvrn-*/`) catches them:
395
389
 
396
- | Profile arch | Archive/library dir | Runtime (DLL/EXE) dir |
397
- |---|---|---|
398
- | `x86_64` / `armv8` | `output/lib64/` | `output/bin64/<CONFIG>/` |
399
- | `x86` / `armv7` | `output/lib/` | `output/bin/<CONFIG>/` |
400
- | unmapped | `output/lib/` | `output/bin/<CONFIG>/` |
390
+ | Variable | Value |
391
+ |---|---|
392
+ | `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` | `${CMAKE_BINARY_DIR}/lib/` |
393
+ | `CMAKE_LIBRARY_OUTPUT_DIRECTORY` | `${CMAKE_BINARY_DIR}/lib/` |
394
+ | `CMAKE_RUNTIME_OUTPUT_DIRECTORY` | `${CMAKE_BINARY_DIR}/bin` |
395
+
396
+ With per-profile build dirs (`build/wyvrn-x64-msvc/` vs `build/wyvrn-x86-msvc/`) already isolating multi-arch builds, the output directories themselves don't carry an arch suffix — but `WYVRN_APPLY_ARCH_SUFFIX` still stamps the suffix onto artefact **names** (`String64.lib` vs `String32.lib`) for the cross-arch ship-together case.
401
397
 
402
- Prior to `2.5.0` the runtime directory was always `output/bin/<CONFIG>/` regardless of arch a multi-arch consumer building x64 then x86 would silently overwrite the first arch's DLLs. Scripts that hard-code `output/bin/…` expecting x64 payloads need to switch to `output/bin64/…` after upgrading.
398
+ Prior to `2.17.0` outputs landed in `${CMAKE_SOURCE_DIR}/output/{bin,lib}${WYVRN_ARCH_SUFFIX}/`outside the build dir, so `wyvrnpm clean --all` left binaries behind. Scripts that hard-code `output/bin64/foo.exe` must switch to `<binaryDir>/bin/<CONFIG>/foo.exe` (multi-config generators) or `<binaryDir>/bin/foo.exe` (single-config). Run `wyvrnpm clean --all` after upgrade — it sweeps the legacy `output/` tree as a migration aid.
403
399
 
404
400
  ```cmake
405
401
  project(String CXX)
package/bin/wyvrnpm.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  'use strict';
4
4
 
@@ -16,6 +16,7 @@ const publish = require('../src/commands/publish');
16
16
  const configure = require('../src/commands/configure');
17
17
  const profile = require('../src/commands/profile');
18
18
  const build = require('../src/commands/build');
19
+ const test = require('../src/commands/test');
19
20
  const show = require('../src/commands/show');
20
21
  const installSkill = require('../src/commands/install-skill');
21
22
  const cache = require('../src/commands/cache');
@@ -435,6 +436,110 @@ yargs
435
436
  }),
436
437
  )
437
438
 
439
+ // ── test ──────────────────────────────────────────────────────────────────
440
+ .command(
441
+ 'test',
442
+ 'Run ctest against the wyvrnpm-generated test preset. ' +
443
+ 'Auto-runs `wyvrnpm build` if the toolchain is missing or stale (disable with --no-build).',
444
+ (y) => {
445
+ y
446
+ .option('preset', {
447
+ alias: 'P',
448
+ type: 'string',
449
+ description: 'CMake test preset name (default: wyvrn-<profile>-<config>)',
450
+ })
451
+ .option('profile', {
452
+ alias: 'p',
453
+ type: 'string',
454
+ description: 'Build profile name — determines the default preset',
455
+ })
456
+ .option('config', {
457
+ alias: 'c',
458
+ type: 'string',
459
+ default: 'Release',
460
+ description:
461
+ 'Build configuration to test. Accepts a single value (Release) ' +
462
+ 'or a comma-separated list (Debug,Release) to run ctest per ' +
463
+ 'config. Valid: Debug | Release | RelWithDebInfo | MinSizeRel.',
464
+ })
465
+ .option('all-configs', {
466
+ type: 'boolean',
467
+ default: false,
468
+ description:
469
+ 'Run tests for every config declared in the recipe\'s build.configs ' +
470
+ '(falls back to all four canonical configs when no recipe is present). ' +
471
+ 'Equivalent to spelling out --config Debug,Release,... by hand.',
472
+ })
473
+ .option('verbose', {
474
+ alias: 'v',
475
+ type: 'boolean',
476
+ default: false,
477
+ description: 'Pass -V to ctest (extra detail per test)',
478
+ })
479
+ .option('tests-regex', {
480
+ alias: 'R',
481
+ type: 'string',
482
+ description: 'Run only tests whose name matches the regex (forwards to ctest -R)',
483
+ })
484
+ .option('label-regex', {
485
+ alias: 'L',
486
+ type: 'string',
487
+ description: 'Run only tests whose label matches the regex (forwards to ctest -L)',
488
+ })
489
+ .option('auto-build', {
490
+ type: 'boolean',
491
+ default: true,
492
+ description:
493
+ 'Auto-run `wyvrnpm build` when the toolchain is stale. ' +
494
+ 'Disable with --no-build (alias) or --no-auto-build.',
495
+ })
496
+ .option('no-build', {
497
+ type: 'boolean',
498
+ default: false,
499
+ description: 'Shorthand for --no-auto-build — skip the staleness check, just run ctest.',
500
+ })
501
+ .option('source', {
502
+ alias: 's',
503
+ type: 'string',
504
+ array: true,
505
+ description: 'Package source(s) — forwarded to auto-build when triggered',
506
+ })
507
+ .option('platform', {
508
+ type: 'string',
509
+ choices: ['win_x64', 'win_x86', 'linux_x64', 'linux_x86', 'osx_x64', 'osx_arm64'],
510
+ default: 'win_x64',
511
+ })
512
+ .option('timeout', {
513
+ type: 'number',
514
+ default: 300,
515
+ })
516
+ .option('build-dir', {
517
+ type: 'string',
518
+ description:
519
+ 'Project-relative subdir for the build tree (default: "build"). ' +
520
+ 'Must match the value used at install/build time. For persistent ' +
521
+ 'override, set `binaryDirRoot` in wyvrn.local.json.',
522
+ })
523
+ .option('format', {
524
+ type: 'string',
525
+ choices: ['text', 'json'],
526
+ default: 'text',
527
+ description:
528
+ 'Output mode. `json` emits a structured summary on stdout ' +
529
+ '(per-config total/passed/failed/duration/exitCode) and routes ' +
530
+ 'log lines to stderr. Use for CI gating.',
531
+ });
532
+ },
533
+ (argv) => test({
534
+ ...argv,
535
+ allConfigs: argv['all-configs'],
536
+ autoBuild: argv['no-build'] ? false : argv['auto-build'],
537
+ buildDir: argv['build-dir'],
538
+ testsRegex: argv['tests-regex'],
539
+ labelRegex: argv['label-regex'],
540
+ }),
541
+ )
542
+
438
543
  // ── cache ─────────────────────────────────────────────────────────────────
439
544
  .command(
440
545
  'cache',
@@ -161,19 +161,18 @@ else()
161
161
  message(STATUS "Unknown architecture")
162
162
  endif()
163
163
 
164
- # Output dirs are suffixed with WYVRN_ARCH_SUFFIX ("64" / "32" / "") so
165
- # multi-arch builds from the same source tree don't clobber each other
166
- # (e.g. building x64 then x86 used to silently overwrite the x64 DLLs
167
- # in output/bin/<CONFIG>/). Single-arch consumers see no path change on
168
- # x86 or unmapped arches the suffix is empty there. Set defensively:
169
- # if wyvrn_toolchain.cmake hasn't been included (standalone cmake runs),
170
- # WYVRN_ARCH_SUFFIX is undefined and we fall back to the legacy layout.
171
- if(NOT DEFINED WYVRN_ARCH_SUFFIX)
172
- set(WYVRN_ARCH_SUFFIX "")
173
- endif()
174
- set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/lib${WYVRN_ARCH_SUFFIX}/")
175
- set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/lib${WYVRN_ARCH_SUFFIX}/")
176
- set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/output/bin${WYVRN_ARCH_SUFFIX}")
164
+ # Put compiled artefacts inside the CMake binary dir so `wyvrnpm clean --all`
165
+ # (which sweeps `<binaryDirRoot>/wyvrn-*/`) catches them. Pre-2.17 wyvrnpm
166
+ # put these under `${CMAKE_SOURCE_DIR}/output/<bin|lib>${WYVRN_ARCH_SUFFIX}/`
167
+ # which leaked binaries into the source tree and survived clean.
168
+ # Per-profile build dirs (build/wyvrn-x64-msvc/ vs build/wyvrn-x86-msvc/)
169
+ # already isolate multi-arch builds, so the WYVRN_ARCH_SUFFIX directory
170
+ # suffix is redundant and dropped here. WYVRN_ARCH_SUFFIX itself stays
171
+ # exposed by wyvrn_toolchain.cmake and is still applied to artefact NAMES
172
+ # via WYVRN_APPLY_ARCH_SUFFIX (cmake/macros.cmake).
173
+ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib/")
174
+ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib/")
175
+ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
177
176
 
178
177
  add_compile_definitions(
179
178
  $<$<CONFIG:Debug>:WYVRN_DEBUG>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.16.1",
3
+ "version": "2.17.0",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -136,7 +136,7 @@ function stripPresetsFromWorkspace(rootDir, { dryRun }) {
136
136
  * Flag matrix:
137
137
  * - (default) wyvrn_internal/ + wyvrn.lock
138
138
  * - --build-dir also the active profile's wyvrn-<profile> build dir
139
- * - --all default set + every wyvrn-prefixed build dir + wyvrn-prefixed preset entries
139
+ * - --all default set + every wyvrn-prefixed build dir + wyvrn-prefixed preset entries + legacy <root>/output/ tree
140
140
  * - --build-cache also the machine-wide source-build cache
141
141
  * - --uploaded-built wipe just the upload sidecars (targeted-only when used
142
142
  * alone — does not also do the default scrub)
@@ -197,6 +197,18 @@ async function clean(argv) {
197
197
  }
198
198
  }
199
199
 
200
+ // --all: sweep the legacy <root>/output/ tree from pre-2.17 wyvrnpm. Old
201
+ // cmake/variables.cmake hardcoded CMAKE_{RUNTIME,LIBRARY,ARCHIVE}_OUTPUT_DIRECTORY
202
+ // under ${CMAKE_SOURCE_DIR}/output/{bin,lib}${WYVRN_ARCH_SUFFIX}/, outside
203
+ // the build dir — clean --all missed it. Projects that already rebuilt
204
+ // under the new layout simply won't have this directory.
205
+ if (argv.all) {
206
+ const legacyOutputDir = path.join(rootDir, 'output');
207
+ if (removePath(legacyOutputDir, { dryRun, label: legacyOutputDir })) {
208
+ removedAnything = true;
209
+ }
210
+ }
211
+
200
212
  // --all: also strip wyvrn-* entries from CMakePresets.json / CMakeUserPresets.json.
201
213
  if (argv.all) {
202
214
  const touched = stripPresetsFromWorkspace(rootDir, { dryRun });
@@ -0,0 +1,302 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ const { normalizeRecipe, hasBuildRecipe } = require('../build/recipe');
6
+ const { buildContext } = require('../context');
7
+ const build = require('./build');
8
+ const log = require('../logger');
9
+ const { version: WYVRNPM_VERSION } = require('../../package.json');
10
+
11
+ const { isToolchainStale, resolveConfigList } = build._helpers;
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Pure helpers (unit-testable — no process spawning)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Resolve the test preset name for a given (profile, config). Mirrors the
19
+ * naming emitted by `buildTestPresets` in [src/toolchain/presets.js]:
20
+ * `wyvrn-<profile>-<config-lowercased>`. CLI `--preset` overrides the
21
+ * computed name when provided.
22
+ *
23
+ * @param {{ presetOverride?: string|null, profileName: string, config: string }} args
24
+ * @returns {string}
25
+ */
26
+ function resolveTestPresetName({ presetOverride, profileName, config }) {
27
+ if (presetOverride) return presetOverride;
28
+ return `wyvrn-${profileName}-${config.toLowerCase()}`;
29
+ }
30
+
31
+ /**
32
+ * Build the ctest argv for a single preset invocation.
33
+ *
34
+ * @param {{
35
+ * presetName: string,
36
+ * verbose?: boolean,
37
+ * testsRegex?: string|null,
38
+ * labelRegex?: string|null,
39
+ * passthru?: string[],
40
+ * }} args
41
+ * @returns {string[]}
42
+ */
43
+ function buildCtestArgs({
44
+ presetName,
45
+ verbose = false,
46
+ testsRegex = null,
47
+ labelRegex = null,
48
+ passthru = [],
49
+ }) {
50
+ const args = ['--preset', presetName];
51
+ if (verbose) args.push('-V');
52
+ if (testsRegex) args.push('-R', testsRegex);
53
+ if (labelRegex) args.push('-L', labelRegex);
54
+ if (passthru.length > 0) args.push(...passthru);
55
+ return args;
56
+ }
57
+
58
+ /**
59
+ * Parse ctest's tail summary from captured stdout. ctest emits a stable
60
+ * summary line near the end of every run:
61
+ *
62
+ * 100% tests passed, 0 tests failed out of 42
63
+ * Total Test time (real) = 1.23 sec
64
+ *
65
+ * Returns `null` for any field that couldn't be parsed (e.g. when ctest
66
+ * found no tests — "No tests were found!!!" — which is itself a valid
67
+ * outcome). Callers fall back to `exitCode` for pass/fail in that case.
68
+ *
69
+ * @param {string} stdout
70
+ * @returns {{ total: number|null, passed: number|null, failed: number|null, durationMs: number|null }}
71
+ */
72
+ function parseCtestSummary(stdout) {
73
+ const summaryMatch = stdout.match(
74
+ /(\d+(?:\.\d+)?)%\s+tests\s+passed,\s+(\d+)\s+tests?\s+failed\s+out\s+of\s+(\d+)/,
75
+ );
76
+ const timeMatch = stdout.match(
77
+ /Total Test time \(real\)\s*=\s*([\d.]+)\s*sec/,
78
+ );
79
+
80
+ if (!summaryMatch) {
81
+ return {
82
+ total: null,
83
+ passed: null,
84
+ failed: null,
85
+ durationMs: timeMatch ? Math.round(parseFloat(timeMatch[1]) * 1000) : null,
86
+ };
87
+ }
88
+
89
+ const failed = parseInt(summaryMatch[2], 10);
90
+ const total = parseInt(summaryMatch[3], 10);
91
+ return {
92
+ total,
93
+ failed,
94
+ passed: total - failed,
95
+ durationMs: timeMatch ? Math.round(parseFloat(timeMatch[1]) * 1000) : null,
96
+ };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Spawn wrapper
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Run `ctest` with the given args.
105
+ *
106
+ * - Text mode: inherits stdio for real-time output. Returns `{ exitCode }`.
107
+ * - JSON mode: pipes stdout, tees to stderr so the user still sees progress,
108
+ * captures the full stdout for summary parsing. Returns
109
+ * `{ exitCode, capturedStdout }`.
110
+ *
111
+ * Resolves with the exit code (never rejects on non-zero — callers decide
112
+ * how to aggregate across configs).
113
+ *
114
+ * @param {string[]} args
115
+ * @param {{ captureStdout: boolean }} opts
116
+ * @returns {Promise<{ exitCode: number, capturedStdout: string }>}
117
+ */
118
+ function runCtest(args, { captureStdout }) {
119
+ return new Promise((resolve, reject) => {
120
+ const stdio = captureStdout
121
+ ? ['inherit', 'pipe', 'inherit']
122
+ : 'inherit';
123
+ const proc = spawn('ctest', args, { stdio });
124
+ let captured = '';
125
+ if (captureStdout && proc.stdout) {
126
+ proc.stdout.on('data', (chunk) => {
127
+ const text = chunk.toString('utf8');
128
+ captured += text;
129
+ process.stderr.write(text);
130
+ });
131
+ }
132
+ proc.on('error', (err) => {
133
+ reject(new Error(
134
+ `Failed to spawn ctest: ${err.message}\n` +
135
+ ' Is CMake installed and on PATH?',
136
+ ));
137
+ });
138
+ proc.on('close', (code) => {
139
+ resolve({ exitCode: code ?? 1, capturedStdout: captured });
140
+ });
141
+ });
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Command entry
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * `wyvrnpm test` — run ctest against the wyvrnpm-generated test preset.
150
+ *
151
+ * Auto-runs `wyvrnpm build` first when the toolchain is stale or missing
152
+ * (mirrors `wyvrnpm build`'s auto-install). Disable with `--no-build`.
153
+ *
154
+ * Per-config loop: same precedence as `wyvrnpm build` —
155
+ * `--all-configs` > `--config X,Y` > `Release`. ctest is one-config-per-
156
+ * invocation, so multi-config means N spawns. Exit code is 0 iff every
157
+ * config returns 0.
158
+ *
159
+ * `--format json` emits a structured summary on stdout (log lines route
160
+ * to stderr); summary stats parsed from ctest's tail output.
161
+ *
162
+ * @param {object} argv
163
+ */
164
+ async function test(argv) {
165
+ if (argv.format === 'json') log.setJsonMode(true);
166
+
167
+ let ctx;
168
+ try {
169
+ ctx = buildContext(argv);
170
+ } catch (err) {
171
+ log.error(err.message);
172
+ process.exit(1);
173
+ }
174
+
175
+ const { rootDir, manifestPath } = ctx;
176
+ const { name: profileName } = ctx.profiles.host;
177
+
178
+ // Resolve the recipe's config list up front so --all-configs has
179
+ // something to consult. Same fallback chain as `wyvrnpm build`.
180
+ let configList;
181
+ try {
182
+ const recipe = hasBuildRecipe(ctx.manifest)
183
+ ? (() => { try { return normalizeRecipe(ctx.manifest.build, null); } catch { return null; } })()
184
+ : null;
185
+ configList = resolveConfigList({ argv, recipeConfigs: recipe?.configs ?? null });
186
+ } catch (err) {
187
+ log.error(err.message);
188
+ process.exit(1);
189
+ }
190
+
191
+ // ── Stale check / auto-build ─────────────────────────────────────────────
192
+ // `--no-build` (i.e. argv.autoBuild === false) opts out of the auto-build
193
+ // before ctest. Default behaviour matches `wyvrnpm build`'s auto-install
194
+ // gate: if the toolchain or build outputs look stale, run build first.
195
+ // We delegate to `build()` rather than reaching into install — build
196
+ // already handles its own staleness checks and forwards to install when
197
+ // needed. Pass --install:false so the build dir holds artefacts ctest
198
+ // can actually reach.
199
+ const staleness = isToolchainStale({ rootDir, manifestPath });
200
+ if (staleness.stale) {
201
+ if (argv.autoBuild === false) {
202
+ log.error(
203
+ `Toolchain is stale (${staleness.reason}) and --no-build was passed.\n` +
204
+ ' Run: wyvrnpm build (or omit --no-build)',
205
+ );
206
+ process.exit(1);
207
+ }
208
+ log.info(`Toolchain ${staleness.reason} — running build first`);
209
+ await build({
210
+ manifest: argv.manifest,
211
+ root: argv.root,
212
+ profile: argv.profile,
213
+ source: argv.source,
214
+ platform: argv.platform ?? 'win_x64',
215
+ timeout: argv.timeout ?? 300,
216
+ buildDir: argv.buildDir,
217
+ preset: argv.preset,
218
+ config: argv.config,
219
+ allConfigs: argv.allConfigs,
220
+ verbose: false, // build's stdout would muddy --format json
221
+ install: false, // we want artefacts in the build dir, not an install tree
222
+ autoInstall: true,
223
+ });
224
+ }
225
+
226
+ // ── Per-config ctest loop ────────────────────────────────────────────────
227
+ if (configList.length > 1) {
228
+ log.info(`Testing ${configList.length} configs: ${configList.join(', ')}`);
229
+ }
230
+
231
+ const jsonMode = argv.format === 'json';
232
+ const passthru = argv['--'] ?? [];
233
+ const results = [];
234
+
235
+ for (const config of configList) {
236
+ const presetName = resolveTestPresetName({
237
+ presetOverride: argv.preset,
238
+ profileName,
239
+ config,
240
+ });
241
+ const ctestArgs = buildCtestArgs({
242
+ presetName,
243
+ verbose: argv.verbose,
244
+ testsRegex: argv.testsRegex,
245
+ labelRegex: argv.labelRegex,
246
+ passthru,
247
+ });
248
+ log.info(`ctest ${ctestArgs.join(' ')}`);
249
+
250
+ const { exitCode, capturedStdout } = await runCtest(ctestArgs, {
251
+ captureStdout: jsonMode,
252
+ });
253
+
254
+ const summary = jsonMode ? parseCtestSummary(capturedStdout) : {
255
+ total: null, passed: null, failed: null, durationMs: null,
256
+ };
257
+ results.push({
258
+ config,
259
+ preset: presetName,
260
+ total: summary.total,
261
+ passed: summary.passed,
262
+ failed: summary.failed,
263
+ durationMs: summary.durationMs,
264
+ exitCode,
265
+ });
266
+ }
267
+
268
+ const passedConfigs = results.filter((r) => r.exitCode === 0).length;
269
+ const failedConfigs = results.length - passedConfigs;
270
+ const ok = failedConfigs === 0;
271
+
272
+ if (jsonMode) {
273
+ const payload = {
274
+ command: 'test',
275
+ wyvrnpmVersion: WYVRNPM_VERSION,
276
+ profile: profileName,
277
+ configs: results,
278
+ totalConfigs: results.length,
279
+ passedConfigs,
280
+ failedConfigs,
281
+ ok,
282
+ };
283
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
284
+ } else if (ok) {
285
+ log.success(`Tests passed (${results.length} config${results.length === 1 ? '' : 's'}: ${configList.join(', ')})`);
286
+ } else {
287
+ const failedNames = results.filter((r) => r.exitCode !== 0).map((r) => r.config).join(', ');
288
+ log.error(`Tests failed in ${failedConfigs}/${results.length} config(s): ${failedNames}`);
289
+ }
290
+
291
+ if (!ok) process.exit(1);
292
+ }
293
+
294
+ module.exports = test;
295
+ module.exports.default = test;
296
+
297
+ // Exported for unit tests
298
+ module.exports._helpers = {
299
+ resolveTestPresetName,
300
+ buildCtestArgs,
301
+ parseCtestSummary,
302
+ };
package/src/index.js CHANGED
@@ -3,6 +3,8 @@
3
3
  module.exports = {
4
4
  init: require('./commands/init'),
5
5
  install: require('./commands/install'),
6
+ build: require('./commands/build'),
7
+ test: require('./commands/test'),
6
8
  clean: require('./commands/clean'),
7
9
  publish: require('./commands/publish'),
8
10
  configure: require('./commands/configure'),
@@ -187,6 +187,41 @@ function buildBuildPresets(profileName, configs = null) {
187
187
  }));
188
188
  }
189
189
 
190
+ /**
191
+ * One ctest preset per configuration the recipe declares (or the
192
+ * legacy default pair `Debug` + `Release` when no list is provided).
193
+ *
194
+ * Test presets share their name with the matching build preset
195
+ * (`wyvrn-<profile>-<config>`) — CMake's preset name namespaces are
196
+ * per-array, so `cmake --build --preset wyvrn-default-release` and
197
+ * `ctest --preset wyvrn-default-release` can both resolve without
198
+ * collision. That symmetry is the whole point: the user types one
199
+ * preset name across both tools.
200
+ *
201
+ * `output.outputOnFailure: true` defaults to ctest's `--output-on-failure`
202
+ * — the only sane default for CI consumption. `execution.stopOnFailure:
203
+ * false` lets the full suite run rather than bailing on the first
204
+ * failure (Conan/Catch2 convention).
205
+ *
206
+ * @param {string} profileName
207
+ * @param {string[]|null} [configs] Recipe's declared configurations.
208
+ * @returns {Array<object>}
209
+ */
210
+ function buildTestPresets(profileName, configs = null) {
211
+ const base = `wyvrn-${profileName}`;
212
+ const list = (Array.isArray(configs) && configs.length > 0)
213
+ ? configs
214
+ : ['Debug', 'Release'];
215
+ return list.map((cfg) => ({
216
+ name: `${base}-${cfg.toLowerCase()}`,
217
+ displayName: `wyvrn ${profileName} (${cfg})`,
218
+ configurePreset: base,
219
+ configuration: cfg,
220
+ output: { outputOnFailure: true },
221
+ execution: { stopOnFailure: false },
222
+ }));
223
+ }
224
+
190
225
  /**
191
226
  * Build a fresh presets file from scratch (no prior content to merge).
192
227
  */
@@ -214,6 +249,7 @@ function buildFreshPresetsFile({
214
249
  profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs, binaryDirRoot,
215
250
  })],
216
251
  buildPresets: buildBuildPresets(profileName, configs),
252
+ testPresets: buildTestPresets(profileName, configs),
217
253
  };
218
254
  }
219
255
 
@@ -242,6 +278,7 @@ function mergeIntoExisting(existing, {
242
278
  profileName, profileHash, toolchainRelPath, generator, extraCacheVariables, installDir, profileArch, configs, binaryDirRoot,
243
279
  });
244
280
  const newBuilds = buildBuildPresets(profileName, configs);
281
+ const newTests = buildTestPresets(profileName, configs);
245
282
  const newConfigureNames = new Set([newConfigure.name]);
246
283
  // When replacing THIS profile's build presets, drop every pre-existing
247
284
  // one that targets the same configurePreset — the fresh `newBuilds`
@@ -253,7 +290,7 @@ function mergeIntoExisting(existing, {
253
290
  // leave orphan build presets pointing at configurations CMake won't
254
291
  // generate.
255
292
  const myBase = `wyvrn-${profileName}`;
256
- const isMyBuildPreset = (p) => p && p.configurePreset === myBase;
293
+ const isMyChildPreset = (p) => p && p.configurePreset === myBase;
257
294
 
258
295
  const configurePresets = (existing.configurePresets ?? []).filter((p) => !newConfigureNames.has(p.name));
259
296
  configurePresets.push(newConfigure);
@@ -262,9 +299,16 @@ function mergeIntoExisting(existing, {
262
299
  // configurePreset — the fresh `newBuilds` list (one per current
263
300
  // `configs` entry) fully replaces them. Presets for OTHER profiles
264
301
  // stay untouched so the multi-profile merge story is preserved.
265
- const buildPresets = (existing.buildPresets ?? []).filter((p) => !isMyBuildPreset(p));
302
+ const buildPresets = (existing.buildPresets ?? []).filter((p) => !isMyChildPreset(p));
266
303
  buildPresets.push(...newBuilds);
267
304
 
305
+ // Same logic for testPresets — scoped to THIS profile's configurePreset
306
+ // so user-named test presets and other-profile entries survive
307
+ // byte-identical. Files written by pre-2.18 wyvrnpm have no testPresets
308
+ // array; we just create one.
309
+ const testPresets = (existing.testPresets ?? []).filter((p) => !isMyChildPreset(p));
310
+ testPresets.push(...newTests);
311
+
268
312
  return {
269
313
  ...existing,
270
314
  version: existing.version ?? SCHEMA_VERSION,
@@ -278,6 +322,7 @@ function mergeIntoExisting(existing, {
278
322
  },
279
323
  configurePresets,
280
324
  buildPresets,
325
+ testPresets,
281
326
  };
282
327
  }
283
328
 
@@ -387,6 +432,13 @@ function stripWyvrnPresets(existing) {
387
432
  }
388
433
  return true;
389
434
  });
435
+ const testPresets = (existing.testPresets ?? []).filter((p) => {
436
+ if (p && (isWyvrnName(p.name) || isWyvrnName(p.configurePreset))) {
437
+ removed.push(p.name);
438
+ return false;
439
+ }
440
+ return true;
441
+ });
390
442
 
391
443
  // Drop our vendor marker; preserve any foreign vendor entries.
392
444
  let vendor = existing.vendor;
@@ -398,10 +450,10 @@ function stripWyvrnPresets(existing) {
398
450
  // Decide if anything meaningful survives. The schema `version` field
399
451
  // is metadata, not content — a file with only `{ version: 3 }` is a
400
452
  // husk we should delete.
401
- const hasSurvivingPresets = configurePresets.length > 0 || buildPresets.length > 0;
453
+ const hasSurvivingPresets = configurePresets.length > 0 || buildPresets.length > 0 || testPresets.length > 0;
402
454
  const hasForeignVendor = vendor !== undefined;
403
455
  const otherTopLevelKeys = Object.keys(existing).filter(
404
- (k) => k !== 'version' && k !== 'vendor' && k !== 'configurePresets' && k !== 'buildPresets',
456
+ (k) => k !== 'version' && k !== 'vendor' && k !== 'configurePresets' && k !== 'buildPresets' && k !== 'testPresets',
405
457
  );
406
458
 
407
459
  if (!hasSurvivingPresets && !hasForeignVendor && otherTopLevelKeys.length === 0) {
@@ -413,6 +465,8 @@ function stripWyvrnPresets(existing) {
413
465
  else delete result.configurePresets;
414
466
  if (buildPresets.length > 0) result.buildPresets = buildPresets;
415
467
  else delete result.buildPresets;
468
+ if (testPresets.length > 0) result.testPresets = testPresets;
469
+ else delete result.testPresets;
416
470
  if (vendor !== undefined) result.vendor = vendor;
417
471
  else delete result.vendor;
418
472
 
@@ -427,6 +481,7 @@ module.exports = {
427
481
  isWyvrnGenerated,
428
482
  buildConfigurePreset,
429
483
  buildBuildPresets,
484
+ buildTestPresets,
430
485
  buildFreshPresetsFile,
431
486
  mergeIntoExisting,
432
487
  VENDOR_KEY,