@output.ai/llm 0.2.0 → 0.2.2
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/package.json +1 -1
- package/src/ai_sdk.js +53 -35
- package/src/index.d.ts +70 -0
- package/src/index.js +1 -0
- package/src/load_content.js +25 -104
- package/src/load_content.spec.js +47 -63
package/package.json
CHANGED
package/src/ai_sdk.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { Tracing } from '@output.ai/core/tracing';
|
|
2
|
+
import { z } from '@output.ai/core';
|
|
2
3
|
import { loadModel } from './ai_model.js';
|
|
3
4
|
import * as AI from 'ai';
|
|
4
5
|
import { validateGenerateTextArgs, validateGenerateObjectArgs, validateGenerateArrayArgs, validateGenerateEnumArgs } from './validations.js';
|
|
5
6
|
import { loadPrompt } from './prompt_loader.js';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/*
|
|
9
|
+
Word of wisdom:
|
|
10
|
+
We could retrieve the result object using the rest operator:
|
|
11
|
+
```js
|
|
12
|
+
const { usage, providerMetadata, ...rest } = response;
|
|
13
|
+
const result = rest[resultProperty];
|
|
14
|
+
```
|
|
15
|
+
But we CAN'T because the response of the generateText is an instance of `DefaultGenerateTextResult`
|
|
16
|
+
and 'text' is a getter (`get text()`).
|
|
17
|
+
Be aware of this when refactoring.
|
|
18
|
+
*/
|
|
19
|
+
const traceWrapper = async ( { traceId, resultProperty, fn } ) => {
|
|
8
20
|
try {
|
|
9
|
-
const
|
|
10
|
-
|
|
21
|
+
const response = await fn();
|
|
22
|
+
const { usage, providerMetadata } = response;
|
|
23
|
+
const result = response[resultProperty];
|
|
24
|
+
Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata } } );
|
|
11
25
|
return result;
|
|
12
26
|
} catch ( error ) {
|
|
13
27
|
Tracing.addEventError( { id: traceId, details: error } );
|
|
@@ -52,11 +66,12 @@ const extraAiSdkOptionsFromPrompt = prompt => {
|
|
|
52
66
|
export async function generateText( { prompt, variables } ) {
|
|
53
67
|
validateGenerateTextArgs( { prompt, variables } );
|
|
54
68
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
55
|
-
const traceId = startTrace( 'generateText', { prompt
|
|
69
|
+
const traceId = startTrace( 'generateText', { prompt, variables, loadedPrompt } );
|
|
56
70
|
|
|
57
|
-
return traceWrapper(
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
return traceWrapper( {
|
|
72
|
+
traceId, resultProperty: 'text', fn: async () =>
|
|
73
|
+
AI.generateText( extraAiSdkOptionsFromPrompt( loadedPrompt ) )
|
|
74
|
+
} );
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
/**
|
|
@@ -74,17 +89,18 @@ export async function generateObject( args ) {
|
|
|
74
89
|
validateGenerateObjectArgs( args );
|
|
75
90
|
const { prompt, variables, schema, schemaName, schemaDescription } = args;
|
|
76
91
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
77
|
-
const traceId = startTrace( 'generateObject', { ...args,
|
|
92
|
+
const traceId = startTrace( 'generateObject', { ...args, schema: z.toJSONSchema( schema ), loadedPrompt } );
|
|
78
93
|
|
|
79
|
-
return traceWrapper(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
return traceWrapper( {
|
|
95
|
+
traceId, resultProperty: 'object', fn: async () =>
|
|
96
|
+
AI.generateObject( {
|
|
97
|
+
output: 'object',
|
|
98
|
+
schema,
|
|
99
|
+
schemaName,
|
|
100
|
+
schemaDescription,
|
|
101
|
+
...extraAiSdkOptionsFromPrompt( loadedPrompt )
|
|
102
|
+
} )
|
|
103
|
+
} );
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
/**
|
|
@@ -102,17 +118,18 @@ export async function generateArray( args ) {
|
|
|
102
118
|
validateGenerateArrayArgs( args );
|
|
103
119
|
const { prompt, variables, schema, schemaName, schemaDescription } = args;
|
|
104
120
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
105
|
-
const traceId = startTrace( 'generateArray', { ...args,
|
|
121
|
+
const traceId = startTrace( 'generateArray', { ...args, schema: z.toJSONSchema( schema ), loadedPrompt } );
|
|
106
122
|
|
|
107
|
-
return traceWrapper(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
return traceWrapper( {
|
|
124
|
+
traceId, resultProperty: 'object', fn: async () =>
|
|
125
|
+
AI.generateObject( {
|
|
126
|
+
output: 'array',
|
|
127
|
+
schema,
|
|
128
|
+
schemaName,
|
|
129
|
+
schemaDescription,
|
|
130
|
+
...extraAiSdkOptionsFromPrompt( loadedPrompt )
|
|
131
|
+
} )
|
|
132
|
+
} );
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
/**
|
|
@@ -128,13 +145,14 @@ export async function generateEnum( args ) {
|
|
|
128
145
|
validateGenerateEnumArgs( args );
|
|
129
146
|
const { prompt, variables, enum: _enum } = args;
|
|
130
147
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
131
|
-
const traceId = startTrace( 'generateEnum', { ...args,
|
|
148
|
+
const traceId = startTrace( 'generateEnum', { ...args, loadedPrompt } );
|
|
132
149
|
|
|
133
|
-
return traceWrapper(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
return traceWrapper( {
|
|
151
|
+
traceId, resultProperty: 'object', fn: async () =>
|
|
152
|
+
AI.generateObject( {
|
|
153
|
+
output: 'enum',
|
|
154
|
+
enum: _enum,
|
|
155
|
+
...extraAiSdkOptionsFromPrompt( loadedPrompt )
|
|
156
|
+
} )
|
|
157
|
+
} );
|
|
140
158
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
import type { z } from '@output.ai/core';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Represents a single message in a prompt conversation
|
|
5
|
+
* @example
|
|
6
|
+
* const msg: PromptMessage = {
|
|
7
|
+
* role: 'user',
|
|
8
|
+
* content: 'Hello, Claude!'
|
|
9
|
+
* };
|
|
10
|
+
*/
|
|
11
|
+
export type PromptMessage = {
|
|
12
|
+
/** The role of the message. Examples: 'system', 'user', 'assistant' */
|
|
13
|
+
role: string;
|
|
14
|
+
/** The content of the message */
|
|
15
|
+
content: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Configuration for LLM prompt generation
|
|
20
|
+
* @example
|
|
21
|
+
* const prompt: Prompt = {
|
|
22
|
+
* name: 'summarizePrompt',
|
|
23
|
+
* config: {
|
|
24
|
+
* provider: 'anthropic',
|
|
25
|
+
* model: 'claude-opus-4-1',
|
|
26
|
+
* temperature: 0.7,
|
|
27
|
+
* maxTokens: 2048
|
|
28
|
+
* },
|
|
29
|
+
* messages: [...]
|
|
30
|
+
* };
|
|
31
|
+
*/
|
|
32
|
+
export type Prompt = {
|
|
33
|
+
/** Name of the prompt file */
|
|
34
|
+
name: string;
|
|
35
|
+
|
|
36
|
+
/** General configurations for the LLM */
|
|
37
|
+
config: {
|
|
38
|
+
/** LLM Provider */
|
|
39
|
+
provider: 'anthropic' | 'openai' | 'azure';
|
|
40
|
+
|
|
41
|
+
/** Model name/identifier */
|
|
42
|
+
model: string;
|
|
43
|
+
|
|
44
|
+
/** Generation temperature (0-2). Lower = more deterministic */
|
|
45
|
+
temperature?: number;
|
|
46
|
+
|
|
47
|
+
/** Maximum tokens in the response */
|
|
48
|
+
maxTokens?: number;
|
|
49
|
+
|
|
50
|
+
/** Additional provider-specific options */
|
|
51
|
+
options?: Record<string, Record<string, JSONValue>>;
|
|
52
|
+
|
|
53
|
+
/** Provider-specific configurations */
|
|
54
|
+
providerOptions?: Record<string, unknown>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Array of messages in the conversation */
|
|
58
|
+
messages: PromptMessage[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load a prompt file and render it with variables.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} name - Name of the prompt file (without .prompt extension)
|
|
65
|
+
* @param {Record<string, string | number>} [variables] - Variables to interpolate
|
|
66
|
+
* @returns {Prompt} Loaded and rendered prompt object
|
|
67
|
+
*/
|
|
68
|
+
export function loadPrompt(
|
|
69
|
+
name: string,
|
|
70
|
+
variables?: Record<string, string | number>
|
|
71
|
+
): Prompt;
|
|
72
|
+
|
|
3
73
|
/**
|
|
4
74
|
* Use an LLM model to generate text.
|
|
5
75
|
*
|
package/src/index.js
CHANGED
package/src/load_content.js
CHANGED
|
@@ -1,121 +1,42 @@
|
|
|
1
|
-
import { join
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolveInvocationDir } from '@output.ai/core/utils';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
return parenMatch[1];
|
|
11
|
-
}
|
|
12
|
-
if ( directMatch ) {
|
|
13
|
-
return directMatch[1];
|
|
14
|
-
}
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const getInvocationDir = _ => {
|
|
19
|
-
const stack = new Error().stack;
|
|
20
|
-
|
|
21
|
-
if ( !stack ) {
|
|
22
|
-
throw new Error( 'Stack trace is unavailable - cannot determine invocation directory' );
|
|
5
|
+
const scanDir = dir => {
|
|
6
|
+
try {
|
|
7
|
+
return readdirSync( dir, { withFileTypes: true } );
|
|
8
|
+
} catch ( error ) {
|
|
9
|
+
throw new Error( `Error scanning directory "${dir}"`, { cause: error } );
|
|
23
10
|
}
|
|
11
|
+
};
|
|
24
12
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// Match file:// URLs (ESM) or regular file paths
|
|
31
|
-
// Pattern 1: file:///path/to/file.js:line:col
|
|
32
|
-
// Pattern 2: at func (/path/to/file.js:line:col)
|
|
33
|
-
// Pattern 3: /path/to/file.js:line:col
|
|
34
|
-
const fileUrlMatch = line.match( /file:\/\/([^:]+\.(?:js|mjs|ts|tsx|jsx))/ );
|
|
35
|
-
const parenMatch = line.match( /\(([^:)]+\.(?:js|mjs|ts|tsx|jsx)):\d+:\d+\)/ );
|
|
36
|
-
const directMatch = line.match( /^\s*at\s+[^(]*([^:]+\.(?:js|mjs|ts|tsx|jsx)):\d+:\d+/ );
|
|
37
|
-
|
|
38
|
-
// Determine the file path from different stack trace formats
|
|
39
|
-
const filePath = extractFilePath( fileUrlMatch, parenMatch, directMatch );
|
|
40
|
-
|
|
41
|
-
if ( filePath ) {
|
|
42
|
-
// Skip internal Node.js paths
|
|
43
|
-
if ( filePath.includes( 'node:' ) || filePath.includes( 'internal/' ) ) {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Skip SDK internal files - we want the actual caller, not the SDK
|
|
48
|
-
// This includes @output.ai packages and sdk/ directory
|
|
49
|
-
if ( filePath.includes( '/sdk/' ) || filePath.includes( 'node_modules/@output.ai/' ) ) {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Found a valid caller file outside the SDK
|
|
54
|
-
return dirname( filePath );
|
|
55
|
-
}
|
|
13
|
+
const loadFile = path => {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync( path, 'utf-8' );
|
|
16
|
+
} catch ( error ) {
|
|
17
|
+
throw new Error( `Error reading file "${path}"`, { cause: error } );
|
|
56
18
|
}
|
|
57
|
-
|
|
58
|
-
throw new Error(
|
|
59
|
-
'Unable to determine invocation directory from stack trace. ' +
|
|
60
|
-
`Stack preview:\n${lines.slice( 0, 10 ).join( '\n' )}`
|
|
61
|
-
);
|
|
62
19
|
};
|
|
63
20
|
|
|
64
21
|
/**
|
|
65
22
|
* Recursively search for a file by its name and load its content.
|
|
66
23
|
*
|
|
67
24
|
* @param {string} name - Name of the file load its content
|
|
68
|
-
* @param {string} [dir
|
|
69
|
-
* @param {number} [depth=0] - Current recursion depth
|
|
70
|
-
* @param {number} [maxDepth=5] - Maximum recursion depth
|
|
25
|
+
* @param {string} [dir] - The directory to search for the file, defaults to invocation directory
|
|
71
26
|
* @returns {string | null} - File content or null if not found
|
|
72
27
|
*/
|
|
73
|
-
export const loadContent = ( name, dir =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Validate filename doesn't contain path separators (prevent directory traversal)
|
|
80
|
-
if ( name.includes( '..' ) || name.includes( '/' ) || name.includes( '\\' ) ) {
|
|
81
|
-
throw new Error( `Invalid file name "${name}" - must not contain path separators or ".."` );
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const entries = readdirSync( dir, { withFileTypes: true } );
|
|
86
|
-
|
|
87
|
-
for ( const entry of entries ) {
|
|
88
|
-
if ( entry.name === name ) {
|
|
89
|
-
try {
|
|
90
|
-
return readFileSync( join( dir, entry.name ), 'utf-8' );
|
|
91
|
-
} catch ( error ) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`Found file "${name}" in "${dir}" but failed to read it: ${error.message}`,
|
|
94
|
-
{ cause: error }
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Recurse into subdirectories, but skip symlinks to prevent infinite loops
|
|
100
|
-
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
101
|
-
const content = loadContent( name, join( dir, entry.name ), depth + 1, maxDepth );
|
|
102
|
-
if ( content !== null ) {
|
|
103
|
-
return content;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
28
|
+
export const loadContent = ( name, dir = resolveInvocationDir() ) => {
|
|
29
|
+
for ( const entry of scanDir( dir ) ) {
|
|
30
|
+
if ( entry.name === name ) {
|
|
31
|
+
return loadFile( join( dir, entry.name ) );
|
|
106
32
|
}
|
|
107
33
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return null;
|
|
34
|
+
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
35
|
+
const content = loadContent( name, join( dir, entry.name ) );
|
|
36
|
+
if ( content ) {
|
|
37
|
+
return content;
|
|
38
|
+
}
|
|
114
39
|
}
|
|
115
|
-
// Propagate all other errors (permission denied, I/O errors, etc.)
|
|
116
|
-
throw new Error(
|
|
117
|
-
`Failed to read directory "${dir}" while searching for "${name}": ${error.message}`,
|
|
118
|
-
{ cause: error }
|
|
119
|
-
);
|
|
120
40
|
}
|
|
41
|
+
return null;
|
|
121
42
|
};
|
package/src/load_content.spec.js
CHANGED
|
@@ -1,97 +1,81 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { getInvocationDir, loadContent } from './load_content.js';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
2
|
import { join } from 'path';
|
|
4
3
|
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
4
|
import { tmpdir } from 'node:os';
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
// Hoisted state so mocks can read dynamic values set in tests
|
|
7
|
+
const state = vi.hoisted( () => ( { dir: '', entries: {} } ) );
|
|
8
|
+
|
|
9
|
+
// Mock core utils to control resolveInvocationDir
|
|
10
|
+
vi.mock( '@output.ai/core/utils', () => ( {
|
|
11
|
+
resolveInvocationDir: () => state.dir
|
|
12
|
+
} ) );
|
|
13
|
+
|
|
14
|
+
// Mock node:fs.readFileSync for directory scans while delegating file reads
|
|
15
|
+
vi.mock( 'node:fs', async importOriginal => {
|
|
16
|
+
const actual = await importOriginal();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
readFileSync: vi.fn( ( path, options ) => {
|
|
20
|
+
if ( options && typeof options === 'object' && options.withFileTypes ) {
|
|
21
|
+
return state.entries[path] || [];
|
|
22
|
+
}
|
|
23
|
+
return actual.readFileSync( path, options );
|
|
24
|
+
} )
|
|
25
|
+
};
|
|
26
|
+
} );
|
|
15
27
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
const dirEntry = ( name, { isDir = false, isLink = false } = {} ) => ( {
|
|
29
|
+
name,
|
|
30
|
+
isDirectory: () => isDir,
|
|
31
|
+
isSymbolicLink: () => isLink
|
|
32
|
+
} );
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
expect( dir ).toBeTruthy();
|
|
26
|
-
expect( typeof dir ).toBe( 'string' );
|
|
27
|
-
expect( dir.length ).toBeGreaterThan( 0 );
|
|
28
|
-
} );
|
|
34
|
+
beforeEach( () => {
|
|
35
|
+
state.dir = '';
|
|
36
|
+
state.entries = {};
|
|
29
37
|
} );
|
|
30
38
|
|
|
31
39
|
describe( 'loadContent', () => {
|
|
32
|
-
it( 'loads file from root directory', () => {
|
|
40
|
+
it( 'loads file from root directory using mocked resolveInvocationDir', async () => {
|
|
33
41
|
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
42
|
+
state.dir = tempDir;
|
|
34
43
|
const testContent = 'test file content';
|
|
35
44
|
writeFileSync( join( tempDir, 'test.txt' ), testContent );
|
|
45
|
+
state.entries[tempDir] = [ dirEntry( 'test.txt' ) ];
|
|
36
46
|
|
|
37
|
-
const
|
|
47
|
+
const { loadContent } = await import( './load_content.js' );
|
|
48
|
+
const content = loadContent( 'test.txt' );
|
|
38
49
|
|
|
39
50
|
expect( content ).toBe( testContent );
|
|
40
51
|
} );
|
|
41
52
|
|
|
42
|
-
it( 'loads file from nested subdirectory', () => {
|
|
53
|
+
it( 'loads file from nested subdirectory via recursion', async () => {
|
|
43
54
|
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
44
55
|
const subDir = join( tempDir, 'subdir' );
|
|
45
56
|
mkdirSync( subDir );
|
|
57
|
+
state.dir = tempDir;
|
|
46
58
|
|
|
47
59
|
const testContent = 'nested file content';
|
|
48
60
|
writeFileSync( join( subDir, 'nested.txt' ), testContent );
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
state.entries[tempDir] = [ dirEntry( 'subdir', { isDir: true } ) ];
|
|
63
|
+
state.entries[subDir] = [ dirEntry( 'nested.txt' ) ];
|
|
64
|
+
|
|
65
|
+
const { loadContent } = await import( './load_content.js' );
|
|
66
|
+
const content = loadContent( 'nested.txt' );
|
|
51
67
|
|
|
52
68
|
expect( content ).toBe( testContent );
|
|
53
69
|
} );
|
|
54
70
|
|
|
55
|
-
it( 'returns null when file does not exist', () => {
|
|
71
|
+
it( 'returns null when file does not exist', async () => {
|
|
56
72
|
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
73
|
+
state.dir = tempDir;
|
|
74
|
+
state.entries[tempDir] = [ dirEntry( 'other.txt' ) ];
|
|
57
75
|
|
|
58
|
-
const
|
|
76
|
+
const { loadContent } = await import( './load_content.js' );
|
|
77
|
+
const content = loadContent( 'nonexistent.txt' );
|
|
59
78
|
|
|
60
79
|
expect( content ).toBeNull();
|
|
61
80
|
} );
|
|
62
|
-
|
|
63
|
-
it( 'returns null when starting directory does not exist', () => {
|
|
64
|
-
// ENOENT is suppressed and returns null (expected behavior)
|
|
65
|
-
const content = loadContent( 'test.txt', '/nonexistent-root-path-12345' );
|
|
66
|
-
expect( content ).toBeNull();
|
|
67
|
-
} );
|
|
68
|
-
|
|
69
|
-
it( 'respects depth limit', () => {
|
|
70
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
71
|
-
|
|
72
|
-
// Create deeply nested structure: dir/1/2/3/4/5/6/deep.txt (7 levels)
|
|
73
|
-
const levels = [ 1, 2, 3, 4, 5, 6 ];
|
|
74
|
-
const deepPath = levels.reduce( ( acc, level ) => {
|
|
75
|
-
const newPath = join( acc, `level${level}` );
|
|
76
|
-
mkdirSync( newPath );
|
|
77
|
-
return newPath;
|
|
78
|
-
}, tempDir );
|
|
79
|
-
writeFileSync( join( deepPath, 'deep.txt' ), 'deeply nested' );
|
|
80
|
-
|
|
81
|
-
// Should NOT find it with default maxDepth of 5
|
|
82
|
-
const content = loadContent( 'deep.txt', tempDir );
|
|
83
|
-
expect( content ).toBeNull();
|
|
84
|
-
} );
|
|
85
|
-
|
|
86
|
-
it( 'throws error when filename contains path separators', () => {
|
|
87
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
88
|
-
|
|
89
|
-
expect( () => {
|
|
90
|
-
loadContent( '../test.txt', tempDir );
|
|
91
|
-
} ).toThrow( /Invalid file name/ );
|
|
92
|
-
|
|
93
|
-
expect( () => {
|
|
94
|
-
loadContent( 'foo/bar.txt', tempDir );
|
|
95
|
-
} ).toThrow( /Invalid file name/ );
|
|
96
|
-
} );
|
|
97
81
|
} );
|