dazscript-framework 1.0.32 → 1.0.34

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
@@ -11,6 +11,7 @@
11
11
  - [Installation & Setup](#installation--setup)
12
12
  - [Unit Tests](#unit-tests)
13
13
  - [DAZ Studio Integration Tests](#daz-studio-integration-tests)
14
+ - [DAZ Studio Headless Probes](#daz-studio-headless-probes)
14
15
  - [Project Configuration](#project-configuration)
15
16
  - [The `action(...)` Entrypoint](#the-action-entrypoint)
16
17
  - [Build Output: Launcher Shims](#build-output-launcher-shims)
@@ -62,6 +63,7 @@ To include optional test scaffolds, run one or both:
62
63
  ```bash
63
64
  npx dazscript init --unit-tests
64
65
  npx dazscript init --integration-tests
66
+ npx dazscript init --probes
65
67
  ```
66
68
 
67
69
  ### 3. Write the script
@@ -177,6 +179,7 @@ Available `init` flags:
177
179
  npx dazscript init --menu-path /MyScripts --scripts-path ./src --out-dir ./out --app-data-path YourName/my-project
178
180
  npx dazscript init --unit-tests --app-data-path YourName/my-project
179
181
  npx dazscript init --integration-tests --app-data-path YourName/my-project
182
+ npx dazscript init --probes --app-data-path YourName/my-project
180
183
  ```
181
184
 
182
185
  | Flag | Description |
@@ -222,6 +225,22 @@ npm run test:integration
222
225
 
223
226
  The generated script calls `dazscript integration --fixture ./test/integration/fixtures/<project>-smoke.dsa.ts`. Configure local machine paths in an ignored `.env.integration.local`; `DAZ_STUDIO_EXE` is always required, and `DAZ_TEST_CONTENT_DUF` is required only for tests that use `--require-content`. See `test/integration/README.md` in this repository for maintainer and consuming-project details.
224
227
 
228
+ ### DAZ Studio headless probes
229
+
230
+ Projects can bootstrap non-asserting DAZ Studio probes with:
231
+
232
+ ```bash
233
+ npx dazscript init --probes
234
+ ```
235
+
236
+ Run a probe with:
237
+
238
+ ```bash
239
+ npm run probe
240
+ ```
241
+
242
+ Probes use the same headless DAZ build and launch infrastructure as integration tests, but they do not require `ok: true` assertions. A probe succeeds when DAZ completes and writes readable JSON. Use `dazscript probe` for exploratory runtime observations such as available globals, installed plugins, scene state, signal behavior, or content loading details.
243
+
225
244
  ---
226
245
 
227
246
  ### Project Configuration
@@ -592,7 +611,7 @@ my-daz-scripts/
592
611
 
593
612
  Files ending in `.dsa.ts` are treated as runnable entry points and compiled to `.dsa`. Plain `.ts` files are modules — imported by entry points but not compiled independently.
594
613
 
595
- The `test/unit/` files are generated only when you run `dazscript init --unit-tests`. The `test/integration/` files and env examples are generated only when you run `dazscript init --integration-tests`.
614
+ The `test/unit/` files are generated only when you run `dazscript init --unit-tests`. The `test/integration/` files and env examples are generated only when you run `dazscript init --integration-tests`. The `probes/` files and probe env examples are generated only when you run `dazscript init --probes`.
596
615
 
597
616
  **Common commands**
598
617
 
@@ -9,7 +9,7 @@ const { copyIcons } = require('./icons');
9
9
  const { runEncrypt } = require('./encrypt');
10
10
  const { generateInstallerFiles } = require('./install-generator');
11
11
  const { initProject } = require('./init');
12
- const { runIntegration } = require('./integration');
12
+ const { runIntegration, runProbe } = require('./integration');
13
13
 
14
14
  function printHelp() {
15
15
  console.log(`dazscript <command> [options]
@@ -22,6 +22,7 @@ Commands:
22
22
  icons Copy png assets into the output directory
23
23
  installer Generate Install.dsa.ts and Uninstall.dsa.ts
24
24
  integration Run a DAZ Studio headless integration fixture
25
+ probe Run a non-asserting DAZ Studio headless probe fixture
25
26
 
26
27
  Options:
27
28
  --menu-path <path> Default menu path. Default: /MyScripts
@@ -32,11 +33,12 @@ Options:
32
33
  --daz-studio <path> Daz Studio executable for encrypt
33
34
  --keep-source Keep source script.dsa files after encrypting
34
35
  --timeout-ms <value> Daz Studio encrypt timeout. Default: 300000
35
- --fixture <path> Integration fixture .dsa.ts file
36
- --env-file <path> Integration env file. Default: .env.integration.local
37
- --require-content Require DAZ_TEST_CONTENT_DUF for integration tests
36
+ --fixture <path> Integration or probe fixture .dsa.ts file
37
+ --env-file <path> Integration/probe env file. Defaults: .env.integration.local or .env.probe.local
38
+ --require-content Require DAZ_TEST_CONTENT_DUF for integration tests or probes
38
39
  --unit-tests Add Vitest unit-test scaffold during init
39
40
  --integration-tests Add integration-test scaffold during init
41
+ --probes Add DAZ headless probe scaffold during init
40
42
  --force Overwrite generated files
41
43
  --help Show this message
42
44
  `);
@@ -109,6 +111,11 @@ function parseOptions(args, defaults) {
109
111
  continue;
110
112
  }
111
113
 
114
+ if (arg === '--probes') {
115
+ options.probes = true;
116
+ continue;
117
+ }
118
+
112
119
  if (arg === '--menu-path') {
113
120
  options.menuPath = args[index + 1];
114
121
  index += 1;
@@ -230,6 +237,7 @@ async function main(argv) {
230
237
  requireContent: false,
231
238
  unitTests: false,
232
239
  integrationTests: false,
240
+ probes: false,
233
241
  });
234
242
 
235
243
  if (options.help) {
@@ -242,6 +250,11 @@ async function main(argv) {
242
250
  return;
243
251
  }
244
252
 
253
+ if (command === 'probe') {
254
+ await runProbe(options, process.env, workdir);
255
+ return;
256
+ }
257
+
245
258
  const commandOptions =
246
259
  command === 'init'
247
260
  ? await resolveInitOptions(workdir, options)
@@ -147,6 +147,23 @@ function updatePackageJsonForIntegration(workdir, fixturePath) {
147
147
  }
148
148
  }
149
149
 
150
+ function updatePackageJsonForProbe(workdir, fixturePath) {
151
+ const packageJsonPath = path.join(workdir, 'package.json');
152
+ const packageJson = fs.existsSync(packageJsonPath)
153
+ ? readJson(packageJsonPath)
154
+ : { private: true };
155
+
156
+ packageJson.scripts = packageJson.scripts || {};
157
+ if (packageJson.scripts.probe) {
158
+ console.log('skip package.json probe');
159
+ }
160
+ else {
161
+ packageJson.scripts.probe = `dazscript probe --fixture ${fixturePath}`;
162
+ writeJson(packageJsonPath, packageJson);
163
+ console.log('update package.json');
164
+ }
165
+ }
166
+
150
167
  function isNpmDefaultTestScript(scriptName, command) {
151
168
  return scriptName === 'test' && command === 'echo "Error: no test specified" && exit 1';
152
169
  }
@@ -423,6 +440,122 @@ function initIntegrationTests(workdir, options) {
423
440
  ensureLine(path.join(workdir, '.gitignore'), '.env.integration.local');
424
441
  }
425
442
 
443
+ function buildProbeFixtureContent() {
444
+ return `import { action } from '@dsf/core/action'
445
+ import { saveToFile } from '@dsf/helpers/file-helper'
446
+ import { getStringScriptArguments } from '@dsf/helpers/script-helper'
447
+
448
+ type ProbeResult = {
449
+ kind: string
450
+ status: string
451
+ observations: Record<string, unknown>
452
+ }
453
+
454
+ const countSceneNodes = (): number => {
455
+ return Scene.getNumNodes()
456
+ }
457
+
458
+ action({ text: 'Scene Probe', menuPath: false }, () => {
459
+ const args = getStringScriptArguments()
460
+ const resultPath = args.length > 0 ? args[0] : ''
461
+ const result: ProbeResult = {
462
+ kind: 'daz-headless-probe',
463
+ status: 'observed',
464
+ observations: {
465
+ graphicsMode: App.getGraphicsMode(),
466
+ interfaceAvailable: !!App.getInterface(),
467
+ nodeCount: countSceneNodes(),
468
+ time: String(Scene.getTime()),
469
+ frame: Scene.getFrame()
470
+ }
471
+ }
472
+
473
+ if (resultPath) {
474
+ saveToFile(resultPath, JSON.stringify(result, null, 2))
475
+ }
476
+ })
477
+ `;
478
+ }
479
+
480
+ function buildProbeReadmeContent(fixturePath) {
481
+ return `# DAZ Headless Probes
482
+
483
+ Probes are non-asserting DAZ Studio headless scripts for runtime exploration. They are useful for AI-agent investigations where the desired output is structured observations, not pass/fail test assertions.
484
+
485
+ Create a local env file:
486
+
487
+ \`\`\`bash
488
+ cp .env.probe.linux.example .env.probe.local
489
+ \`\`\`
490
+
491
+ \`\`\`powershell
492
+ Copy-Item .env.probe.windows.example .env.probe.local
493
+ \`\`\`
494
+
495
+ Then run:
496
+
497
+ \`\`\`bash
498
+ npm run probe
499
+ \`\`\`
500
+
501
+ The default scene probe is:
502
+
503
+ \`\`\`text
504
+ ${fixturePath}
505
+ \`\`\`
506
+
507
+ Generated output is written to \`probes/out/\` and ignored by git.
508
+ `;
509
+ }
510
+
511
+ function buildProbeLinuxEnvExample() {
512
+ return `# Copy this file to .env.probe.local and edit paths for your machine.
513
+ # Shell environment variables override values in .env.probe.local.
514
+ # DAZ_STUDIO_EXE is required for probes.
515
+
516
+ WINEPREFIX=/home/your-user/.local/share/daz-wine/prefix
517
+ DAZ_STUDIO_EXE=/home/your-user/.local/share/daz-wine/prefix/drive_c/Program Files/DAZ 3D/DAZStudio4/DAZStudio.exe
518
+ DAZ_PROBE_TIMEOUT_MS=300000
519
+ `;
520
+ }
521
+
522
+ function buildProbeWindowsEnvExample() {
523
+ return `# Copy this file to .env.probe.local and edit paths for your machine.
524
+ # Shell environment variables override values in .env.probe.local.
525
+ # Forward slashes avoid escaping issues in env files.
526
+ # DAZ_STUDIO_EXE is required for probes.
527
+
528
+ DAZ_STUDIO_EXE=C:/Program Files/DAZ 3D/DAZStudio4/DAZStudio.exe
529
+ DAZ_PROBE_TIMEOUT_MS=300000
530
+ `;
531
+ }
532
+
533
+ function initProbes(workdir, options) {
534
+ const fixtureBaseName = `${toFixtureName(path.basename(workdir))}-scene.dsa.ts`;
535
+ const fixturePath = `./probes/fixtures/${fixtureBaseName}`;
536
+ const localFixturePath = path.join(workdir, fixturePath);
537
+
538
+ writeFileIfNeeded(localFixturePath, buildProbeFixtureContent(), options.force);
539
+ writeFileIfNeeded(
540
+ path.join(workdir, 'probes/README.md'),
541
+ buildProbeReadmeContent(fixturePath),
542
+ options.force
543
+ );
544
+ writeFileIfNeeded(
545
+ path.join(workdir, '.env.probe.linux.example'),
546
+ buildProbeLinuxEnvExample(),
547
+ options.force
548
+ );
549
+ writeFileIfNeeded(
550
+ path.join(workdir, '.env.probe.windows.example'),
551
+ buildProbeWindowsEnvExample(),
552
+ options.force
553
+ );
554
+ updatePackageJsonForProbe(workdir, fixturePath);
555
+ ensureLine(path.join(workdir, '.gitignore'), 'probes/out/');
556
+ ensureLine(path.join(workdir, '.gitignore'), '.env.probe.local');
557
+ }
558
+
426
559
  function initProject(workdir, rawOptions) {
427
560
  const projectName = path.basename(workdir);
428
561
  const options = {
@@ -434,6 +567,7 @@ function initProject(workdir, rawOptions) {
434
567
  bundleName: toBundleName(projectName),
435
568
  unitTests: Boolean(rawOptions.unitTests),
436
569
  integrationTests: Boolean(rawOptions.integrationTests),
570
+ probes: Boolean(rawOptions.probes),
437
571
  };
438
572
 
439
573
  writeFileIfNeeded(
@@ -456,10 +590,15 @@ function initProject(workdir, rawOptions) {
456
590
  if (options.integrationTests) {
457
591
  initIntegrationTests(workdir, options);
458
592
  }
593
+
594
+ if (options.probes) {
595
+ initProbes(workdir, options);
596
+ }
459
597
  }
460
598
 
461
599
  module.exports = {
462
600
  initIntegrationTests,
601
+ initProbes,
463
602
  initUnitTests,
464
603
  initProject,
465
604
  };
@@ -71,10 +71,10 @@ function assertRequiredFile(label, filePath, allowDazPath) {
71
71
  return resolvedPath;
72
72
  }
73
73
 
74
- function resolveIntegrationOptions(cwd, rawOptions, env) {
74
+ function resolveHeadlessOptions(cwd, rawOptions, env, defaults) {
75
75
  const options = rawOptions || {};
76
76
  const projectRoot = path.resolve(cwd || process.cwd());
77
- const envFile = path.resolve(projectRoot, options.envFile || '.env.integration.local');
77
+ const envFile = path.resolve(projectRoot, options.envFile || defaults.envFile);
78
78
  const fixturePath = options.fixture
79
79
  ? path.resolve(projectRoot, options.fixture)
80
80
  : '';
@@ -100,12 +100,12 @@ function resolveIntegrationOptions(cwd, rawOptions, env) {
100
100
  throw new Error(`DAZ_TEST_CONTENT_DUF does not exist: ${path.resolve(rawContentPath)}`);
101
101
  }
102
102
 
103
- const timeoutMs = Number(options.timeoutMs || env.DAZ_TEST_TIMEOUT_MS || defaultTimeoutMs);
103
+ const timeoutMs = Number(options.timeoutMs || env[defaults.timeoutEnv] || env.DAZ_TEST_TIMEOUT_MS || defaultTimeoutMs);
104
104
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
105
- throw new Error(`invalid timeout: ${options.timeoutMs || env.DAZ_TEST_TIMEOUT_MS}`);
105
+ throw new Error(`invalid timeout: ${options.timeoutMs || env[defaults.timeoutEnv] || env.DAZ_TEST_TIMEOUT_MS}`);
106
106
  }
107
107
 
108
- const outDir = path.resolve(projectRoot, options.outDir || './test/integration/out');
108
+ const outDir = path.resolve(projectRoot, options.outDir || defaults.outDir);
109
109
  const fixtureName = path.basename(fixturePath).replace(/\.dsa\.ts$/i, '').replace(/\.ts$/i, '');
110
110
  const fixtureRoot = path.join(outDir, 'fixture');
111
111
  const resultPath = path.join(fixtureRoot, 'result.json');
@@ -124,12 +124,35 @@ function resolveIntegrationOptions(cwd, rawOptions, env) {
124
124
  contentPath: rawContentPath ? normalizeForDaz(rawContentPath) : '',
125
125
  requireContent: Boolean(options.requireContent),
126
126
  timeoutMs,
127
- appDataPath: options.appDataPath || 'DazScriptFramework/integration-tests',
128
- bundleName: options.bundleName || 'Integration Test Fixture',
127
+ appDataPath: options.appDataPath || defaults.appDataPath,
128
+ bundleName: options.bundleName || defaults.bundleName,
129
+ commandName: defaults.commandName,
129
130
  env,
130
131
  };
131
132
  }
132
133
 
134
+ function resolveIntegrationOptions(cwd, rawOptions, env) {
135
+ return resolveHeadlessOptions(cwd, rawOptions, env, {
136
+ commandName: 'integration',
137
+ envFile: '.env.integration.local',
138
+ timeoutEnv: 'DAZ_TEST_TIMEOUT_MS',
139
+ outDir: './test/integration/out',
140
+ appDataPath: 'DazScriptFramework/integration-tests',
141
+ bundleName: 'Integration Test Fixture',
142
+ });
143
+ }
144
+
145
+ function resolveProbeOptions(cwd, rawOptions, env) {
146
+ return resolveHeadlessOptions(cwd, rawOptions, env, {
147
+ commandName: 'probe',
148
+ envFile: '.env.probe.local',
149
+ timeoutEnv: 'DAZ_PROBE_TIMEOUT_MS',
150
+ outDir: './probes/out',
151
+ appDataPath: 'DazScriptFramework/probes',
152
+ bundleName: 'Probe Fixture',
153
+ });
154
+ }
155
+
133
156
  function run(command, args, options) {
134
157
  const result = spawnSync(command, args, {
135
158
  cwd: options.cwd,
@@ -306,7 +329,7 @@ function runDazFixture(options) {
306
329
  const command = useWine ? 'wine' : options.dazStudioExe;
307
330
  const commandArgs = useWine ? [options.dazStudioExe].concat(dazArgs) : dazArgs;
308
331
 
309
- console.log(`[dazscript integration] launching DAZ: ${useWine ? `${command} ${options.dazStudioExe}` : command}`);
332
+ console.log(`[dazscript ${options.commandName || 'integration'}] launching DAZ: ${useWine ? `${command} ${options.dazStudioExe}` : command}`);
310
333
  run(command, commandArgs, {
311
334
  cwd: options.fixtureRoot,
312
335
  env: options.env,
@@ -335,6 +358,19 @@ function readIntegrationResult(resultPath) {
335
358
  return result;
336
359
  }
337
360
 
361
+ function readProbeResult(resultPath) {
362
+ if (!fs.existsSync(resultPath)) {
363
+ throw new Error(`DAZ did not write result JSON: ${resultPath}`);
364
+ }
365
+
366
+ try {
367
+ return JSON.parse(fs.readFileSync(resultPath, 'utf8'));
368
+ }
369
+ catch (error) {
370
+ throw new Error(`could not parse result JSON: ${error}`);
371
+ }
372
+ }
373
+
338
374
  async function runIntegration(rawOptions, injectedEnv, cwd) {
339
375
  const env = { ...(injectedEnv || process.env) };
340
376
  const projectRoot = path.resolve(cwd || process.cwd());
@@ -349,14 +385,31 @@ async function runIntegration(rawOptions, injectedEnv, cwd) {
349
385
  return result;
350
386
  }
351
387
 
388
+ async function runProbe(rawOptions, injectedEnv, cwd) {
389
+ const env = { ...(injectedEnv || process.env) };
390
+ const projectRoot = path.resolve(cwd || process.cwd());
391
+ const envFile = path.resolve(projectRoot, (rawOptions && rawOptions.envFile) || '.env.probe.local');
392
+ loadEnvFile(envFile, env);
393
+
394
+ const options = resolveProbeOptions(projectRoot, rawOptions, env);
395
+ buildFixtureProject(options);
396
+ runDazFixture(options);
397
+ const result = readProbeResult(options.resultPath);
398
+ console.log(`[dazscript probe] result: ${options.resultPath}`);
399
+ return result;
400
+ }
401
+
352
402
  module.exports = {
353
403
  loadEnvFile,
354
404
  normalizeForDaz,
355
405
  getNpmInvocation,
356
406
  getFixtureBuildDependencies,
357
407
  readIntegrationResult,
408
+ readProbeResult,
358
409
  resolveIntegrationOptions,
410
+ resolveProbeOptions,
359
411
  buildFixtureProject,
360
412
  runDazFixture,
361
413
  runIntegration,
414
+ runProbe,
362
415
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dazscript-framework",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "author": "Freddy Diaz",
5
5
  "license": "MPL-2.0",
6
6
  "description": "",
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getMirrorNameCandidate, getMirrorNodeMatch, getMirrorNodes } from './skeleton-helper'
3
+
4
+ type TestNode = {
5
+ name: string
6
+ getName: () => string
7
+ }
8
+
9
+ const node = (name: string): TestNode => ({
10
+ name,
11
+ getName: () => name
12
+ })
13
+
14
+ const figure = (names: string[]) => ({
15
+ findNodeChild: (name: string) => names.indexOf(name) >= 0 ? node(name) : null
16
+ })
17
+
18
+ describe('skeleton mirror helper', () => {
19
+ it('resolves Genesis-style compact prefixes', () => {
20
+ expect(getMirrorNameCandidate('rThighBend')).toMatchObject({
21
+ side: 'right',
22
+ convention: 'compact-prefix',
23
+ sourceToken: 'r',
24
+ mirrorToken: 'l',
25
+ mirrorName: 'lThighBend'
26
+ })
27
+
28
+ expect(getMirrorNameCandidate('lHand')).toMatchObject({
29
+ side: 'left',
30
+ mirrorName: 'rHand'
31
+ })
32
+ })
33
+
34
+ it('resolves underscore prefix and suffix names', () => {
35
+ expect(getMirrorNameCandidate('right_hand')).toMatchObject({
36
+ side: 'right',
37
+ convention: 'underscore-prefix',
38
+ mirrorName: 'left_hand'
39
+ })
40
+
41
+ expect(getMirrorNameCandidate('hand_R')).toMatchObject({
42
+ side: 'right',
43
+ convention: 'underscore-suffix',
44
+ mirrorName: 'hand_L'
45
+ })
46
+ })
47
+
48
+ it('resolves word prefix and suffix names', () => {
49
+ expect(getMirrorNameCandidate('RightHand')).toMatchObject({
50
+ side: 'right',
51
+ convention: 'word-prefix',
52
+ mirrorName: 'LeftHand'
53
+ })
54
+
55
+ expect(getMirrorNameCandidate('HandLeft')).toMatchObject({
56
+ side: 'left',
57
+ convention: 'word-suffix',
58
+ mirrorName: 'HandRight'
59
+ })
60
+ })
61
+
62
+ it('does not classify center bones as mirrored', () => {
63
+ expect(getMirrorNameCandidate('hip')).toBeNull()
64
+ expect(getMirrorNameCandidate('abdomenLower')).toBeNull()
65
+ })
66
+
67
+ it('returns rich match metadata while keeping getMirrorNodes compatible', () => {
68
+ const testFigure = figure(['lThighBend', 'hip'])
69
+ const source = node('rThighBend')
70
+ const center = node('hip')
71
+
72
+ const match = getMirrorNodeMatch(testFigure as unknown as DzSkeleton, source as unknown as DzNode)
73
+
74
+ expect(match.side).toBe('right')
75
+ expect(match.mirror?.getName()).toBe('lThighBend')
76
+ expect(getMirrorNodes(testFigure as unknown as DzSkeleton, [
77
+ source as unknown as DzNode,
78
+ center as unknown as DzNode
79
+ ]).map(item => item.getName())).toEqual(['lThighBend'])
80
+ })
81
+ })
@@ -10,18 +10,143 @@ export const getModifiers = (figure: DzSkeleton): DzModifier[] => {
10
10
  return modifiers
11
11
  }
12
12
 
13
+ export type MirrorSide = 'left' | 'right'
14
+
15
+ export type MirrorNameConvention =
16
+ | 'compact-prefix'
17
+ | 'underscore-prefix'
18
+ | 'underscore-suffix'
19
+ | 'word-prefix'
20
+ | 'word-suffix'
21
+
22
+ export type MirrorNodeMatch = {
23
+ source: DzNode
24
+ mirror: DzNode | null
25
+ side: MirrorSide | null
26
+ convention: MirrorNameConvention | null
27
+ sourceToken: string | null
28
+ mirrorToken: string | null
29
+ mirrorName: string | null
30
+ }
31
+
32
+ type MirrorNameCandidate = Omit<MirrorNodeMatch, 'source' | 'mirror'>
33
+
34
+ const preserveCase = (source: string, replacement: string): string => {
35
+ if (source.toUpperCase() === source) return replacement.toUpperCase()
36
+ if (source.toLowerCase() === source) return replacement.toLowerCase()
37
+ if (source.length > 0 && source[0].toUpperCase() === source[0]) {
38
+ return replacement[0].toUpperCase() + replacement.substring(1).toLowerCase()
39
+ }
40
+
41
+ return replacement
42
+ }
43
+
44
+ export const getMirrorNameCandidate = (name: string): MirrorNameCandidate | null => {
45
+ const compactPrefix = /^([lr])([A-Z].*)$/.exec(name)
46
+ if (compactPrefix) {
47
+ const sourceToken = compactPrefix[1]
48
+ const mirrorToken = sourceToken === 'r' ? 'l' : 'r'
49
+ return {
50
+ side: sourceToken === 'r' ? 'right' : 'left',
51
+ convention: 'compact-prefix',
52
+ sourceToken,
53
+ mirrorToken,
54
+ mirrorName: `${mirrorToken}${compactPrefix[2]}`
55
+ }
56
+ }
57
+
58
+ const underscorePrefix = /^(r|l|right|left)([_-].+)$/i.exec(name)
59
+ if (underscorePrefix) {
60
+ const sourceToken = underscorePrefix[1]
61
+ const side = sourceToken.toLowerCase()[0] === 'r' ? 'right' : 'left'
62
+ const mirrorToken = preserveCase(sourceToken, sourceToken.length === 1
63
+ ? (side === 'right' ? 'l' : 'r')
64
+ : (side === 'right' ? 'left' : 'right'))
65
+ return {
66
+ side,
67
+ convention: 'underscore-prefix',
68
+ sourceToken,
69
+ mirrorToken,
70
+ mirrorName: `${mirrorToken}${underscorePrefix[2]}`
71
+ }
72
+ }
73
+
74
+ const underscoreSuffix = /^(.+[_-])(r|l|right|left)$/i.exec(name)
75
+ if (underscoreSuffix) {
76
+ const sourceToken = underscoreSuffix[2]
77
+ const side = sourceToken.toLowerCase()[0] === 'r' ? 'right' : 'left'
78
+ const mirrorToken = preserveCase(sourceToken, sourceToken.length === 1
79
+ ? (side === 'right' ? 'l' : 'r')
80
+ : (side === 'right' ? 'left' : 'right'))
81
+ return {
82
+ side,
83
+ convention: 'underscore-suffix',
84
+ sourceToken,
85
+ mirrorToken,
86
+ mirrorName: `${underscoreSuffix[1]}${mirrorToken}`
87
+ }
88
+ }
89
+
90
+ const wordPrefix = /^(right|left)([A-Z].*)$/i.exec(name)
91
+ if (wordPrefix) {
92
+ const sourceToken = wordPrefix[1]
93
+ const side = sourceToken.toLowerCase() === 'right' ? 'right' : 'left'
94
+ const mirrorToken = preserveCase(sourceToken, side === 'right' ? 'left' : 'right')
95
+ return {
96
+ side,
97
+ convention: 'word-prefix',
98
+ sourceToken,
99
+ mirrorToken,
100
+ mirrorName: `${mirrorToken}${wordPrefix[2]}`
101
+ }
102
+ }
103
+
104
+ const wordSuffix = /^(.+[a-z])(Right|Left)$/i.exec(name)
105
+ if (wordSuffix) {
106
+ const sourceToken = wordSuffix[2]
107
+ const side = sourceToken.toLowerCase() === 'right' ? 'right' : 'left'
108
+ const mirrorToken = preserveCase(sourceToken, side === 'right' ? 'left' : 'right')
109
+ return {
110
+ side,
111
+ convention: 'word-suffix',
112
+ sourceToken,
113
+ mirrorToken,
114
+ mirrorName: `${wordSuffix[1]}${mirrorToken}`
115
+ }
116
+ }
117
+
118
+ return null
119
+ }
120
+
121
+ export const getMirrorNodeMatch = (figure: DzSkeleton, node: DzNode): MirrorNodeMatch => {
122
+ const name = node.getName().valueOf()
123
+ const candidate = getMirrorNameCandidate(name)
124
+
125
+ if (!candidate) {
126
+ return {
127
+ source: node,
128
+ mirror: null,
129
+ side: null,
130
+ convention: null,
131
+ sourceToken: null,
132
+ mirrorToken: null,
133
+ mirrorName: null
134
+ }
135
+ }
136
+
137
+ return {
138
+ source: node,
139
+ mirror: figure.findNodeChild(candidate.mirrorName, true),
140
+ ...candidate
141
+ }
142
+ }
143
+
144
+ export const getMirrorNodeMatches = (figure: DzSkeleton, nodes: DzNode[]): MirrorNodeMatch[] => {
145
+ return nodes.map(node => getMirrorNodeMatch(figure, node))
146
+ }
147
+
13
148
  export const getMirrorNodes = (figure: DzSkeleton, nodes: DzNode[]): DzNode[] => {
14
- let mirrorNodes: DzNode[] = []
15
-
16
- nodes.forEach((node) => {
17
- const name = node.getName().valueOf()
18
- let prefix = name[0]
19
- if (prefix !== 'r' && prefix !== 'l') return
20
- prefix = prefix === 'r' ? 'l' : 'r'
21
- const mirrorName = `${prefix}${name.substring(1)}`
22
- const mirrorNode = figure.findNodeChild(mirrorName, true)
23
- if (mirrorNode) mirrorNodes.push(mirrorNode)
24
- })
25
-
26
- return mirrorNodes
27
- }
149
+ return getMirrorNodeMatches(figure, nodes)
150
+ .map(match => match.mirror)
151
+ .filter(node => node !== null) as DzNode[]
152
+ }
@@ -3,7 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { afterEach, describe, expect, it } from 'vitest'
5
5
 
6
- const { initIntegrationTests, initUnitTests } = require('../../dist/scripts/init')
6
+ const { initIntegrationTests, initProbes, initUnitTests } = require('../../dist/scripts/init')
7
7
 
8
8
  const tempDirs: string[] = []
9
9
 
@@ -74,6 +74,48 @@ describe('init integration tests', () => {
74
74
  })
75
75
  })
76
76
 
77
+ describe('init probes', () => {
78
+ it('creates the probe fixture, docs, env examples, npm script, and ignore entries', () => {
79
+ const projectDir = makeProject('probe-test')
80
+ const fixtureBaseName = toFixtureName(projectDir)
81
+
82
+ initProbes(projectDir, { force: false })
83
+
84
+ const fixturePath = path.join(projectDir, `probes/fixtures/${fixtureBaseName}-scene.dsa.ts`)
85
+ const packageJson = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
86
+ const gitignore = fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf8')
87
+
88
+ expect(fs.existsSync(fixturePath)).toBe(true)
89
+ expect(fs.existsSync(path.join(projectDir, 'probes/README.md'))).toBe(true)
90
+ expect(fs.existsSync(path.join(projectDir, '.env.probe.linux.example'))).toBe(true)
91
+ expect(fs.existsSync(path.join(projectDir, '.env.probe.windows.example'))).toBe(true)
92
+ expect(packageJson.scripts.probe).toBe(
93
+ `dazscript probe --fixture ./probes/fixtures/${fixtureBaseName}-scene.dsa.ts`
94
+ )
95
+ expect(gitignore).toContain('probes/out/')
96
+ expect(gitignore).toContain('.env.probe.local')
97
+ })
98
+
99
+ it('does not replace an existing probe script or duplicate ignore entries', () => {
100
+ const projectDir = makeProject('existing-probe')
101
+ const packageJsonPath = path.join(projectDir, 'package.json')
102
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
103
+ packageJson.scripts.probe = 'custom probe'
104
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
105
+ fs.writeFileSync(path.join(projectDir, '.gitignore'), 'probes/out/\n')
106
+
107
+ initProbes(projectDir, { force: false })
108
+ initProbes(projectDir, { force: false })
109
+
110
+ const updatedPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
111
+ const gitignore = fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf8')
112
+
113
+ expect(updatedPackageJson.scripts.probe).toBe('custom probe')
114
+ expect(gitignore.match(/probes\/out\//g)?.length).toBe(1)
115
+ expect(gitignore.match(/\.env\.probe\.local/g)?.length).toBe(1)
116
+ })
117
+ })
118
+
77
119
  describe('init unit tests', () => {
78
120
  it('creates Vitest config, sample test, docs, npm scripts, and dev dependency', () => {
79
121
  const projectDir = makeProject('unit-test')
@@ -8,7 +8,9 @@ const {
8
8
  getFixtureBuildDependencies,
9
9
  getNpmInvocation,
10
10
  readIntegrationResult,
11
+ readProbeResult,
11
12
  resolveIntegrationOptions,
13
+ resolveProbeOptions,
12
14
  } = require('../../dist/scripts/integration')
13
15
 
14
16
  const tempDirs: string[] = []
@@ -80,6 +82,28 @@ describe('integration option resolution', () => {
80
82
  })
81
83
  })
82
84
 
85
+ describe('probe option resolution', () => {
86
+ it('uses probe-specific env and output defaults', () => {
87
+ const projectDir = makeProject()
88
+ fs.mkdirSync(path.join(projectDir, 'probes/fixtures'), { recursive: true })
89
+ fs.writeFileSync(path.join(projectDir, 'probes/fixtures/scene.dsa.ts'), 'action({}, function() {})\n')
90
+
91
+ const options = resolveProbeOptions(projectDir, {
92
+ fixture: './probes/fixtures/scene.dsa.ts',
93
+ }, {
94
+ DAZ_STUDIO_EXE: path.join(projectDir, 'DAZStudio.exe'),
95
+ DAZ_PROBE_TIMEOUT_MS: '123456',
96
+ })
97
+
98
+ expect(options.envFile).toBe(path.join(projectDir, '.env.probe.local'))
99
+ expect(options.outDir).toBe(path.join(projectDir, 'probes/out'))
100
+ expect(options.fixtureRoot).toBe(path.join(projectDir, 'probes/out/fixture'))
101
+ expect(options.resultPath).toBe(path.join(projectDir, 'probes/out/fixture/result.json'))
102
+ expect(options.timeoutMs).toBe(123456)
103
+ expect(options.commandName).toBe('probe')
104
+ })
105
+ })
106
+
83
107
  describe('integration command resolution', () => {
84
108
  it('uses node plus npm-cli on Windows so child_process can spawn without a shell', () => {
85
109
  const invocation = getNpmInvocation(['--version'], 'win32')
@@ -125,3 +149,21 @@ describe('integration result reader', () => {
125
149
  expect(() => readIntegrationResult(resultPath)).toThrow(/bad frame/)
126
150
  })
127
151
  })
152
+
153
+ describe('probe result reader', () => {
154
+ it('accepts arbitrary readable probe JSON without ok assertions', () => {
155
+ const projectDir = makeProject()
156
+ const resultPath = path.join(projectDir, 'result.json')
157
+ fs.writeFileSync(resultPath, JSON.stringify({
158
+ kind: 'daz-headless-probe',
159
+ status: 'inconclusive',
160
+ observations: { interfaceAvailable: false }
161
+ }))
162
+
163
+ expect(readProbeResult(resultPath)).toEqual({
164
+ kind: 'daz-headless-probe',
165
+ status: 'inconclusive',
166
+ observations: { interfaceAvailable: false }
167
+ })
168
+ })
169
+ })