@outputai/core 0.7.1-next.ae5bab4.0 → 0.7.1-next.ba2fb0b.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.
Files changed (78) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/hooks/index.d.ts +10 -0
  6. package/src/interface/evaluator.js +7 -20
  7. package/src/interface/evaluator.spec.js +117 -1
  8. package/src/interface/step.js +8 -9
  9. package/src/interface/step.spec.js +124 -0
  10. package/src/interface/validations/index.js +108 -0
  11. package/src/interface/validations/index.spec.js +182 -0
  12. package/src/interface/validations/schemas.js +113 -0
  13. package/src/interface/validations/schemas.spec.js +209 -0
  14. package/src/interface/webhook.js +1 -1
  15. package/src/interface/webhook.spec.js +1 -1
  16. package/src/interface/workflow.d.ts +10 -9
  17. package/src/interface/workflow.js +76 -164
  18. package/src/interface/workflow.spec.js +637 -521
  19. package/src/interface/workflow_activity_options.js +16 -0
  20. package/src/interface/workflow_utils.js +1 -1
  21. package/src/interface/zod_integration.spec.js +2 -2
  22. package/src/internal_utils/aggregations.js +0 -10
  23. package/src/internal_utils/aggregations.spec.js +1 -48
  24. package/src/internal_utils/errors.js +14 -8
  25. package/src/internal_utils/errors.spec.js +73 -27
  26. package/src/utils/index.d.ts +19 -0
  27. package/src/utils/utils.js +53 -0
  28. package/src/utils/utils.spec.js +105 -1
  29. package/src/worker/bundle.js +26 -0
  30. package/src/worker/bundle.spec.js +53 -0
  31. package/src/worker/bundler_options.js +1 -1
  32. package/src/worker/bundler_options.spec.js +1 -1
  33. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  34. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  35. package/src/worker/check.js +24 -0
  36. package/src/worker/connection_monitor.js +112 -0
  37. package/src/worker/connection_monitor.spec.js +199 -0
  38. package/src/worker/index.js +146 -41
  39. package/src/worker/index.spec.js +281 -109
  40. package/src/worker/interceptors/activity.js +7 -24
  41. package/src/worker/interceptors/activity.spec.js +97 -66
  42. package/src/worker/interceptors/index.js +4 -7
  43. package/src/worker/interceptors/modules.js +15 -0
  44. package/src/worker/interceptors/workflow.js +6 -8
  45. package/src/worker/interceptors/workflow.spec.js +49 -42
  46. package/src/worker/interruption.js +33 -0
  47. package/src/worker/interruption.spec.js +98 -0
  48. package/src/worker/loader/activities.js +75 -0
  49. package/src/worker/loader/activities.spec.js +213 -0
  50. package/src/worker/loader/hooks.js +28 -0
  51. package/src/worker/loader/hooks.spec.js +64 -0
  52. package/src/worker/loader/matchers.js +46 -0
  53. package/src/worker/loader/matchers.spec.js +140 -0
  54. package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
  55. package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
  56. package/src/worker/loader/workflows.js +82 -0
  57. package/src/worker/loader/workflows.spec.js +256 -0
  58. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  59. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  60. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  61. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  62. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  63. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  64. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  65. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  66. package/src/interface/validations/runtime.js +0 -20
  67. package/src/interface/validations/runtime.spec.js +0 -29
  68. package/src/interface/validations/schema_utils.js +0 -8
  69. package/src/interface/validations/schema_utils.spec.js +0 -67
  70. package/src/interface/validations/static.js +0 -137
  71. package/src/interface/validations/static.spec.js +0 -397
  72. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  73. package/src/worker/loader.js +0 -202
  74. package/src/worker/loader.spec.js +0 -498
  75. package/src/worker/shutdown.js +0 -26
  76. package/src/worker/shutdown.spec.js +0 -82
  77. package/src/worker/start_catalog.js +0 -96
  78. package/src/worker/start_catalog.spec.js +0 -179
