@outputai/core 0.3.3-next.e8eff63.0 → 0.4.1-dev.622e67b.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.
@@ -1,15 +1,268 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
- import { join, sep } from 'node:path';
4
- import { importComponents, staticMatchers } from './loader_tools.js';
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, sep } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { pathToFileURL } from 'node:url';
6
+ import {
7
+ activityMatchersBuilder,
8
+ matchFiles,
9
+ findWorkflowsInNodeModules,
10
+ findWorkflowsInPackages,
11
+ findSharedActivitiesFromWorkflows,
12
+ importComponents,
13
+ findPackageRoot,
14
+ isPackageRoot,
15
+ isPathDescendentFromNodeModules,
16
+ resolveNodeModulesPath,
17
+ resolveSymlink,
18
+ staticMatchers,
19
+ packageExposesWorkflows
20
+ } from './loader_tools.js';
5
21
 
6
- describe( '.importComponents', () => {
7
- const TEMP_BASE = join( process.cwd(), 'sdk/core/temp_test_modules' );
8
- afterEach( () => {
9
- rmSync( TEMP_BASE, { recursive: true, force: true } );
22
+ const TEMP_BASE = join( process.cwd(), 'sdk/core/temp_test_modules' );
23
+
24
+ afterEach( () => {
25
+ rmSync( TEMP_BASE, { recursive: true, force: true } );
26
+ } );
27
+
28
+ const fileEntry = path => ( { path, url: pathToFileURL( path ).href } );
29
+
30
+ describe( 'resolveSymlink', () => {
31
+ it( 'returns the canonical path for a directory', () => {
32
+ const dir = join( TEMP_BASE, `rs-dir-${Date.now()}` );
33
+ mkdirSync( dir, { recursive: true } );
34
+ expect( resolveSymlink( dir ) ).toBe( dir );
35
+ } );
36
+
37
+ it( 'returns the canonical path for a regular file', () => {
38
+ const dir = join( TEMP_BASE, `rs-file-${Date.now()}` );
39
+ mkdirSync( dir, { recursive: true } );
40
+ const file = join( dir, 'a.txt' );
41
+ writeFileSync( file, 'x' );
42
+ expect( resolveSymlink( file ) ).toBe( file );
43
+ } );
44
+
45
+ it( 'returns null for a broken symlink', () => {
46
+ const dir = join( TEMP_BASE, `rs-broken-${Date.now()}` );
47
+ mkdirSync( dir, { recursive: true } );
48
+ const link = join( dir, 'broken' );
49
+ symlinkSync( 'missing-target', link, 'file' );
50
+ expect( resolveSymlink( link ) ).toBe( null );
51
+ } );
52
+ } );
53
+
54
+ describe( 'resolveNodeModulesPath', () => {
55
+ it( 'returns node_modules when passed that directory', () => {
56
+ const root = join( TEMP_BASE, `rnm-direct-${Date.now()}` );
57
+ const nm = join( root, 'node_modules' );
58
+ mkdirSync( nm, { recursive: true } );
59
+ expect( resolveNodeModulesPath( nm ) ).toBe( nm );
60
+ } );
61
+
62
+ it( 'finds node_modules from a project root directory', () => {
63
+ const root = join( TEMP_BASE, `rnm-root-${Date.now()}` );
64
+ const nm = join( root, 'node_modules' );
65
+ mkdirSync( nm, { recursive: true } );
66
+ expect( resolveNodeModulesPath( root ) ).toBe( nm );
67
+ } );
68
+
69
+ it( 'walks upward from a nested path until node_modules is found', () => {
70
+ const root = join( TEMP_BASE, `rnm-walk-${Date.now()}` );
71
+ const nm = join( root, 'node_modules' );
72
+ const nested = join( root, 'src', 'deep' );
73
+ mkdirSync( nested, { recursive: true } );
74
+ mkdirSync( nm, { recursive: true } );
75
+ expect( resolveNodeModulesPath( nested ) ).toBe( nm );
76
+ } );
77
+
78
+ it( 'uses dirname when targetPath is a file', () => {
79
+ const root = join( TEMP_BASE, `rnm-file-${Date.now()}` );
80
+ const nm = join( root, 'node_modules' );
81
+ const file = join( root, 'index.js' );
82
+ mkdirSync( nm, { recursive: true } );
83
+ writeFileSync( file, '' );
84
+ expect( resolveNodeModulesPath( file ) ).toBe( nm );
85
+ } );
86
+
87
+ it( 'returns null when targetPath does not exist', () => {
88
+ expect( resolveNodeModulesPath( join( TEMP_BASE, 'nope', 'missing' ) ) ).toBe( null );
89
+ } );
90
+
91
+ it( 'returns null when no ancestor has node_modules', () => {
92
+ const isolated = mkdtempSync( join( tmpdir(), 'loader-tools-rnm-' ) );
93
+ try {
94
+ const orphan = join( isolated, 'nested', 'sub' );
95
+ mkdirSync( orphan, { recursive: true } );
96
+ expect( resolveNodeModulesPath( orphan ) ).toBe( null );
97
+ } finally {
98
+ rmSync( isolated, { recursive: true, force: true } );
99
+ }
100
+ } );
101
+
102
+ it( 'returns the canonical directory when node_modules is a symlink', () => {
103
+ const root = join( TEMP_BASE, `rnm-symlink-${Date.now()}` );
104
+ const realNm = join( root, 'real_node_modules' );
105
+ const nmLink = join( root, 'node_modules' );
106
+ mkdirSync( realNm, { recursive: true } );
107
+ symlinkSync( 'real_node_modules', nmLink, 'dir' );
108
+ expect( resolveNodeModulesPath( root ) ).toBe( realNm );
109
+ } );
110
+
111
+ it( 'walks up when node_modules is a broken symlink', () => {
112
+ const grandparent = join( TEMP_BASE, `rnm-walkup-${Date.now()}` );
113
+ const root = join( grandparent, 'proj' );
114
+ const nmBroken = join( root, 'node_modules' );
115
+ const nmUp = join( grandparent, 'node_modules' );
116
+ mkdirSync( nmUp, { recursive: true } );
117
+ mkdirSync( root, { recursive: true } );
118
+ symlinkSync( 'does-not-exist', nmBroken, 'dir' );
119
+ expect( resolveNodeModulesPath( root ) ).toBe( nmUp );
120
+ } );
121
+ } );
122
+
123
+ describe( 'node_modules package resource helpers', () => {
124
+ it( 'detects paths inside node_modules', () => {
125
+ expect( isPathDescendentFromNodeModules( `${sep}app${sep}node_modules${sep}pkg${sep}index.js` ) ).toBe( true );
126
+ expect( isPathDescendentFromNodeModules( 'C:\\app\\node_modules\\pkg\\index.js' ) ).toBe( true );
127
+ expect( isPathDescendentFromNodeModules( `${sep}app${sep}src${sep}index.js` ) ).toBe( false );
128
+ } );
129
+
130
+ it( 'detects installed package roots', () => {
131
+ expect( isPackageRoot( `${sep}app${sep}node_modules${sep}pkg` ) ).toBe( true );
132
+ expect( isPackageRoot( `${sep}app${sep}node_modules${sep}@scope${sep}pkg` ) ).toBe( true );
133
+ expect( isPackageRoot( `${sep}app${sep}node_modules${sep}@scope` ) ).toBe( false );
134
+ expect( isPackageRoot( `${sep}app${sep}node_modules${sep}pkg${sep}lib` ) ).toBe( false );
135
+ } );
136
+
137
+ it( 'finds the closest installed package root for a file', () => {
138
+ const root = join( TEMP_BASE, `pkgroot-${Date.now()}` );
139
+ const pkgRoot = join( root, 'node_modules', '@acme', 'wf_pkg' );
140
+ const deepFile = join( pkgRoot, 'lib', 'nested', 'workflow.js' );
141
+ mkdirSync( join( pkgRoot, 'lib', 'nested' ), { recursive: true } );
142
+ writeFileSync( join( pkgRoot, 'package.json' ), JSON.stringify( {
143
+ name: '@acme/wf_pkg',
144
+ dependencies: { '@outputai/core': '1.0.0' }
145
+ } ) );
146
+ writeFileSync( deepFile, 'export default {};\n' );
147
+
148
+ expect( findPackageRoot( deepFile ) ).toBe( pkgRoot );
149
+ } );
150
+
151
+ it( 'returns null when no installed package root is found', () => {
152
+ const root = join( TEMP_BASE, `pkgroot-missing-${Date.now()}` );
153
+ const deepFile = join( root, 'node_modules', 'plain_lib', 'index.js' );
154
+ mkdirSync( dirname( deepFile ), { recursive: true } );
155
+ writeFileSync( deepFile, 'export const x = 1;\n' );
156
+
157
+ expect( findPackageRoot( deepFile ) ).toBe( null );
158
+ } );
159
+ } );
160
+
161
+ describe( 'activityMatchersBuilder', () => {
162
+ const base = `${sep}app${sep}proj`;
163
+
164
+ it( 'stepsFile matches only steps.js at base', () => {
165
+ const m = activityMatchersBuilder( base );
166
+ expect( m.stepsFile( `${base}${sep}steps.js` ) ).toBe( true );
167
+ expect( m.stepsFile( `${base}${sep}nested${sep}steps.js` ) ).toBe( false );
10
168
  } );
169
+
170
+ it( 'evaluatorsFile matches only evaluators.js at base', () => {
171
+ const m = activityMatchersBuilder( base );
172
+ expect( m.evaluatorsFile( `${base}${sep}evaluators.js` ) ).toBe( true );
173
+ expect( m.evaluatorsFile( `${base}${sep}sub${sep}evaluators.js` ) ).toBe( false );
174
+ } );
175
+
176
+ it( 'stepsDir matches js under steps/', () => {
177
+ const m = activityMatchersBuilder( base );
178
+ expect( m.stepsDir( `${base}${sep}steps${sep}a.js` ) ).toBe( true );
179
+ expect( m.stepsDir( `${base}${sep}steps${sep}sub${sep}b.js` ) ).toBe( true );
180
+ expect( m.stepsDir( `${base}${sep}other${sep}a.js` ) ).toBe( false );
181
+ } );
182
+
183
+ it( 'evaluatorsDir matches js under evaluators/', () => {
184
+ const m = activityMatchersBuilder( base );
185
+ expect( m.evaluatorsDir( `${base}${sep}evaluators${sep}x.js` ) ).toBe( true );
186
+ expect( m.evaluatorsDir( `${base}${sep}evaluators${sep}y${sep}z.js` ) ).toBe( true );
187
+ expect( m.evaluatorsDir( `${base}${sep}steps${sep}x.js` ) ).toBe( false );
188
+ } );
189
+ } );
190
+
191
+ describe( 'matchFiles', () => {
192
+ it( 'collects files matching matchers', () => {
193
+ const root = join( TEMP_BASE, `fbnr-files-${Date.now()}` );
194
+ mkdirSync( root, { recursive: true } );
195
+ writeFileSync( join( root, 'a.txt' ), '' );
196
+ writeFileSync( join( root, 'b.txt' ), '' );
197
+ const found = matchFiles( root, [ p => p.endsWith( 'a.txt' ) ] );
198
+ expect( found ).toHaveLength( 1 );
199
+ expect( found[0].path ).toBe( join( root, 'a.txt' ) );
200
+ } );
201
+
202
+ it( 'skips broken symlinks without throwing', () => {
203
+ const root = join( TEMP_BASE, `fbnr-broken-${Date.now()}` );
204
+ mkdirSync( root, { recursive: true } );
205
+ symlinkSync( 'nowhere', join( root, 'bad' ), 'file' );
206
+ writeFileSync( join( root, 'ok.txt' ), '' );
207
+ const found = matchFiles( root, [ p => p.endsWith( '.txt' ) ] );
208
+ expect( found.map( f => f.path ) ).toEqual( [ join( root, 'ok.txt' ) ] );
209
+ } );
210
+
211
+ it( 'follows symlinks to directories', () => {
212
+ const base = join( TEMP_BASE, `fbnr-symlink-dir-${Date.now()}` );
213
+ const targetDir = join( base, 'target' );
214
+ const scanRoot = join( base, 'root' );
215
+ mkdirSync( targetDir, { recursive: true } );
216
+ mkdirSync( scanRoot, { recursive: true } );
217
+ writeFileSync( join( targetDir, 'x.txt' ), '' );
218
+ symlinkSync( join( '..', 'target' ), join( scanRoot, 'link' ), 'dir' );
219
+ const found = matchFiles( scanRoot, [ p => p.endsWith( 'x.txt' ) ] );
220
+ expect( found ).toHaveLength( 1 );
221
+ expect( found[0].path ).toBe( join( targetDir, 'x.txt' ) );
222
+ } );
223
+ } );
224
+
225
+ describe( 'findWorkflowsInPackages', () => {
226
+ it( 'collects workflow.js from exposed workflow packages under node_modules', () => {
227
+ const root = join( TEMP_BASE, `fwp-${Date.now()}` );
228
+ const nm = join( root, 'node_modules' );
229
+ const pkg = join( nm, 'wf_pkg' );
230
+ mkdirSync( join( pkg, 'lib' ), { recursive: true } );
231
+ writeFileSync( join( pkg, 'package.json' ), JSON.stringify( {
232
+ name: 'wf_pkg',
233
+ outputai: { workflows: { expose: true } }
234
+ } ) );
235
+ const wf = join( pkg, 'lib', 'workflow.js' );
236
+ writeFileSync( wf, 'export default {};\n' );
237
+
238
+ const found = findWorkflowsInPackages( nm );
239
+ expect( found ).toHaveLength( 1 );
240
+ expect( found[0].path ).toBe( wf );
241
+ } );
242
+
243
+ it( 'lists the same workflow twice when package scan sees a canonical package and symlink alias', () => {
244
+ const root = join( TEMP_BASE, `fwp-symlink-${Date.now()}` );
245
+ const nm = join( root, 'node_modules' );
246
+ const realPkg = join( nm, 'real_pkg' );
247
+ const linkPkg = join( nm, 'link_pkg' );
248
+ mkdirSync( join( realPkg, 'w' ), { recursive: true } );
249
+ const wf = join( realPkg, 'w', 'workflow.js' );
250
+ writeFileSync( join( realPkg, 'package.json' ), JSON.stringify( {
251
+ name: 'real_pkg',
252
+ outputai: { workflows: { expose: true } }
253
+ } ) );
254
+ writeFileSync( wf, 'export default {};\n' );
255
+ symlinkSync( 'real_pkg', linkPkg, 'dir' );
256
+
257
+ const found = findWorkflowsInPackages( nm );
258
+ expect( found ).toHaveLength( 2 );
259
+ expect( new Set( found.map( f => realpathSync( f.path ) ) ).size ).toBe( 1 );
260
+ } );
261
+ } );
262
+
263
+ describe( 'importComponents', () => {
11
264
  it( 'imports modules and yields metadata from exports tagged with METADATA_ACCESS_SYMBOL', async () => {
12
- const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}` );
265
+ const root = join( TEMP_BASE, `meta-${Date.now()}` );
13
266
  mkdirSync( root, { recursive: true } );
14
267
  const file = join( root, 'meta.module.js' );
15
268
  writeFileSync( file, [
@@ -21,7 +274,7 @@ describe( '.importComponents', () => {
21
274
  ].join( '\n' ) );
22
275
 
23
276
  const collected = [];
24
- for await ( const m of importComponents( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) {
277
+ for await ( const m of importComponents( [ fileEntry( file ) ] ) ) {
25
278
  collected.push( m );
26
279
  }
27
280
 
@@ -32,12 +285,10 @@ describe( '.importComponents', () => {
32
285
  expect( m.path ).toBe( file );
33
286
  expect( typeof m.fn ).toBe( 'function' );
34
287
  }
35
-
36
- rmSync( root, { recursive: true, force: true } );
37
288
  } );
38
289
 
39
290
  it( 'ignores exports without metadata symbol', async () => {
40
- const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-nometa` );
291
+ const root = join( TEMP_BASE, `meta-${Date.now()}-nometa` );
41
292
  mkdirSync( root, { recursive: true } );
42
293
  const file = join( root, 'meta.module.js' );
43
294
  writeFileSync( file, [
@@ -46,16 +297,15 @@ describe( '.importComponents', () => {
46
297
  ].join( '\n' ) );
47
298
 
48
299
  const collected = [];
49
- for await ( const m of importComponents( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) {
300
+ for await ( const m of importComponents( [ fileEntry( file ) ] ) ) {
50
301
  collected.push( m );
51
302
  }
52
303
 
53
304
  expect( collected.length ).toBe( 0 );
54
- rmSync( root, { recursive: true, force: true } );
55
305
  } );
56
306
 
57
307
  it( 'skips files inside ignored directories (node_modules, vendor)', async () => {
58
- const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-ignoredirs` );
308
+ const root = join( TEMP_BASE, `meta-${Date.now()}-ignoredirs` );
59
309
  const okDir = join( root, 'ok' );
60
310
  const nmDir = join( root, 'node_modules' );
61
311
  const vendorDir = join( root, 'vendor' );
@@ -77,18 +327,16 @@ describe( '.importComponents', () => {
77
327
  writeFileSync( vendorFile, fileContents );
78
328
 
79
329
  const collected = [];
80
- for await ( const m of importComponents( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) {
330
+ for await ( const m of importComponents( matchFiles( root, [ v => v.endsWith( 'meta.module.js' ) ] ) ) ) {
81
331
  collected.push( m );
82
332
  }
83
333
 
84
334
  expect( collected.length ).toBe( 1 );
85
335
  expect( collected[0].path ).toBe( okFile );
86
-
87
- rmSync( root, { recursive: true, force: true } );
88
336
  } );
89
337
 
90
338
  it( 'supports partial matching by folder name', async () => {
91
- const root = join( process.cwd(), 'sdk/core/temp_test_modules', `meta-${Date.now()}-foldermatch` );
339
+ const root = join( TEMP_BASE, `meta-${Date.now()}-foldermatch` );
92
340
  const okDir = join( root, 'features', 'ok' );
93
341
  const otherDir = join( root, 'features', 'other' );
94
342
  mkdirSync( okDir, { recursive: true } );
@@ -104,21 +352,244 @@ describe( '.importComponents', () => {
104
352
  writeFileSync( okFile, src );
105
353
  writeFileSync( otherFile, src );
106
354
 
107
- // Match any JS under a folder named "ok"
108
355
  const matcher = v => v.includes( `${join( 'features', 'ok' )}${sep}` );
109
356
  const collected = [];
110
- for await ( const m of importComponents( root, [ matcher ] ) ) {
357
+ for await ( const m of importComponents( matchFiles( root, [ matcher ] ) ) ) {
111
358
  collected.push( m );
112
359
  }
113
360
  expect( collected.length ).toBe( 1 );
114
361
  expect( collected[0].path ).toBe( okFile );
362
+ } );
363
+
364
+ it( 'follows symlinks to directories and collects matching files inside the target', async () => {
365
+ const base = join( TEMP_BASE, `symlink-dir-${Date.now()}` );
366
+ const targetDir = join( base, 'target' );
367
+ const scanRoot = join( base, 'root' );
368
+ mkdirSync( targetDir, { recursive: true } );
369
+ mkdirSync( scanRoot, { recursive: true } );
370
+ const moduleFile = join( targetDir, 'meta.module.js' );
371
+ writeFileSync( moduleFile, [
372
+ 'import { METADATA_ACCESS_SYMBOL } from "#consts";',
373
+ 'export const ViaLink = () => {};',
374
+ 'ViaLink[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "via_link" };'
375
+ ].join( '\n' ) );
376
+
377
+ symlinkSync( join( '..', 'target' ), join( scanRoot, 'pkg' ), 'dir' );
378
+
379
+ const collected = [];
380
+ for await ( const m of importComponents( matchFiles( scanRoot, [ v => v.endsWith( 'meta.module.js' ) ] ) ) ) {
381
+ collected.push( m );
382
+ }
383
+
384
+ expect( collected.length ).toBe( 1 );
385
+ expect( collected[0].path ).toBe( moduleFile );
386
+ expect( collected[0].metadata.name ).toBe( 'via_link' );
387
+ } );
388
+
389
+ it( 'collects a symlinked file when the matcher matches the realpath target (canonical url)', async () => {
390
+ const root = join( TEMP_BASE, `symlink-file-${Date.now()}` );
391
+ mkdirSync( root, { recursive: true } );
392
+ const realFile = join( root, 'real.module.js' );
393
+ writeFileSync( realFile, [
394
+ 'import { METADATA_ACCESS_SYMBOL } from "#consts";',
395
+ 'export const R = () => {};',
396
+ 'R[METADATA_ACCESS_SYMBOL] = { kind: "step", name: "r" };'
397
+ ].join( '\n' ) );
398
+ const linkPath = join( root, 'alias.module.js' );
399
+ symlinkSync( 'real.module.js', linkPath, 'file' );
400
+
401
+ const collected = [];
402
+ for await ( const m of importComponents( matchFiles( root, [ v => v.endsWith( '.module.js' ) ] ) ) ) {
403
+ collected.push( m );
404
+ }
405
+
406
+ expect( collected.length ).toBe( 2 );
407
+ expect( collected.every( m => m.metadata.name === 'r' ) ).toBe( true );
408
+ expect( collected[0].fn ).toBe( collected[1].fn );
409
+ } );
410
+ } );
411
+
412
+ describe( 'packageExposesWorkflows', () => {
413
+ it( 'returns true when outputai.workflows.expose is true', () => {
414
+ const dir = join( TEMP_BASE, `wf-proj-expose-${Date.now()}` );
415
+ mkdirSync( dir, { recursive: true } );
416
+ const pkg = join( dir, 'package.json' );
417
+ writeFileSync( pkg, JSON.stringify( { outputai: { workflows: { expose: true } } } ) );
418
+ expect( packageExposesWorkflows( pkg ) ).toBe( true );
419
+ } );
420
+
421
+ it( 'returns false when outputai.workflows.expose is false', () => {
422
+ const dir = join( TEMP_BASE, `wf-proj-no-expose-${Date.now()}` );
423
+ mkdirSync( dir, { recursive: true } );
424
+ const pkg = join( dir, 'package.json' );
425
+ writeFileSync( pkg, JSON.stringify( { outputai: { workflows: { expose: false } } } ) );
426
+ expect( packageExposesWorkflows( pkg ) ).toBe( false );
427
+ } );
428
+
429
+ it( 'returns false for the legacy output.workflows.expose field', () => {
430
+ const dir = join( TEMP_BASE, `wf-proj-legacy-expose-${Date.now()}` );
431
+ mkdirSync( dir, { recursive: true } );
432
+ const pkg = join( dir, 'package.json' );
433
+ writeFileSync( pkg, JSON.stringify( { output: { workflows: { expose: true } } } ) );
434
+ expect( packageExposesWorkflows( pkg ) ).toBe( false );
435
+ } );
436
+
437
+ it( 'returns false when only OutputAI dependencies are present', () => {
438
+ const dir = join( TEMP_BASE, `wf-proj-dep-only-${Date.now()}` );
439
+ mkdirSync( dir, { recursive: true } );
440
+ const pkg = join( dir, 'package.json' );
441
+ writeFileSync( pkg, JSON.stringify( { dependencies: { '@outputai/core': '1.0.0' } } ) );
442
+ expect( packageExposesWorkflows( pkg ) ).toBe( false );
443
+ } );
444
+
445
+ it( 'returns false when package.json is missing', () => {
446
+ expect( packageExposesWorkflows( join( TEMP_BASE, 'missing-package-json', 'package.json' ) ) ).toBe( false );
447
+ } );
115
448
 
116
- rmSync( root, { recursive: true, force: true } );
449
+ it( 'returns false when package.json is not valid JSON', () => {
450
+ const dir = join( TEMP_BASE, `wf-proj-badjson-${Date.now()}` );
451
+ mkdirSync( dir, { recursive: true } );
452
+ const pkg = join( dir, 'package.json' );
453
+ writeFileSync( pkg, '{ not json' );
454
+ expect( packageExposesWorkflows( pkg ) ).toBe( false );
117
455
  } );
118
456
  } );
119
457
 
120
- describe( '.staticMatchers', () => {
121
- describe( '.sharedStepsDir', () => {
458
+ describe( 'findWorkflowsInNodeModules', () => {
459
+ it( 'resolves node_modules from project root and finds workflows', () => {
460
+ const root = join( TEMP_BASE, `nm-from-root-${Date.now()}` );
461
+ const nm = join( root, 'node_modules' );
462
+ const pkg = join( nm, 'pkg_a' );
463
+ mkdirSync( join( pkg, 'w' ), { recursive: true } );
464
+ writeFileSync( join( pkg, 'package.json' ), JSON.stringify( {
465
+ name: 'pkg_a',
466
+ outputai: { workflows: { expose: true } }
467
+ } ) );
468
+ const wf = join( pkg, 'w', 'workflow.js' );
469
+ writeFileSync( wf, 'export default {};\n' );
470
+
471
+ const found = findWorkflowsInNodeModules( root );
472
+ expect( found.length ).toBe( 1 );
473
+ expect( found[0].path ).toBe( wf );
474
+ } );
475
+
476
+ it( 'finds workflow.js under an unscoped package with exposed workflows', () => {
477
+ const root = join( TEMP_BASE, `nm-unscoped-${Date.now()}` );
478
+ const nm = join( root, 'node_modules' );
479
+ const pkg = join( nm, 'catalog_pkg' );
480
+ mkdirSync( join( pkg, 'workflows', 'a' ), { recursive: true } );
481
+ writeFileSync( join( pkg, 'package.json' ), JSON.stringify( {
482
+ name: 'catalog_pkg',
483
+ outputai: { workflows: { expose: true } }
484
+ } ) );
485
+ writeFileSync( join( pkg, 'workflows', 'a', 'workflow.js' ), 'export default {};\n' );
486
+
487
+ const found = findWorkflowsInNodeModules( nm );
488
+ expect( found.length ).toBe( 1 );
489
+ expect( found[0].path ).toBe( join( pkg, 'workflows', 'a', 'workflow.js' ) );
490
+ expect( found[0].url ).toBe( pathToFileURL( join( pkg, 'workflows', 'a', 'workflow.js' ) ).href );
491
+ } );
492
+
493
+ it( 'finds workflow.js under a scoped package', () => {
494
+ const root = join( TEMP_BASE, `nm-scoped-${Date.now()}` );
495
+ const nm = join( root, 'node_modules' );
496
+ const pkg = join( nm, '@acme', 'wf_pkg' );
497
+ mkdirSync( join( pkg, 'lib' ), { recursive: true } );
498
+ writeFileSync( join( pkg, 'package.json' ), JSON.stringify( {
499
+ name: '@acme/wf_pkg',
500
+ outputai: { workflows: { expose: true } }
501
+ } ) );
502
+ writeFileSync( join( pkg, 'lib', 'workflow.js' ), 'export default {};\n' );
503
+
504
+ const found = findWorkflowsInNodeModules( nm );
505
+ expect( found.length ).toBe( 1 );
506
+ expect( found[0].path ).toBe( join( pkg, 'lib', 'workflow.js' ) );
507
+ } );
508
+
509
+ it( 'skips packages that do not expose workflows', () => {
510
+ const root = join( TEMP_BASE, `nm-skip-${Date.now()}` );
511
+ const nm = join( root, 'node_modules' );
512
+ const pkg = join( nm, 'plain_lib' );
513
+ mkdirSync( pkg, { recursive: true } );
514
+ writeFileSync( join( pkg, 'package.json' ), JSON.stringify( { name: 'plain_lib' } ) );
515
+ writeFileSync( join( pkg, 'workflow.js' ), 'export default {};\n' );
516
+
517
+ expect( findWorkflowsInNodeModules( nm ) ).toEqual( [] );
518
+ } );
519
+
520
+ it( 'deduplicates the same workflow when reachable via symlink alias and canonical package', () => {
521
+ const root = join( TEMP_BASE, `nm-dedupe-${Date.now()}` );
522
+ const nm = join( root, 'node_modules' );
523
+ const realPkg = join( nm, 'real_pkg' );
524
+ const linkPkg = join( nm, 'link_pkg' );
525
+ mkdirSync( join( realPkg, 'w' ), { recursive: true } );
526
+ const wf = join( realPkg, 'w', 'workflow.js' );
527
+ writeFileSync( join( realPkg, 'package.json' ), JSON.stringify( {
528
+ name: 'real_pkg',
529
+ outputai: { workflows: { expose: true } }
530
+ } ) );
531
+ writeFileSync( wf, 'export default {};\n' );
532
+ symlinkSync( 'real_pkg', linkPkg, 'dir' );
533
+
534
+ const found = findWorkflowsInNodeModules( nm );
535
+ expect( found ).toHaveLength( 1 );
536
+ expect( realpathSync( found[0].path ) ).toBe( wf );
537
+ } );
538
+ } );
539
+
540
+ describe( 'findSharedActivitiesFromWorkflows', () => {
541
+ it( 'finds shared steps and evaluators from external workflow package roots', () => {
542
+ const root = join( TEMP_BASE, `external-shared-${Date.now()}` );
543
+ const pkg = join( root, 'node_modules', '@acme', 'workflow_pkg' );
544
+ const workflowA = join( pkg, 'workflows', 'a', 'workflow.js' );
545
+ const workflowB = join( pkg, 'workflows', 'b', 'workflow.js' );
546
+ const sharedStep = join( pkg, 'shared', 'steps', 'prepare.js' );
547
+ const sharedEvaluator = join( pkg, 'shared', 'evaluators', 'quality.js' );
548
+ mkdirSync( dirname( workflowA ), { recursive: true } );
549
+ mkdirSync( dirname( workflowB ), { recursive: true } );
550
+ mkdirSync( dirname( sharedStep ), { recursive: true } );
551
+ mkdirSync( dirname( sharedEvaluator ), { recursive: true } );
552
+ writeFileSync( join( pkg, 'package.json' ), JSON.stringify( {
553
+ name: '@acme/workflow_pkg',
554
+ dependencies: { '@outputai/core': '1.0.0' }
555
+ } ) );
556
+ writeFileSync( workflowA, 'export default {};\n' );
557
+ writeFileSync( workflowB, 'export default {};\n' );
558
+ writeFileSync( sharedStep, 'export const Prepare = step({ name: "prepare" });\n' );
559
+ writeFileSync( sharedEvaluator, 'export const Quality = evaluator({ name: "quality" });\n' );
560
+ writeFileSync( join( pkg, 'shared', 'readme.md' ), '# ignored\n' );
561
+
562
+ const found = findSharedActivitiesFromWorkflows( [
563
+ { path: workflowA },
564
+ { path: workflowB },
565
+ { path: join( root, 'local', 'workflow.js' ) }
566
+ ] );
567
+ expect( found.map( f => f.path ).sort() ).toEqual( [ sharedEvaluator, sharedStep ].sort() );
568
+ } );
569
+ } );
570
+
571
+ describe( 'staticMatchers', () => {
572
+ describe( 'workflowFile', () => {
573
+ it( 'matches paths ending with path separator and workflow.js', () => {
574
+ expect( staticMatchers.workflowFile( `${sep}x${sep}y${sep}workflow.js` ) ).toBe( true );
575
+ } );
576
+
577
+ it( 'rejects workflow.ts', () => {
578
+ expect( staticMatchers.workflowFile( `${sep}a${sep}workflow.ts` ) ).toBe( false );
579
+ } );
580
+ } );
581
+
582
+ describe( 'workflowPathHasShared', () => {
583
+ it( 'matches workflow.js under a shared folder segment', () => {
584
+ expect( staticMatchers.workflowPathHasShared( `${sep}foo${sep}shared${sep}workflow.js` ) ).toBe( true );
585
+ } );
586
+
587
+ it( 'rejects workflow.js not under shared', () => {
588
+ expect( staticMatchers.workflowPathHasShared( `${sep}foo${sep}workflow.js` ) ).toBe( false );
589
+ } );
590
+ } );
591
+
592
+ describe( 'sharedStepsDir', () => {
122
593
  it( 'matches .js files inside shared/steps/', () => {
123
594
  expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}tools.js` ) ).toBe( true );
124
595
  } );
@@ -136,7 +607,7 @@ describe( '.staticMatchers', () => {
136
607
  } );
137
608
  } );
138
609
 
139
- describe( '.sharedEvaluatorsDir', () => {
610
+ describe( 'sharedEvaluatorsDir', () => {
140
611
  it( 'matches .js files inside shared/evaluators/', () => {
141
612
  expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}quality.js` ) ).toBe( true );
142
613
  } );