@outputai/core 0.3.3-next.e8eff63.0 → 0.4.1-dev.06c2b50.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,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
+ import { staticMatchers } from './loader_tools.js';
4
5
 
5
6
  vi.mock( '#consts', () => ( {
6
7
  ACTIVITY_SEND_HTTP_REQUEST: '__internal#sendHttpRequest',
@@ -18,10 +19,22 @@ vi.mock( '#internal_activities', () => ( {
18
19
  getTraceDestinations: getTraceDestinationsMock
19
20
  } ) );
20
21
 
21
- const importComponentsMock = vi.fn();
22
+ const { importComponentsMock, findSharedActivitiesFromWorkflowsMock, findWorkflowsInNodeModulesMock, matchFilesMock } = vi.hoisted( () => ( {
23
+ importComponentsMock: vi.fn(),
24
+ findSharedActivitiesFromWorkflowsMock: vi.fn(),
25
+ findWorkflowsInNodeModulesMock: vi.fn(),
26
+ matchFilesMock: vi.fn()
27
+ } ) );
28
+
22
29
  vi.mock( './loader_tools.js', async importOriginal => {
23
30
  const actual = await importOriginal();
24
- return { ...actual, importComponents: importComponentsMock };
31
+ return {
32
+ ...actual,
33
+ importComponents: importComponentsMock,
34
+ findSharedActivitiesFromWorkflows: findSharedActivitiesFromWorkflowsMock,
35
+ findWorkflowsInNodeModules: findWorkflowsInNodeModulesMock,
36
+ matchFiles: matchFilesMock
37
+ };
25
38
  } );
26
39
 
27
40
  const fsMocks = vi.hoisted( () => ( {
@@ -38,12 +51,19 @@ vi.mock( 'node:fs', () => ( {
38
51
  describe( 'worker/loader', () => {
39
52
  beforeEach( () => {
40
53
  vi.clearAllMocks();
54
+ importComponentsMock.mockReset();
55
+ importComponentsMock.mockImplementation( async function *() {} );
56
+ findSharedActivitiesFromWorkflowsMock.mockReset();
57
+ findSharedActivitiesFromWorkflowsMock.mockReturnValue( [] );
58
+ findWorkflowsInNodeModulesMock.mockReset();
59
+ findWorkflowsInNodeModulesMock.mockReturnValue( [] );
60
+ matchFilesMock.mockReset();
61
+ matchFilesMock.mockReturnValue( [] );
41
62
  } );
42
63
 
43
64
  it( 'loadActivities returns map including system activity and writes options file', async () => {
44
65
  const { loadActivities } = await import( './loader.js' );
45
66
 
46
- // First call: workflow directory scan (options.activityOptions propagated to activity options file)
47
67
  importComponentsMock.mockImplementationOnce( async function *() {
48
68
  yield {
49
69
  fn: () => {},
@@ -51,7 +71,6 @@ describe( 'worker/loader', () => {
51
71
  path: '/a/steps.js'
52
72
  };
53
73
  } );
54
- // Second call: shared activities scan (no results)
55
74
  importComponentsMock.mockImplementationOnce( async function *() {} );
56
75
 
57
76
  const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
@@ -59,7 +78,6 @@ describe( 'worker/loader', () => {
59
78
  expect( activities['A#Act1'] ).toBeTypeOf( 'function' );
60
79
  expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
61
80
 
62
- // options file written with the collected activityOptions map
63
81
  expect( fsMocks.writeFileSync ).toHaveBeenCalledTimes( 1 );
64
82
  const [ writtenPath, contents ] = fsMocks.writeFileSync.mock.calls[0];
65
83
  expect( writtenPath ).toMatch( /temp\/__activity_options\.js$/ );
@@ -86,15 +104,212 @@ describe( 'worker/loader', () => {
86
104
  expect( written['A#EmptyOptions'] ).toBeUndefined();
87
105
  } );
88
106
 
89
- it( 'loadWorkflows returns array of workflows with metadata', async () => {
90
- const { loadWorkflows } = await import( './loader.js' );
107
+ it( 'loadActivities throws when two activities in the same workflow share a name', async () => {
108
+ const { loadActivities } = await import( './loader.js' );
109
+ importComponentsMock.mockImplementationOnce( async function *() {
110
+ yield { fn: () => {}, metadata: { name: 'DuplicateActivity' }, path: '/a/steps.js' };
111
+ yield { fn: () => {}, metadata: { name: 'DuplicateActivity' }, path: '/a/evaluators.js' };
112
+ } );
91
113
 
114
+ await expect( loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] ) ).rejects.toThrow(
115
+ 'Activity "DuplicateActivity" in workflow "A" conflicts with another activity in the same workflow. \
116
+ Activity names must be unique within a workflow.'
117
+ );
118
+ } );
119
+
120
+ it( 'loadActivities throws when two shared activities share a name', async () => {
121
+ const { loadActivities } = await import( './loader.js' );
122
+ importComponentsMock.mockImplementationOnce( async function *() {} );
92
123
  importComponentsMock.mockImplementationOnce( async function *() {
93
- yield { metadata: { name: 'Flow1', description: 'd' }, path: '/b/workflow.js' };
124
+ yield { fn: () => {}, metadata: { name: 'DuplicateShared' }, path: '/root/shared/steps/a.js' };
125
+ yield { fn: () => {}, metadata: { name: 'DuplicateShared' }, path: '/root/shared/evaluators/a.js' };
126
+ } );
127
+
128
+ await expect( loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] ) ).rejects.toThrow(
129
+ 'Shared activity "DuplicateShared" conflicts with another shared activity. Shared activity names must be unique.'
130
+ );
131
+ } );
132
+
133
+ describe( 'loadWorkflows', () => {
134
+ it( 'returns local workflows from importComponents with metadata spread onto each entry', async () => {
135
+ const { loadWorkflows } = await import( './loader.js' );
136
+ const localFiles = [ { path: '/b/workflow.js', url: 'file:///b/workflow.js' } ];
137
+ matchFilesMock.mockReturnValueOnce( localFiles );
138
+
139
+ importComponentsMock.mockImplementationOnce( async function *() {
140
+ yield { metadata: { name: 'Flow1', description: 'd' }, path: '/b/workflow.js' };
141
+ } );
142
+
143
+ const workflows = await loadWorkflows( '/root' );
144
+ expect( workflows ).toEqual( [ { name: 'Flow1', description: 'd', path: '/b/workflow.js', external: false } ] );
145
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, localFiles );
146
+ expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
147
+ expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledWith( '/root' );
148
+ } );
149
+
150
+ it( 'calls matchFiles with rootDir and workflowFile matcher', async () => {
151
+ const { loadWorkflows } = await import( './loader.js' );
152
+ const localFiles = [ { path: '/my/app/workflow.js', url: 'file:///my/app/workflow.js' } ];
153
+ const externalFiles = [ { path: '/my/app/node_modules/pkg/workflow.js', url: 'file:///my/app/node_modules/pkg/workflow.js' } ];
154
+ matchFilesMock.mockReturnValueOnce( localFiles );
155
+ findWorkflowsInNodeModulesMock.mockReturnValue( externalFiles );
156
+
157
+ await loadWorkflows( '/my/app' );
158
+
159
+ expect( matchFilesMock ).toHaveBeenCalledOnce();
160
+ expect( matchFilesMock ).toHaveBeenCalledWith( '/my/app', [ staticMatchers.workflowFile ] );
161
+ expect( importComponentsMock ).toHaveBeenCalledTimes( 1 );
162
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, [ ...localFiles, ...externalFiles ] );
163
+ expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
164
+ expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledWith( '/my/app' );
165
+ } );
166
+
167
+ it( 'appends node_modules workflows after local ones and sets external: true', async () => {
168
+ const { loadWorkflows } = await import( './loader.js' );
169
+
170
+ importComponentsMock.mockImplementationOnce( async function *() {
171
+ yield { metadata: { name: 'LocalFlow', description: 'local' }, path: '/my/app/workflows/wf/workflow.js' };
172
+ yield {
173
+ metadata: { name: '__sum_numbers', description: 'from catalog' },
174
+ path: '/my/app/node_modules/catalog_pkg/src/w/workflow.js'
175
+ };
176
+ } );
177
+ findWorkflowsInNodeModulesMock.mockReturnValue( [ { path: '/my/app/node_modules/catalog_pkg/src/w/workflow.js' } ] );
178
+
179
+ const workflows = await loadWorkflows( '/my/app' );
180
+ expect( workflows ).toEqual( [
181
+ { name: 'LocalFlow', description: 'local', path: '/my/app/workflows/wf/workflow.js', external: false },
182
+ {
183
+ name: '__sum_numbers',
184
+ description: 'from catalog',
185
+ path: '/my/app/node_modules/catalog_pkg/src/w/workflow.js',
186
+ external: true
187
+ }
188
+ ] );
189
+ } );
190
+
191
+ it( 'returns only external workflows when the project root has none', async () => {
192
+ const { loadWorkflows } = await import( './loader.js' );
193
+
194
+ importComponentsMock.mockImplementationOnce( async function *() {
195
+ yield { metadata: { name: 'PkgFlow', description: 'pkg' }, path: '/proj/node_modules/a/w/workflow.js' };
196
+ } );
197
+ findWorkflowsInNodeModulesMock.mockReturnValue( [ { path: '/proj/node_modules/a/w/workflow.js' } ] );
198
+
199
+ const workflows = await loadWorkflows( '/proj' );
200
+ expect( workflows ).toEqual( [
201
+ {
202
+ name: 'PkgFlow',
203
+ description: 'pkg',
204
+ path: '/proj/node_modules/a/w/workflow.js',
205
+ external: true
206
+ }
207
+ ] );
208
+ } );
209
+
210
+ it( 'throws when a local workflow path is under a shared directory', async () => {
211
+ const { loadWorkflows } = await import( './loader.js' );
212
+ importComponentsMock.mockImplementationOnce( async function *() {
213
+ yield { metadata: { name: 'Invalid' }, path: '/root/shared/workflow.js' };
214
+ } );
215
+
216
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow( 'Workflow directory can\'t be named "shared"' );
217
+ expect( findWorkflowsInNodeModulesMock ).toHaveBeenCalledOnce();
218
+ } );
219
+
220
+ it( 'throws when a workflow name conflicts with an earlier workflow name', async () => {
221
+ const { loadWorkflows } = await import( './loader.js' );
222
+ importComponentsMock.mockImplementationOnce( async function *() {
223
+ yield { metadata: { name: 'duplicate' }, path: '/root/a/workflow.js' };
224
+ yield { metadata: { name: 'duplicate' }, path: '/root/b/workflow.js' };
225
+ } );
226
+
227
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
228
+ 'Workflow name "duplicate" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
229
+ );
230
+ } );
231
+
232
+ it( 'throws when a workflow name conflicts with an earlier alias', async () => {
233
+ const { loadWorkflows } = await import( './loader.js' );
234
+ importComponentsMock.mockImplementationOnce( async function *() {
235
+ yield { metadata: { name: 'alpha', aliases: [ 'legacy' ] }, path: '/root/a/workflow.js' };
236
+ yield { metadata: { name: 'legacy' }, path: '/root/b/workflow.js' };
237
+ } );
238
+
239
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
240
+ 'Workflow name "legacy" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
241
+ );
242
+ } );
243
+
244
+ it( 'throws when an alias conflicts with an earlier workflow name', async () => {
245
+ const { loadWorkflows } = await import( './loader.js' );
246
+ importComponentsMock.mockImplementationOnce( async function *() {
247
+ yield { metadata: { name: 'alpha' }, path: '/root/a/workflow.js' };
248
+ yield { metadata: { name: 'beta', aliases: [ 'alpha' ] }, path: '/root/b/workflow.js' };
249
+ } );
250
+
251
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
252
+ 'Workflow "beta" alias "alpha" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
253
+ );
254
+ } );
255
+
256
+ it( 'throws when an alias conflicts with an earlier alias', async () => {
257
+ const { loadWorkflows } = await import( './loader.js' );
258
+ importComponentsMock.mockImplementationOnce( async function *() {
259
+ yield { metadata: { name: 'alpha', aliases: [ 'shared_alias' ] }, path: '/root/a/workflow.js' };
260
+ yield { metadata: { name: 'beta', aliases: [ 'shared_alias' ] }, path: '/root/b/workflow.js' };
261
+ } );
262
+
263
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
264
+ 'Workflow "beta" alias "shared_alias" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
265
+ );
266
+ } );
267
+
268
+ it( 'throws when an alias is identical to its workflow name', async () => {
269
+ const { loadWorkflows } = await import( './loader.js' );
270
+ importComponentsMock.mockImplementationOnce( async function *() {
271
+ yield { metadata: { name: 'alpha', aliases: [ 'alpha' ] }, path: '/root/a/workflow.js' };
272
+ } );
273
+
274
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
275
+ 'Workflow "alpha" alias "alpha" conflicts with another workflow or alias. Workflow names and aliases must be unique.'
276
+ );
94
277
  } );
95
278
 
96
- const workflows = await loadWorkflows( '/root' );
97
- expect( workflows ).toEqual( [ { name: 'Flow1', description: 'd', path: '/b/workflow.js' } ] );
279
+ it( 'allows workflow names and aliases that only differ by case', async () => {
280
+ const { loadWorkflows } = await import( './loader.js' );
281
+ importComponentsMock.mockImplementationOnce( async function *() {
282
+ yield { metadata: { name: 'Alpha', aliases: [ 'Legacy' ] }, path: '/root/a/workflow.js' };
283
+ yield { metadata: { name: 'alpha', aliases: [ 'legacy' ] }, path: '/root/b/workflow.js' };
284
+ } );
285
+
286
+ await expect( loadWorkflows( '/root' ) ).resolves.toEqual( [
287
+ { name: 'Alpha', aliases: [ 'Legacy' ], path: '/root/a/workflow.js', external: false },
288
+ { name: 'alpha', aliases: [ 'legacy' ], path: '/root/b/workflow.js', external: false }
289
+ ] );
290
+ } );
291
+
292
+ it( 'throws when a workflow name is reserved for the internal catalog', async () => {
293
+ const { loadWorkflows } = await import( './loader.js' );
294
+ importComponentsMock.mockImplementationOnce( async function *() {
295
+ yield { metadata: { name: 'catalog' }, path: '/root/catalog/workflow.js' };
296
+ } );
297
+
298
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
299
+ 'Workflow name "catalog" is reserved for the internal catalog workflow.'
300
+ );
301
+ } );
302
+
303
+ it( 'throws when a workflow alias is reserved for the internal catalog', async () => {
304
+ const { loadWorkflows } = await import( './loader.js' );
305
+ importComponentsMock.mockImplementationOnce( async function *() {
306
+ yield { metadata: { name: 'alpha', aliases: [ 'catalog' ] }, path: '/root/a/workflow.js' };
307
+ } );
308
+
309
+ await expect( loadWorkflows( '/root' ) ).rejects.toThrow(
310
+ 'Workflow "alpha" alias "catalog" is reserved for the internal catalog workflow.'
311
+ );
312
+ } );
98
313
  } );
99
314
 
100
315
  it( 'createWorkflowsEntryPoint writes index and returns its path', async () => {
@@ -123,89 +338,69 @@ describe( 'worker/loader', () => {
123
338
  expect( contents ).toContain( 'export { default as W_legacy } from \'/abs/wf.js\';' );
124
339
  } );
125
340
 
126
- it( 'createWorkflowsEntryPoint throws on alias conflicting with primary name', async () => {
127
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
128
-
129
- const workflows = [
130
- { name: 'alpha', path: '/a.js', aliases: [] },
131
- { name: 'beta', path: '/b.js', aliases: [ 'alpha' ] }
132
- ];
133
- expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "alpha" on workflow "beta" conflicts with workflow "alpha"/ );
134
- } );
135
-
136
- it( 'createWorkflowsEntryPoint throws on alias conflicting with another alias', async () => {
137
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
138
-
139
- const workflows = [
140
- { name: 'alpha', path: '/a.js', aliases: [ 'shared_alias' ] },
141
- { name: 'beta', path: '/b.js', aliases: [ 'shared_alias' ] }
142
- ];
143
- expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "shared_alias" on workflow "beta" conflicts with/ );
144
- } );
145
-
146
- it( 'createWorkflowsEntryPoint throws on alias identical to own name', async () => {
147
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
148
-
149
- const workflows = [ { name: 'alpha', path: '/a.js', aliases: [ 'alpha' ] } ];
150
- expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Workflow "alpha" has an alias identical to its own name/ );
151
- } );
152
-
153
- it( 'createWorkflowsEntryPoint catches case-insensitive alias collision with primary name', async () => {
154
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
155
-
156
- const workflows = [
157
- { name: 'Alpha', path: '/a.js', aliases: [] },
158
- { name: 'beta', path: '/b.js', aliases: [ 'alpha' ] }
159
- ];
160
- expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "alpha" on workflow "beta" conflicts with workflow "Alpha"/ );
161
- } );
162
-
163
- it( 'createWorkflowsEntryPoint catches case-insensitive alias-to-alias collision', async () => {
164
- const { createWorkflowsEntryPoint } = await import( './loader.js' );
165
-
166
- const workflows = [
167
- { name: 'alpha', path: '/a.js', aliases: [ 'Legacy' ] },
168
- { name: 'beta', path: '/b.js', aliases: [ 'legacy' ] }
169
- ];
170
- expect( () => createWorkflowsEntryPoint( workflows ) ).toThrow( /Alias "legacy" on workflow "beta" conflicts with/ );
171
- } );
172
-
173
341
  it( 'loadActivities uses folder-based matchers for steps/evaluators and shared', async () => {
174
342
  const { loadActivities } = await import( './loader.js' );
175
- // First call (workflow dir): no results
343
+ const workflowFiles = [ { path: '/a/steps/foo.js' } ];
344
+ const sharedFiles = [ { path: '/root/shared/steps/baz.js' } ];
345
+ matchFilesMock.mockReturnValueOnce( workflowFiles );
346
+ matchFilesMock.mockReturnValueOnce( sharedFiles );
176
347
  importComponentsMock.mockImplementationOnce( async function *() {} );
177
- // Second call (shared): no results
178
348
  importComponentsMock.mockImplementationOnce( async function *() {} );
179
349
 
180
350
  const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
181
351
  await loadActivities( '/root', workflows );
182
352
 
183
- // First invocation should target the workflow directory with folder/file matchers
184
- expect( importComponentsMock ).toHaveBeenCalledTimes( 2 );
185
- const [ firstDir, firstMatchers ] = importComponentsMock.mock.calls[0];
353
+ expect( matchFilesMock ).toHaveBeenCalledTimes( 2 );
354
+ const [ firstDir, firstMatchers ] = matchFilesMock.mock.calls[0];
186
355
  expect( firstDir ).toBe( '/a' );
187
356
  expect( Array.isArray( firstMatchers ) ).toBe( true );
188
- // Should match folder-based steps and evaluators files
189
357
  expect( firstMatchers.some( fn => fn( '/a/steps/foo.js' ) ) ).toBe( true );
190
358
  expect( firstMatchers.some( fn => fn( '/a/evaluators/bar.js' ) ) ).toBe( true );
191
- // And also direct file names
192
359
  expect( firstMatchers.some( fn => fn( '/a/steps.js' ) ) ).toBe( true );
193
360
  expect( firstMatchers.some( fn => fn( '/a/evaluators.js' ) ) ).toBe( true );
194
361
 
195
- // Second invocation should target root with shared matchers
196
- const [ secondDir, secondMatchers ] = importComponentsMock.mock.calls[1];
362
+ const [ secondDir, secondMatchers ] = matchFilesMock.mock.calls[1];
197
363
  expect( secondDir ).toBe( '/root' );
198
364
  expect( secondMatchers.some( fn => fn( '/root/shared/steps/baz.js' ) ) ).toBe( true );
199
365
  expect( secondMatchers.some( fn => fn( '/root/shared/evaluators/qux.js' ) ) ).toBe( true );
366
+
367
+ expect( importComponentsMock ).toHaveBeenCalledTimes( 2 );
368
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, workflowFiles );
369
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 2, sharedFiles );
370
+ } );
371
+
372
+ it( 'loads shared activities from external workflow packages', async () => {
373
+ const { loadActivities } = await import( './loader.js' );
374
+ const externalSharedFiles = [ { path: '/root/node_modules/pkg/shared/steps/prepare.js' } ];
375
+ const localWorkflow = { name: 'Local', path: '/root/workflows/local/workflow.js' };
376
+ const externalWorkflow = { name: 'External', path: '/root/node_modules/pkg/workflows/a/workflow.js', external: true };
377
+ findSharedActivitiesFromWorkflowsMock.mockReturnValue( externalSharedFiles );
378
+ importComponentsMock.mockImplementationOnce( async function *() {} );
379
+ importComponentsMock.mockImplementationOnce( async function *() {} );
380
+ importComponentsMock.mockImplementationOnce( async function *() {
381
+ yield {
382
+ fn: () => {},
383
+ metadata: { name: 'ExternalShared', options: { activityOptions: { retry: { maximumAttempts: 2 } } } },
384
+ path: '/root/node_modules/pkg/shared/steps/prepare.js'
385
+ };
386
+ } );
387
+
388
+ const activities = await loadActivities( '/root', [ localWorkflow, externalWorkflow ] );
389
+
390
+ expect( findSharedActivitiesFromWorkflowsMock ).toHaveBeenCalledWith( [ externalWorkflow ] );
391
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 3, externalSharedFiles );
392
+ expect( activities['$shared#ExternalShared'] ).toBeTypeOf( 'function' );
393
+ const written = JSON.parse(
394
+ fsMocks.writeFileSync.mock.calls[0][1].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
395
+ );
396
+ expect( written['$shared#ExternalShared'] ).toEqual( { retry: { maximumAttempts: 2 } } );
200
397
  } );
201
398
 
202
399
  it( 'loadActivities includes nested workflow steps and shared evaluators', async () => {
203
400
  const { loadActivities } = await import( './loader.js' );
204
- // Workflow dir scan returns a nested step
205
401
  importComponentsMock.mockImplementationOnce( async function *() {
206
402
  yield { fn: () => {}, metadata: { name: 'ActNested' }, path: '/a/steps/foo.js' };
207
403
  } );
208
- // Shared scan returns a shared evaluator
209
404
  importComponentsMock.mockImplementationOnce( async function *() {
210
405
  yield { fn: () => {}, metadata: { name: 'SharedEval' }, path: '/root/shared/evaluators/bar.js' };
211
406
  } );
@@ -216,24 +411,14 @@ describe( 'worker/loader', () => {
216
411
  expect( activities['$shared#SharedEval'] ).toBeTypeOf( 'function' );
217
412
  } );
218
413
 
219
- it( 'loadWorkflows throws when workflow is under shared directory', async () => {
220
- const { loadWorkflows } = await import( './loader.js' );
221
- importComponentsMock.mockImplementationOnce( async function *() {
222
- yield { metadata: { name: 'Invalid' }, path: '/root/shared/workflow.js' };
223
- } );
224
- await expect( loadWorkflows( '/root' ) ).rejects.toThrow( 'Workflow directory can\'t be named \"shared\"' );
225
- } );
226
-
227
414
  it( 'collects workflow nested steps and evaluators across multiple subfolders', async () => {
228
415
  const { loadActivities } = await import( './loader.js' );
229
- // Workflow dir scan returns nested steps and evaluators
230
416
  importComponentsMock.mockImplementationOnce( async function *() {
231
417
  yield { fn: () => {}, metadata: { name: 'StepPrimary' }, path: '/a/steps/primary/foo.js' };
232
418
  yield { fn: () => {}, metadata: { name: 'StepSecondary' }, path: '/a/steps/secondary/bar.js' };
233
419
  yield { fn: () => {}, metadata: { name: 'EvalPrimary' }, path: '/a/evaluators/primary/baz.js' };
234
420
  yield { fn: () => {}, metadata: { name: 'EvalSecondary' }, path: '/a/evaluators/secondary/qux.js' };
235
421
  } );
236
- // Shared scan returns nothing for this test
237
422
  importComponentsMock.mockImplementationOnce( async function *() {} );
238
423
 
239
424
  const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
@@ -246,9 +431,7 @@ describe( 'worker/loader', () => {
246
431
 
247
432
  it( 'collects shared nested steps and evaluators across multiple subfolders', async () => {
248
433
  const { loadActivities } = await import( './loader.js' );
249
- // Workflow dir scan returns nothing for this test
250
434
  importComponentsMock.mockImplementationOnce( async function *() {} );
251
- // Shared scan returns nested steps and evaluators
252
435
  importComponentsMock.mockImplementationOnce( async function *() {
253
436
  yield { fn: () => {}, metadata: { name: 'SharedStepPrimary' }, path: '/root/shared/steps/primary/a.js' };
254
437
  yield { fn: () => {}, metadata: { name: 'SharedStepSecondary' }, path: '/root/shared/steps/secondary/b.js' };
@@ -272,14 +455,14 @@ describe( 'worker/loader', () => {
272
455
  expect( fsMocks.existsSync ).toHaveBeenCalledWith( join( '/root', 'package.json' ) );
273
456
  } );
274
457
 
275
- it( 'imports hook files listed in package.json output.hookFiles', async () => {
458
+ it( 'imports hook files listed in package.json outputai.hookFiles', async () => {
276
459
  vi.doUnmock( 'node:fs' );
277
460
  vi.resetModules();
278
461
  const fs = await import( 'node:fs' );
279
462
  const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
280
463
  try {
281
464
  fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
282
- output: { hookFiles: [ 'hook.js' ] }
465
+ outputai: { hookFiles: [ 'hook.js' ] }
283
466
  } ) );
284
467
  fs.writeFileSync( join( tmpDir, 'hook.js' ), 'globalThis.__loadHooksTestLoaded = true;' );
285
468
 
@@ -291,5 +474,25 @@ describe( 'worker/loader', () => {
291
474
  fs.rmSync( tmpDir, { recursive: true, force: true } );
292
475
  }
293
476
  } );
477
+
478
+ it( 'imports hook files from legacy package.json output.hookFiles', async () => {
479
+ vi.doUnmock( 'node:fs' );
480
+ vi.resetModules();
481
+ const fs = await import( 'node:fs' );
482
+ const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
483
+ try {
484
+ fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
485
+ output: { hookFiles: [ 'legacy_hook.js' ] }
486
+ } ) );
487
+ fs.writeFileSync( join( tmpDir, 'legacy_hook.js' ), 'globalThis.__loadHooksLegacyTestLoaded = true;' );
488
+
489
+ const { loadHooks } = await import( './loader.js' );
490
+ await loadHooks( tmpDir );
491
+ expect( globalThis.__loadHooksLegacyTestLoaded ).toBe( true );
492
+ } finally {
493
+ delete globalThis.__loadHooksLegacyTestLoaded;
494
+ fs.rmSync( tmpDir, { recursive: true, force: true } );
495
+ }
496
+ } );
294
497
  } );
295
498
  } );