@@ -0,0 +1,75 @@
1
+ import { dirname } from 'node:path';
2
+ import { getTraceDestinations, sendHttpRequest } from '#internal_activities';
3
+ import { findSharedActivitiesFromWorkflows, importComponents, matchFiles, writeFileInTempDir } from './tools.js';
4
+ import { buildActivityMatcher, staticMatchers } from './matchers.js';
5
+ import { ACTIVITY_SEND_HTTP_REQUEST, ACTIVITY_OPTIONS_FILENAME, SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS } from '#consts';
6
+ import { createChildLogger } from '#logger';
7
+ import { ValidationError } from '#errors';
8
+
9
+ const log = createChildLogger( 'Activities Loader' );
10
+
11
+ /**
12
+ * Load activities:
13
+ * - Scans activities based on workflows, using each workflow folder as a point to lookup for steps, evaluators files;
14
+ * - Scans shared activities in the rootDir;
15
+ * - Loads internal activities as well;
16
+ *
17
+ * Builds a map of activities, where they is generated according to the type of activity and the value is the function itself and return it.
18
+ * - Shared activity keys have a common prefix followed by the activity name;
19
+ * - Internal activities are registered with a fixed key;
20
+ * - Workflow activities keys are composed using the workflow name and the activity name;
21
+ *
22
+ * @param {string} rootDir
23
+ * @param {import('./workflows.js').Workflow[]} workflows
24
+ * @returns {object}
25
+ */
26
+ export async function loadActivities( rootDir, workflows ) {
27
+ const activities = {};
28
+ const activityOptionsMap = {};
29
+
30
+ // Load workflow-based activities
31
+ for ( const { path: workflowPath, name: workflowName, external } of workflows ) {
32
+ const dir = dirname( workflowPath );
33
+ for await ( const { fn, metadata, path } of importComponents( matchFiles( dir, [ buildActivityMatcher( dir ) ] ) ) ) {
34
+ // Activities loaded from a workflow path will use the workflow name as a namespace, which is unique across the platform, avoiding collision
35
+ const activityKey = `${workflowName}#${metadata.name}`;
36
+
37
+ log.info( metadata.name, { workflow: workflowName, type: metadata.type, ...( external && { external } ), path } );
38
+
39
+ if ( activities[activityKey] ) {
40
+ throw new ValidationError( `Activity "${metadata.name}" in workflow "${workflowName}" conflicts with another \
41
+ activity in the same workflow. Activity names must be unique within a workflow.` );
42
+ }
43
+ activities[activityKey] = fn;
44
+ // propagate the custom options set on the step()/evaluator() constructor
45
+ activityOptionsMap[activityKey] = metadata.options?.activityOptions ?? undefined;
46
+ }
47
+ }
48
+
49
+ // Load shared activities/evaluators from local and external npm modules
50
+ const localSharedActivities = matchFiles( rootDir, [ staticMatchers.sharedStepsDir, staticMatchers.sharedEvaluatorsDir ] );
51
+ const externalSharedActivities = findSharedActivitiesFromWorkflows( workflows.filter( w => w.external ) );
52
+ for await ( const { fn, metadata, path } of importComponents( [ ...localSharedActivities, ...externalSharedActivities ] ) ) {
53
+ const external = externalSharedActivities.some( a => a.path === path );
54
+ // Uses a global namespace for shared activities
55
+ const activityKey = `${SHARED_STEP_PREFIX}#${metadata.name}`;
56
+
57
+ log.info( metadata.name, { shared: true, type: metadata.type, ...( external && { external } ), path } );
58
+
59
+ if ( activities[activityKey] ) {
60
+ throw new ValidationError( `Shared activity "${metadata.name}" conflicts with another shared activity. \
61
+ Shared activity names must be unique.` );
62
+ }
63
+ activities[activityKey] = fn;
64
+ activityOptionsMap[activityKey] = metadata.options?.activityOptions ?? undefined;
65
+ }
66
+
67
+ // writes down the activity option overrides
68
+ const optionsContent = `export default ${JSON.stringify( activityOptionsMap, undefined, 2 )};`;
69
+ const optionsFile = writeFileInTempDir( optionsContent, ACTIVITY_OPTIONS_FILENAME );
70
+
71
+ // system activities
72
+ activities[ACTIVITY_SEND_HTTP_REQUEST] = sendHttpRequest;
73
+ activities[ACTIVITY_GET_TRACE_DESTINATIONS] = getTraceDestinations;
74
+ return { activities, optionsFile };
75
+ };
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock( '#consts', () => ( {
4
+ ACTIVITY_SEND_HTTP_REQUEST: '__internal#sendHttpRequest',
5
+ ACTIVITY_GET_TRACE_DESTINATIONS: '__internal#getTraceDestinations',
6
+ WORKFLOWS_INDEX_FILENAME: '__workflows_entrypoint.js',
7
+ WORKFLOW_CATALOG: 'catalog',
8
+ ACTIVITY_OPTIONS_FILENAME: '__activity_options.js',
9
+ SHARED_STEP_PREFIX: '$shared'
10
+ } ) );
11
+
12
+ const sendHttpRequestMock = vi.fn();
13
+ const getTraceDestinationsMock = vi.fn();
14
+ vi.mock( '#internal_activities', () => ( {
15
+ sendHttpRequest: sendHttpRequestMock,
16
+ getTraceDestinations: getTraceDestinationsMock
17
+ } ) );
18
+
19
+ const { importComponentsMock, findSharedActivitiesFromWorkflowsMock, matchFilesMock, writeFileInTempDirMock } = vi.hoisted( () => ( {
20
+ importComponentsMock: vi.fn(),
21
+ findSharedActivitiesFromWorkflowsMock: vi.fn(),
22
+ matchFilesMock: vi.fn(),
23
+ writeFileInTempDirMock: vi.fn()
24
+ } ) );
25
+
26
+ const { buildActivityMatcherMock, activityMatcherMock, sharedStepsDirMock, sharedEvaluatorsDirMock } = vi.hoisted( () => ( {
27
+ buildActivityMatcherMock: vi.fn(),
28
+ activityMatcherMock: vi.fn(),
29
+ sharedStepsDirMock: vi.fn(),
30
+ sharedEvaluatorsDirMock: vi.fn()
31
+ } ) );
32
+
33
+ vi.mock( './tools.js', () => ( {
34
+ importComponents: importComponentsMock,
35
+ findSharedActivitiesFromWorkflows: findSharedActivitiesFromWorkflowsMock,
36
+ matchFiles: matchFilesMock,
37
+ writeFileInTempDir: writeFileInTempDirMock
38
+ } ) );
39
+
40
+ vi.mock( './matchers.js', () => ( {
41
+ buildActivityMatcher: buildActivityMatcherMock,
42
+ staticMatchers: {
43
+ sharedStepsDir: sharedStepsDirMock,
44
+ sharedEvaluatorsDir: sharedEvaluatorsDirMock
45
+ }
46
+ } ) );
47
+
48
+ describe( 'loadActivities', () => {
49
+ beforeEach( () => {
50
+ vi.clearAllMocks();
51
+ importComponentsMock.mockReset();
52
+ importComponentsMock.mockImplementation( async function *() {} );
53
+ findSharedActivitiesFromWorkflowsMock.mockReset();
54
+ findSharedActivitiesFromWorkflowsMock.mockReturnValue( [] );
55
+ matchFilesMock.mockReset();
56
+ matchFilesMock.mockReturnValue( [] );
57
+ writeFileInTempDirMock.mockReset();
58
+ writeFileInTempDirMock.mockReturnValue( '/tmp/__activity_options.js' );
59
+ buildActivityMatcherMock.mockReset();
60
+ buildActivityMatcherMock.mockReturnValue( activityMatcherMock );
61
+ } );
62
+
63
+ it( 'loadActivities returns map including system activity and writes options file', async () => {
64
+ const { loadActivities } = await import( './activities.js' );
65
+
66
+ importComponentsMock.mockImplementationOnce( async function *() {
67
+ yield {
68
+ fn: () => {},
69
+ metadata: { name: 'Act1', options: { activityOptions: { retry: { maximumAttempts: 3 } } } },
70
+ path: '/a/steps.js'
71
+ };
72
+ } );
73
+ importComponentsMock.mockImplementationOnce( async function *() {} );
74
+
75
+ const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
76
+ const { activities, optionsFile } = await loadActivities( '/root', workflows );
77
+ expect( activities['A#Act1'] ).toBeTypeOf( 'function' );
78
+ expect( activities['__internal#sendHttpRequest'] ).toBe( sendHttpRequestMock );
79
+ expect( optionsFile ).toBe( '/tmp/__activity_options.js' );
80
+
81
+ expect( writeFileInTempDirMock ).toHaveBeenCalledTimes( 1 );
82
+ const [ contents, filename ] = writeFileInTempDirMock.mock.calls[0];
83
+ expect( filename ).toBe( '__activity_options.js' );
84
+ expect( contents ).toContain( 'export default' );
85
+ expect( JSON.parse( contents.replace( /^export default\s*/, '' ).replace( /;\s*$/, '' ) ) ).toEqual( {
86
+ 'A#Act1': { retry: { maximumAttempts: 3 } }
87
+ } );
88
+ } );
89
+
90
+ it( 'loadActivities omits activity options when component has no options or no activityOptions', async () => {
91
+ const { loadActivities } = await import( './activities.js' );
92
+ importComponentsMock.mockImplementationOnce( async function *() {
93
+ yield { fn: () => {}, metadata: { name: 'NoOptions' }, path: '/a/steps.js' };
94
+ yield { fn: () => {}, metadata: { name: 'EmptyOptions', options: {} }, path: '/a/steps2.js' };
95
+ } );
96
+ importComponentsMock.mockImplementationOnce( async function *() {} );
97
+
98
+ await loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] );
99
+ const written = JSON.parse(
100
+ writeFileInTempDirMock.mock.calls[0][0].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
101
+ );
102
+ expect( written['A#NoOptions'] ).toBeUndefined();
103
+ expect( written['A#EmptyOptions'] ).toBeUndefined();
104
+ } );
105
+
106
+ it( 'loadActivities throws when two activities in the same workflow share a name', async () => {
107
+ const { loadActivities } = await import( './activities.js' );
108
+ importComponentsMock.mockImplementationOnce( async function *() {
109
+ yield { fn: () => {}, metadata: { name: 'DuplicateActivity' }, path: '/a/steps.js' };
110
+ yield { fn: () => {}, metadata: { name: 'DuplicateActivity' }, path: '/a/evaluators.js' };
111
+ } );
112
+
113
+ await expect( loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] ) ).rejects.toThrow(
114
+ 'Activity "DuplicateActivity" in workflow "A" conflicts with another activity in the same workflow. \
115
+ Activity names must be unique within a workflow.'
116
+ );
117
+ } );
118
+
119
+ it( 'loadActivities throws when two shared activities share a name', async () => {
120
+ const { loadActivities } = await import( './activities.js' );
121
+ importComponentsMock.mockImplementationOnce( async function *() {} );
122
+ importComponentsMock.mockImplementationOnce( async function *() {
123
+ yield { fn: () => {}, metadata: { name: 'DuplicateShared' }, path: '/root/shared/steps/a.js' };
124
+ yield { fn: () => {}, metadata: { name: 'DuplicateShared' }, path: '/root/shared/evaluators/a.js' };
125
+ } );
126
+
127
+ await expect( loadActivities( '/root', [ { name: 'A', path: '/a/workflow.js' } ] ) ).rejects.toThrow(
128
+ 'Shared activity "DuplicateShared" conflicts with another shared activity. Shared activity names must be unique.'
129
+ );
130
+ } );
131
+
132
+ it( 'loadActivities uses activity and shared matchers for workflow and shared scans', async () => {
133
+ const { loadActivities } = await import( './activities.js' );
134
+ const workflowFiles = [ { path: '/a/steps/foo.js' } ];
135
+ const sharedFiles = [ { path: '/root/shared/steps/baz.js' } ];
136
+ matchFilesMock.mockReturnValueOnce( workflowFiles );
137
+ matchFilesMock.mockReturnValueOnce( sharedFiles );
138
+ importComponentsMock.mockImplementationOnce( async function *() {} );
139
+ importComponentsMock.mockImplementationOnce( async function *() {} );
140
+
141
+ const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
142
+ await loadActivities( '/root', workflows );
143
+
144
+ expect( matchFilesMock ).toHaveBeenCalledTimes( 2 );
145
+ expect( buildActivityMatcherMock ).toHaveBeenCalledWith( '/a' );
146
+ expect( matchFilesMock ).toHaveBeenNthCalledWith( 1, '/a', [ activityMatcherMock ] );
147
+ expect( matchFilesMock ).toHaveBeenNthCalledWith( 2, '/root', [ sharedStepsDirMock, sharedEvaluatorsDirMock ] );
148
+
149
+ expect( importComponentsMock ).toHaveBeenCalledTimes( 2 );
150
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 1, workflowFiles );
151
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 2, sharedFiles );
152
+ } );
153
+
154
+ it( 'loads shared activities from external workflow packages', async () => {
155
+ const { loadActivities } = await import( './activities.js' );
156
+ const externalSharedFiles = [ { path: '/root/node_modules/pkg/shared/steps/prepare.js' } ];
157
+ const localWorkflow = { name: 'Local', path: '/root/workflows/local/workflow.js' };
158
+ const externalWorkflow = { name: 'External', path: '/root/node_modules/pkg/workflows/a/workflow.js', external: true };
159
+ findSharedActivitiesFromWorkflowsMock.mockReturnValue( externalSharedFiles );
160
+ importComponentsMock.mockImplementationOnce( async function *() {} );
161
+ importComponentsMock.mockImplementationOnce( async function *() {} );
162
+ importComponentsMock.mockImplementationOnce( async function *() {
163
+ yield {
164
+ fn: () => {},
165
+ metadata: { name: 'ExternalShared', options: { activityOptions: { retry: { maximumAttempts: 2 } } } },
166
+ path: '/root/node_modules/pkg/shared/steps/prepare.js'
167
+ };
168
+ } );
169
+
170
+ const { activities } = await loadActivities( '/root', [ localWorkflow, externalWorkflow ] );
171
+
172
+ expect( findSharedActivitiesFromWorkflowsMock ).toHaveBeenCalledWith( [ externalWorkflow ] );
173
+ expect( importComponentsMock ).toHaveBeenNthCalledWith( 3, externalSharedFiles );
174
+ expect( activities['$shared#ExternalShared'] ).toBeTypeOf( 'function' );
175
+ const written = JSON.parse(
176
+ writeFileInTempDirMock.mock.calls[0][0].replace( /^export default\s*/, '' ).replace( /;\s*$/, '' )
177
+ );
178
+ expect( written['$shared#ExternalShared'] ).toEqual( { retry: { maximumAttempts: 2 } } );
179
+ } );
180
+
181
+ it( 'loadActivities includes nested workflow steps and shared evaluators', async () => {
182
+ const { loadActivities } = await import( './activities.js' );
183
+ importComponentsMock.mockImplementationOnce( async function *() {
184
+ yield { fn: () => {}, metadata: { name: 'ActNested' }, path: '/a/steps/foo.js' };
185
+ } );
186
+ importComponentsMock.mockImplementationOnce( async function *() {
187
+ yield { fn: () => {}, metadata: { name: 'SharedEval' }, path: '/root/shared/evaluators/bar.js' };
188
+ } );
189
+
190
+ const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
191
+ const { activities } = await loadActivities( '/root', workflows );
192
+ expect( activities['A#ActNested'] ).toBeTypeOf( 'function' );
193
+ expect( activities['$shared#SharedEval'] ).toBeTypeOf( 'function' );
194
+ } );
195
+
196
+ it( 'collects shared nested steps and evaluators across multiple subfolders', async () => {
197
+ const { loadActivities } = await import( './activities.js' );
198
+ importComponentsMock.mockImplementationOnce( async function *() {} );
199
+ importComponentsMock.mockImplementationOnce( async function *() {
200
+ yield { fn: () => {}, metadata: { name: 'SharedStepPrimary' }, path: '/root/shared/steps/primary/a.js' };
201
+ yield { fn: () => {}, metadata: { name: 'SharedStepSecondary' }, path: '/root/shared/steps/secondary/b.js' };
202
+ yield { fn: () => {}, metadata: { name: 'SharedEvalPrimary' }, path: '/root/shared/evaluators/primary/c.js' };
203
+ yield { fn: () => {}, metadata: { name: 'SharedEvalSecondary' }, path: '/root/shared/evaluators/secondary/d.js' };
204
+ } );
205
+
206
+ const workflows = [ { name: 'A', path: '/a/workflow.js' } ];
207
+ const { activities } = await loadActivities( '/root', workflows );
208
+ expect( activities['$shared#SharedStepPrimary'] ).toBeTypeOf( 'function' );
209
+ expect( activities['$shared#SharedStepSecondary'] ).toBeTypeOf( 'function' );
210
+ expect( activities['$shared#SharedEvalPrimary'] ).toBeTypeOf( 'function' );
211
+ expect( activities['$shared#SharedEvalSecondary'] ).toBeTypeOf( 'function' );
212
+ } );
213
+ } );
@@ -0,0 +1,28 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { createChildLogger } from '#logger';
4
+
5
+ const log = createChildLogger( 'Hooks Loader' );
6
+
7
+ /**
8
+ * Loads the hook files from package.json's "outputai" section.
9
+ *
10
+ * @param {string} rootDir
11
+ * @returns {void}
12
+ */
13
+ export async function loadHooks( rootDir ) {
14
+ const packageFile = join( rootDir, 'package.json' );
15
+ if ( existsSync( packageFile ) ) {
16
+ const pkg = await import( packageFile, { with: { type: 'json' } } );
17
+ const content = pkg.default;
18
+ const hooks = [];
19
+ // @DEPRECATED: "output" is the legacy namespace for configs, can be removed after couple version (this is being added in 0.3.x)
20
+ hooks.push( ...( content['output']?.hookFiles ?? [] ) );
21
+ hooks.push( ...( content['outputai']?.hookFiles ?? [] ) );
22
+ for ( const path of hooks ) {
23
+ const hookFile = join( rootDir, path );
24
+ await import( hookFile );
25
+ log.info( 'Hook file loaded', { path } );
26
+ }
27
+ }
28
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+
5
+ const fsMocks = vi.hoisted( () => ( {
6
+ existsSync: vi.fn().mockReturnValue( false )
7
+ } ) );
8
+ vi.mock( 'node:fs', () => ( {
9
+ existsSync: fsMocks.existsSync
10
+ } ) );
11
+
12
+ describe( 'loadHooks', () => {
13
+ beforeEach( () => {
14
+ vi.clearAllMocks();
15
+ fsMocks.existsSync.mockReturnValue( false );
16
+ } );
17
+
18
+ it( 'resolves without importing when package.json does not exist', async () => {
19
+ fsMocks.existsSync.mockReturnValue( false );
20
+ const { loadHooks } = await import( './hooks.js' );
21
+ await expect( loadHooks( '/root' ) ).resolves.toBeUndefined();
22
+ expect( fsMocks.existsSync ).toHaveBeenCalledWith( join( '/root', 'package.json' ) );
23
+ } );
24
+
25
+ it( 'imports hook files listed in package.json outputai.hookFiles', async () => {
26
+ vi.doUnmock( 'node:fs' );
27
+ vi.resetModules();
28
+ const fs = await import( 'node:fs' );
29
+ const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
30
+ try {
31
+ fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
32
+ outputai: { hookFiles: [ 'hook.js' ] }
33
+ } ) );
34
+ fs.writeFileSync( join( tmpDir, 'hook.js' ), 'globalThis.__loadHooksTestLoaded = true;' );
35
+
36
+ const { loadHooks } = await import( './hooks.js' );
37
+ await loadHooks( tmpDir );
38
+ expect( globalThis.__loadHooksTestLoaded ).toBe( true );
39
+ } finally {
40
+ delete globalThis.__loadHooksTestLoaded;
41
+ fs.rmSync( tmpDir, { recursive: true, force: true } );
42
+ }
43
+ } );
44
+
45
+ it( 'imports hook files from legacy package.json output.hookFiles', async () => {
46
+ vi.doUnmock( 'node:fs' );
47
+ vi.resetModules();
48
+ const fs = await import( 'node:fs' );
49
+ const tmpDir = fs.mkdtempSync( join( tmpdir(), 'loader-spec-' ) );
50
+ try {
51
+ fs.writeFileSync( join( tmpDir, 'package.json' ), JSON.stringify( {
52
+ output: { hookFiles: [ 'legacy_hook.js' ] }
53
+ } ) );
54
+ fs.writeFileSync( join( tmpDir, 'legacy_hook.js' ), 'globalThis.__loadHooksLegacyTestLoaded = true;' );
55
+
56
+ const { loadHooks } = await import( './hooks.js' );
57
+ await loadHooks( tmpDir );
58
+ expect( globalThis.__loadHooksLegacyTestLoaded ).toBe( true );
59
+ } finally {
60
+ delete globalThis.__loadHooksLegacyTestLoaded;
61
+ fs.rmSync( tmpDir, { recursive: true, force: true } );
62
+ }
63
+ } );
64
+ } );
@@ -0,0 +1,46 @@
1
+ import { sep } from 'node:path';
2
+ import { rxEscape } from '#utils';
3
+
4
+ /**
5
+ * Creates a matcher function that based on "path", matches:
6
+ * - path/steps.js
7
+ * - path/evaluators.js
8
+ * - path/steps/*
9
+ * - path/evaluators/*
10
+ * @param {string} path
11
+ * @returns {function(string): boolean}
12
+ */
13
+ export const buildActivityMatcher = path => {
14
+ const exp = new RegExp( `^${rxEscape( `${path}${sep}` )}(?:steps|evaluators)(?:\\.js$|${rxEscape( sep )})` );
15
+ return v => exp.test( v );
16
+ };
17
+
18
+ /**
19
+ * Matchers that can be used to access conditions without initializing them
20
+ */
21
+ export const staticMatchers = {
22
+ /**
23
+ * Matches a workflow.js file
24
+ * @param {string} path - Path to test
25
+ * @returns {boolean}
26
+ */
27
+ workflowFile: v => v.endsWith( `${sep}workflow.js` ),
28
+ /**
29
+ * Matches a workflow.js that is inside a shared folder: eg foo/shared/workflow.js
30
+ * @param {string} path - Path to test
31
+ * @returns {boolean}
32
+ */
33
+ workflowPathHasShared: v => v.endsWith( `${sep}shared${sep}workflow.js` ),
34
+ /**
35
+ * Matches the shared folder for steps src/shared/steps/../step_file.js
36
+ * @param {string} path - Path to test
37
+ * @returns {boolean}
38
+ */
39
+ sharedStepsDir: v => v.includes( `${sep}shared${sep}steps${sep}` ) && v.endsWith( '.js' ),
40
+ /**
41
+ * Matches the shared folder for evaluators src/shared/evaluators/../evaluator_file.js
42
+ * @param {string} path - Path to test
43
+ * @returns {boolean}
44
+ */
45
+ sharedEvaluatorsDir: v => v.includes( `${sep}shared${sep}evaluators${sep}` ) && v.endsWith( '.js' )
46
+ };
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sep } from 'node:path';
3
+ import { buildActivityMatcher, staticMatchers } from './matchers.js';
4
+
5
+ describe( 'buildActivityMatcher', () => {
6
+ const base = `${sep}app${sep}proj`;
7
+
8
+ it( 'matches steps.js at the base path', () => {
9
+ const matchActivity = buildActivityMatcher( base );
10
+
11
+ expect( matchActivity( `${base}${sep}steps.js` ) ).toBe( true );
12
+ } );
13
+
14
+ it( 'matches evaluators.js at the base path', () => {
15
+ const matchActivity = buildActivityMatcher( base );
16
+
17
+ expect( matchActivity( `${base}${sep}evaluators.js` ) ).toBe( true );
18
+ } );
19
+
20
+ it( 'matches files under the steps directory', () => {
21
+ const matchActivity = buildActivityMatcher( base );
22
+
23
+ expect( matchActivity( `${base}${sep}steps${sep}a.js` ) ).toBe( true );
24
+ expect( matchActivity( `${base}${sep}steps${sep}sub${sep}b.js` ) ).toBe( true );
25
+ } );
26
+
27
+ it( 'matches files under the evaluators directory', () => {
28
+ const matchActivity = buildActivityMatcher( base );
29
+
30
+ expect( matchActivity( `${base}${sep}evaluators${sep}x.js` ) ).toBe( true );
31
+ expect( matchActivity( `${base}${sep}evaluators${sep}y${sep}z.js` ) ).toBe( true );
32
+ } );
33
+
34
+ it( 'rejects activity filenames outside the base path', () => {
35
+ const matchActivity = buildActivityMatcher( base );
36
+
37
+ expect( matchActivity( `${base}${sep}nested${sep}steps.js` ) ).toBe( false );
38
+ expect( matchActivity( `${base}${sep}sub${sep}evaluators.js` ) ).toBe( false );
39
+ expect( matchActivity( `${sep}app${sep}other${sep}steps.js` ) ).toBe( false );
40
+ } );
41
+
42
+ it( 'rejects non-activity names at the base path', () => {
43
+ const matchActivity = buildActivityMatcher( base );
44
+
45
+ expect( matchActivity( `${base}${sep}workflow.js` ) ).toBe( false );
46
+ expect( matchActivity( `${base}${sep}shared${sep}steps${sep}a.js` ) ).toBe( false );
47
+ expect( matchActivity( `${base}${sep}other${sep}a.js` ) ).toBe( false );
48
+ } );
49
+
50
+ it( 'rejects similarly named files and directories', () => {
51
+ const matchActivity = buildActivityMatcher( base );
52
+
53
+ expect( matchActivity( `${base}${sep}stepsXjs` ) ).toBe( false );
54
+ expect( matchActivity( `${base}${sep}evaluatorsXjs` ) ).toBe( false );
55
+ expect( matchActivity( `${base}${sep}steps-extra.js` ) ).toBe( false );
56
+ expect( matchActivity( `${base}${sep}evaluators-extra.js` ) ).toBe( false );
57
+ expect( matchActivity( `${base}${sep}steps_extra${sep}a.js` ) ).toBe( false );
58
+ expect( matchActivity( `${base}${sep}evaluators_extra${sep}a.js` ) ).toBe( false );
59
+ } );
60
+
61
+ it( 'rejects directory names without a nested path', () => {
62
+ const matchActivity = buildActivityMatcher( base );
63
+
64
+ expect( matchActivity( `${base}${sep}steps` ) ).toBe( false );
65
+ expect( matchActivity( `${base}${sep}evaluators` ) ).toBe( false );
66
+ } );
67
+
68
+ it( 'rejects exact files when additional path segments follow', () => {
69
+ const matchActivity = buildActivityMatcher( base );
70
+
71
+ expect( matchActivity( `${base}${sep}steps.js${sep}extra.js` ) ).toBe( false );
72
+ expect( matchActivity( `${base}${sep}evaluators.js${sep}extra.js` ) ).toBe( false );
73
+ } );
74
+
75
+ it( 'escapes regular expression characters in the base path', () => {
76
+ const specialBase = `${sep}app${sep}proj.with-symbols+(test)`;
77
+ const matchActivity = buildActivityMatcher( specialBase );
78
+
79
+ expect( matchActivity( `${specialBase}${sep}steps.js` ) ).toBe( true );
80
+ expect( matchActivity( `${sep}app${sep}projXwith-symbols+(test)${sep}steps.js` ) ).toBe( false );
81
+ } );
82
+ } );
83
+
84
+ describe( 'staticMatchers', () => {
85
+ describe( 'workflowFile', () => {
86
+ it( 'matches paths ending with path separator and workflow.js', () => {
87
+ expect( staticMatchers.workflowFile( `${sep}x${sep}y${sep}workflow.js` ) ).toBe( true );
88
+ } );
89
+
90
+ it( 'rejects workflow.ts', () => {
91
+ expect( staticMatchers.workflowFile( `${sep}a${sep}workflow.ts` ) ).toBe( false );
92
+ } );
93
+ } );
94
+
95
+ describe( 'workflowPathHasShared', () => {
96
+ it( 'matches workflow.js under a shared folder segment', () => {
97
+ expect( staticMatchers.workflowPathHasShared( `${sep}foo${sep}shared${sep}workflow.js` ) ).toBe( true );
98
+ } );
99
+
100
+ it( 'rejects workflow.js not under shared', () => {
101
+ expect( staticMatchers.workflowPathHasShared( `${sep}foo${sep}workflow.js` ) ).toBe( false );
102
+ } );
103
+ } );
104
+
105
+ describe( 'sharedStepsDir', () => {
106
+ it( 'matches .js files inside shared/steps/', () => {
107
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}tools.js` ) ).toBe( true );
108
+ } );
109
+
110
+ it( 'matches .js files in nested subdirectories of shared/steps/', () => {
111
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}utils${sep}helper.js` ) ).toBe( true );
112
+ } );
113
+
114
+ it( 'rejects .ts files inside shared/steps/', () => {
115
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}src${sep}shared${sep}steps${sep}tools.ts` ) ).toBe( false );
116
+ } );
117
+
118
+ it( 'rejects non-.js files inside shared/steps/', () => {
119
+ expect( staticMatchers.sharedStepsDir( `${sep}app${sep}dist${sep}shared${sep}steps${sep}readme.md` ) ).toBe( false );
120
+ } );
121
+ } );
122
+
123
+ describe( 'sharedEvaluatorsDir', () => {
124
+ it( 'matches .js files inside shared/evaluators/', () => {
125
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}quality.js` ) ).toBe( true );
126
+ } );
127
+
128
+ it( 'matches .js files in nested subdirectories of shared/evaluators/', () => {
129
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}utils${sep}helper.js` ) ).toBe( true );
130
+ } );
131
+
132
+ it( 'rejects .ts files inside shared/evaluators/', () => {
133
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}src${sep}shared${sep}evaluators${sep}quality.ts` ) ).toBe( false );
134
+ } );
135
+
136
+ it( 'rejects non-.js files inside shared/evaluators/', () => {
137
+ expect( staticMatchers.sharedEvaluatorsDir( `${sep}app${sep}dist${sep}shared${sep}evaluators${sep}readme.md` ) ).toBe( false );
138
+ } );
139
+ } );
140
+ } );
@@ -1,8 +1,9 @@
1
- import { dirname, resolve, sep } from 'path';
2
- import { pathToFileURL } from 'url';
1
+ import { join, dirname, resolve } from 'node:path';
2
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
3
  import { METADATA_ACCESS_SYMBOL } from '#consts';
4
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'fs';
4
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, mkdirSync, writeFileSync } from 'node:fs';
5
5
  import { hashElement } from 'folder-hash';
6
+ import { staticMatchers } from './matchers.js';
6
7
 
7
8
  /**
8
9
  * Returns the real path for symlink
@@ -20,69 +21,6 @@ export const resolveSymlink = link => {
20
21
  }
21
22
  };
22
23
 
23
- /**
24
- * Returns matchers that need to be built using a relative path
25
- *
26
- * @param {string} path
27
- * @returns {object} The object containing the matchers
28
- */
29
- export const activityMatchersBuilder = path => ( {
30
- /**
31
- * Matches a file called steps.js, located at the path
32
- * @param {string} path - Path to test
33
- * @returns {boolean}
34
- */
35
- stepsFile: v => v === `${path}${sep}steps.js`,
36
- /**
37
- * Matches a file called evaluators.js, located at the path
38
- * @param {string} path - Path to test
39
- * @returns {boolean}
40
- */
41
- evaluatorsFile: v => v === `${path}${sep}evaluators.js`,
42
- /**
43
- * Matches all files on any levels inside a folder called steps/, located at the path
44
- * @param {string} path - Path to test
45
- * @returns {boolean}
46
- */
47
- stepsDir: v => v.startsWith( `${path}${sep}steps${sep}` ),
48
- /**
49
- * Matches all files on any levels inside a folder called evaluators/, located at the path
50
- * @param {string} path - Path to test
51
- * @returns {boolean}
52
- */
53
- evaluatorsDir: v => v.startsWith( `${path}${sep}evaluators${sep}` )
54
- } );
55
-
56
- /**
57
- * Matchers that can be used to access conditions without initializing them
58
- */
59
- export const staticMatchers = {
60
- /**
61
- * Matches a workflow.js file
62
- * @param {string} path - Path to test
63
- * @returns {boolean}
64
- */
65
- workflowFile: v => v.endsWith( `${sep}workflow.js` ),
66
- /**
67
- * Matches a workflow.js that is inside a shared folder: eg foo/shared/workflow.js
68
- * @param {string} path - Path to test
69
- * @returns {boolean}
70
- */
71
- workflowPathHasShared: v => v.endsWith( `${sep}shared${sep}workflow.js` ),
72
- /**
73
- * Matches the shared folder for steps src/shared/steps/../step_file.js
74
- * @param {string} path - Path to test
75
- * @returns {boolean}
76
- */
77
- sharedStepsDir: v => v.includes( `${sep}shared${sep}steps${sep}` ) && v.endsWith( '.js' ),
78
- /**
79
- * Matches the shared folder for evaluators src/shared/evaluators/../evaluator_file.js
80
- * @param {string} path - Path to test
81
- * @returns {boolean}
82
- */
83
- sharedEvaluatorsDir: v => v.includes( `${sep}shared${sep}evaluators${sep}` ) && v.endsWith( '.js' )
84
- };
85
-
86
24
  /**
87
25
  * @typedef {object} File
88
26
  * @property {string} path - The file path
@@ -314,7 +252,7 @@ export const hashSourceCode = async rootDir => {
314
252
  try {
315
253
  const { hash } = await hashElement( rootDir, {
316
254
  folders: {
317
- exclude: [ '.*', 'node_modules', 'test_coverage', 'vendor', 'test' ],
255
+ exclude: [ '.*', 'node_modules', 'test_coverage', 'vendor', 'test', 'logs', 'dist' ],
318
256
  ignoreRootName: true
319
257
  },
320
258
  files: {
@@ -327,3 +265,17 @@ export const hashSourceCode = async rootDir => {
327
265
  throw new Error( `Error calculating hash from "${error}": ${error.message}`, { cause: error } );
328
266
  }
329
267
  };
268
+
269
+ /**
270
+ * Creates a file in the temp folder and returns its path
271
+ * @returns {string} Folder
272
+ */
273
+ export const writeFileInTempDir = ( content, name ) => {
274
+ const here = dirname( fileURLToPath( import.meta.url ) );
275
+ const tempDir = join( here, '..', 'temp' );
276
+ mkdirSync( tempDir, { recursive: true } );
277
+
278
+ const filename = join( tempDir, name );
279
+ writeFileSync( filename, content, 'utf-8' );
280
+ return filename;
281
+ };