@liquidmetal-ai/precip 1.0.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.
- package/.prettierrc +9 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +28 -0
- package/package.json +53 -0
- package/src/engine/agent.ts +478 -0
- package/src/engine/llm-provider.test.ts +275 -0
- package/src/engine/llm-provider.ts +330 -0
- package/src/engine/stream-parser.ts +170 -0
- package/src/index.ts +142 -0
- package/src/mounts/mount-manager.test.ts +516 -0
- package/src/mounts/mount-manager.ts +327 -0
- package/src/mounts/mount-registry.ts +196 -0
- package/src/mounts/zod-to-string.test.ts +154 -0
- package/src/mounts/zod-to-string.ts +213 -0
- package/src/presets/agent-tools.ts +57 -0
- package/src/presets/index.ts +5 -0
- package/src/sandbox/README.md +1321 -0
- package/src/sandbox/bridges/README.md +571 -0
- package/src/sandbox/bridges/actor.test.ts +229 -0
- package/src/sandbox/bridges/actor.ts +195 -0
- package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
- package/src/sandbox/bridges/bucket.test.ts +300 -0
- package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
- package/src/sandbox/bridges/console-multiple.test.ts +187 -0
- package/src/sandbox/bridges/console.test.ts +157 -0
- package/src/sandbox/bridges/console.ts +122 -0
- package/src/sandbox/bridges/fetch.ts +93 -0
- package/src/sandbox/bridges/index.ts +78 -0
- package/src/sandbox/bridges/readable-stream.ts +323 -0
- package/src/sandbox/bridges/response.test.ts +154 -0
- package/src/sandbox/bridges/response.ts +123 -0
- package/src/sandbox/bridges/review-fixes.test.ts +331 -0
- package/src/sandbox/bridges/search.test.ts +475 -0
- package/src/sandbox/bridges/search.ts +264 -0
- package/src/sandbox/bridges/shared/body-methods.ts +93 -0
- package/src/sandbox/bridges/shared/cleanup.ts +112 -0
- package/src/sandbox/bridges/shared/convert.ts +76 -0
- package/src/sandbox/bridges/shared/headers.ts +181 -0
- package/src/sandbox/bridges/shared/index.ts +36 -0
- package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
- package/src/sandbox/bridges/shared/path-parser.ts +109 -0
- package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
- package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
- package/src/sandbox/bridges/shared/response-object.ts +280 -0
- package/src/sandbox/bridges/shared/result-builder.ts +130 -0
- package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
- package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
- package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
- package/src/sandbox/bridges/storage.ts +421 -0
- package/src/sandbox/bridges/text-decoder.ts +190 -0
- package/src/sandbox/bridges/text-encoder.ts +102 -0
- package/src/sandbox/bridges/types.ts +39 -0
- package/src/sandbox/bridges/utils.ts +123 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/quickjs-wasm.d.ts +9 -0
- package/src/sandbox/sandbox.test.ts +191 -0
- package/src/sandbox/sandbox.ts +831 -0
- package/src/sandbox/test-helper.ts +43 -0
- package/src/sandbox/test-mocks.ts +154 -0
- package/src/sandbox/user-stream.test.ts +77 -0
- package/src/skills/frontmatter.test.ts +305 -0
- package/src/skills/frontmatter.ts +200 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/skills-loader.test.ts +237 -0
- package/src/skills/skills-loader.ts +200 -0
- package/src/tools/actor-storage-tools.ts +250 -0
- package/src/tools/code-tools.test.ts +199 -0
- package/src/tools/code-tools.ts +444 -0
- package/src/tools/file-tools.ts +206 -0
- package/src/tools/registry.ts +125 -0
- package/src/tools/script-tools.ts +145 -0
- package/src/tools/smartbucket-tools.ts +203 -0
- package/src/tools/sql-tools.ts +213 -0
- package/src/tools/tool-factory.ts +119 -0
- package/src/types.ts +512 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +33 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper that mimics deprecated executeWithAsyncHost API
|
|
3
|
+
*
|
|
4
|
+
* Uses Sandbox under the hood for consistency with production code.
|
|
5
|
+
* This helper exists solely for test compatibility and should NOT be used in production.
|
|
6
|
+
*
|
|
7
|
+
* @deprecated Use Sandbox.configure() + Sandbox.getInstance() + sandbox.execute() instead
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Sandbox } from './index.js';
|
|
11
|
+
import type {
|
|
12
|
+
SandboxGlobals,
|
|
13
|
+
SandboxResult,
|
|
14
|
+
Logger
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
import type { BridgeInstaller } from './bridges/types.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Execute code in a sandbox (test compatibility layer)
|
|
20
|
+
*
|
|
21
|
+
* Ensures the sandbox is configured before executing.
|
|
22
|
+
*
|
|
23
|
+
* @deprecated Use Sandbox.configure() + Sandbox.getInstance() + sandbox.execute() instead
|
|
24
|
+
*/
|
|
25
|
+
export async function executeWithAsyncHost(
|
|
26
|
+
code: string,
|
|
27
|
+
asyncGlobals: SandboxGlobals,
|
|
28
|
+
options: {
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
memoryLimitBytes?: number;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
bridgeInstallers?: BridgeInstaller[];
|
|
33
|
+
} = {}
|
|
34
|
+
): Promise<SandboxResult> {
|
|
35
|
+
// In tests, configure if not already configured, otherwise just get the instance
|
|
36
|
+
let sandbox: Sandbox;
|
|
37
|
+
try {
|
|
38
|
+
sandbox = await Sandbox.getInstance();
|
|
39
|
+
} catch {
|
|
40
|
+
sandbox = await Sandbox.configure(options);
|
|
41
|
+
}
|
|
42
|
+
return sandbox.execute(code, asyncGlobals, options);
|
|
43
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities for mocking fetch responses
|
|
3
|
+
*
|
|
4
|
+
* Provides mock implementations of fetch to avoid external network dependencies
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface MockFetchResponse {
|
|
8
|
+
status?: number;
|
|
9
|
+
statusText?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
body?: string | Uint8Array;
|
|
12
|
+
delay?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MockFetchError extends Error {
|
|
16
|
+
constructor(message: string, public status?: number) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'MockFetchError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a mock fetch implementation for testing
|
|
24
|
+
*/
|
|
25
|
+
export function createMockFetch(responses: Map<string, MockFetchResponse> = new Map()) {
|
|
26
|
+
return async function mockFetch(url: string, init?: RequestInit): Promise<Response> {
|
|
27
|
+
const urlKey = url.split('?')[0] ?? url; // Strip query params
|
|
28
|
+
const mock = responses.get(urlKey);
|
|
29
|
+
|
|
30
|
+
if (!mock) {
|
|
31
|
+
throw new MockFetchError(`No mock response configured for: ${urlKey}`, 404);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Simulate delay if specified
|
|
35
|
+
if (mock.delay) {
|
|
36
|
+
await new Promise(resolve => setTimeout(resolve, mock.delay));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Simulate abort
|
|
40
|
+
if (init?.signal) {
|
|
41
|
+
if (init.signal.aborted) {
|
|
42
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rawBody = mock.body !== undefined ? mock.body : JSON.stringify({ mock: true });
|
|
47
|
+
const headers = new Headers(mock.headers);
|
|
48
|
+
|
|
49
|
+
if (typeof rawBody === 'string') {
|
|
50
|
+
headers.set('content-type', 'application/json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Convert Uint8Array to ArrayBuffer for Response compatibility
|
|
54
|
+
const body = rawBody instanceof Uint8Array ? rawBody.buffer as ArrayBuffer : rawBody;
|
|
55
|
+
|
|
56
|
+
return new Response(body, {
|
|
57
|
+
status: mock.status ?? 200,
|
|
58
|
+
statusText: mock.statusText ?? 'OK',
|
|
59
|
+
headers
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Setup common mock responses for httpbin.org endpoints
|
|
66
|
+
*/
|
|
67
|
+
export function createHttpbinMocks(): Map<string, MockFetchResponse> {
|
|
68
|
+
const mocks = new Map<string, MockFetchResponse>();
|
|
69
|
+
|
|
70
|
+
// GET https://httpbin.org/get
|
|
71
|
+
mocks.set('https://httpbin.org/get', {
|
|
72
|
+
status: 200,
|
|
73
|
+
headers: { 'content-type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
args: {},
|
|
76
|
+
url: 'https://httpbin.org/get',
|
|
77
|
+
headers: { 'User-Agent': 'Test' },
|
|
78
|
+
origin: '127.0.0.1'
|
|
79
|
+
})
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// POST https://httpbin.org/uuid
|
|
83
|
+
mocks.set('https://httpbin.org/uuid', {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: { 'content-type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
uuid: '550e8400-e29b-41d4-a716-446655440000'
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// GET https://httpbin.org/json
|
|
92
|
+
mocks.set('https://httpbin.org/json', {
|
|
93
|
+
status: 200,
|
|
94
|
+
headers: { 'content-type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
slideshow: { author: 'Test User' }
|
|
97
|
+
})
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// GET https://httpbin.org/stream-bytes/N
|
|
101
|
+
mocks.set('https://httpbin.org/stream-bytes/10', {
|
|
102
|
+
status: 200,
|
|
103
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
104
|
+
body: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
mocks.set('https://httpbin.org/stream-bytes/50', {
|
|
108
|
+
status: 200,
|
|
109
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
110
|
+
body: new Uint8Array(Array.from({ length: 50 }, (_, i) => i % 256))
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
mocks.set('https://httpbin.org/stream-bytes/10000', {
|
|
114
|
+
status: 200,
|
|
115
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
116
|
+
body: new Uint8Array(Array.from({ length: 10000 }, (_, i) => i % 256))
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// GET https://httpbin.org/delay/N
|
|
120
|
+
const createDelayMock = (delayMs: number): MockFetchResponse => ({
|
|
121
|
+
status: 200,
|
|
122
|
+
headers: { 'content-type': 'application/json' },
|
|
123
|
+
delay: delayMs,
|
|
124
|
+
body: JSON.stringify({ delay: delayMs })
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
mocks.set('https://httpbin.org/delay/5', createDelayMock(5000));
|
|
128
|
+
mocks.set('https://httpbin.org/delay/10', createDelayMock(10000));
|
|
129
|
+
|
|
130
|
+
return mocks;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a bridge installer that mocks fetch
|
|
135
|
+
*/
|
|
136
|
+
import type { BridgeInstaller } from './bridges/types.js';
|
|
137
|
+
import { installFetch } from './bridges/fetch.js';
|
|
138
|
+
|
|
139
|
+
export function createMockFetchBridgeInstaller(): BridgeInstaller {
|
|
140
|
+
const mockFetch = createMockFetch(createHttpbinMocks());
|
|
141
|
+
|
|
142
|
+
return (ctx) => {
|
|
143
|
+
installFetch(ctx);
|
|
144
|
+
|
|
145
|
+
// Replace global fetch with mock
|
|
146
|
+
const originalFetch = globalThis.fetch;
|
|
147
|
+
globalThis.fetch = mockFetch as any;
|
|
148
|
+
|
|
149
|
+
// Return cleanup that restores original fetch
|
|
150
|
+
return () => {
|
|
151
|
+
globalThis.fetch = originalFetch;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test the exact user code that was failing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { executeWithAsyncHost } from './test-helper.js';
|
|
7
|
+
import { createHttpbinMocks } from './test-mocks.js';
|
|
8
|
+
|
|
9
|
+
describe('User Stream Test', () => {
|
|
10
|
+
let mockFetch: any;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
const mocks = createHttpbinMocks();
|
|
14
|
+
mockFetch = vi.fn(async (url: string, _init?: RequestInit) => {
|
|
15
|
+
const urlKey = url.split('?')[0];
|
|
16
|
+
const mock = mocks.get(urlKey);
|
|
17
|
+
if (!mock) {
|
|
18
|
+
throw new Error(`No mock for: ${urlKey}`);
|
|
19
|
+
}
|
|
20
|
+
if (mock.delay) {
|
|
21
|
+
await new Promise(resolve => setTimeout(resolve, mock.delay));
|
|
22
|
+
}
|
|
23
|
+
return new Response(mock.body, {
|
|
24
|
+
status: mock.status ?? 200,
|
|
25
|
+
statusText: mock.statusText ?? 'OK',
|
|
26
|
+
headers: new Headers(mock.headers)
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
globalThis.fetch = mockFetch;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should handle streaming response with TextDecoder', async () => {
|
|
37
|
+
const code = `
|
|
38
|
+
async function main() {
|
|
39
|
+
const res = await fetch('https://httpbin.org/get');
|
|
40
|
+
|
|
41
|
+
const reader = res.body.getReader();
|
|
42
|
+
const decoder = new TextDecoder();
|
|
43
|
+
let result = '';
|
|
44
|
+
|
|
45
|
+
while (true) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
const text = decoder.decode(value, { stream: true });
|
|
49
|
+
result += text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
status: res.status,
|
|
54
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
55
|
+
body: result
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return await main();
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const result = await executeWithAsyncHost(
|
|
63
|
+
code,
|
|
64
|
+
{},
|
|
65
|
+
{
|
|
66
|
+
timeoutMs: 10000
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
expect(result.result.status).toBe(200);
|
|
72
|
+
expect(mockFetch).toHaveBeenCalledWith('https://httpbin.org/get', expect.objectContaining({
|
|
73
|
+
signal: expect.any(AbortSignal)
|
|
74
|
+
}));
|
|
75
|
+
expect(Object.keys(result.result.headers).length).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for YAML frontmatter parser
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
7
|
+
|
|
8
|
+
describe('parseFrontmatter', () => {
|
|
9
|
+
it('should parse basic frontmatter with required fields', () => {
|
|
10
|
+
const content = `---
|
|
11
|
+
name: data-cleanup
|
|
12
|
+
description: Normalize and deduplicate data across tables.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Data Cleanup Skill
|
|
16
|
+
|
|
17
|
+
Instructions here...`;
|
|
18
|
+
|
|
19
|
+
const result = parseFrontmatter(content);
|
|
20
|
+
expect(result).not.toBeNull();
|
|
21
|
+
expect(result!.name).toBe('data-cleanup');
|
|
22
|
+
expect(result!.description).toBe('Normalize and deduplicate data across tables.');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should parse frontmatter with all optional fields', () => {
|
|
26
|
+
const content = `---
|
|
27
|
+
name: pdf-processing
|
|
28
|
+
description: Extracts text and tables from PDF files, fills forms, merges documents.
|
|
29
|
+
license: Apache-2.0
|
|
30
|
+
compatibility: Requires poppler and ghostscript
|
|
31
|
+
allowed-tools: Bash(pdftotext:*) Read
|
|
32
|
+
metadata:
|
|
33
|
+
author: example-org
|
|
34
|
+
version: "1.0"
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
# PDF Processing`;
|
|
38
|
+
|
|
39
|
+
const result = parseFrontmatter(content);
|
|
40
|
+
expect(result).not.toBeNull();
|
|
41
|
+
expect(result!.name).toBe('pdf-processing');
|
|
42
|
+
expect(result!.description).toBe(
|
|
43
|
+
'Extracts text and tables from PDF files, fills forms, merges documents.'
|
|
44
|
+
);
|
|
45
|
+
expect(result!.license).toBe('Apache-2.0');
|
|
46
|
+
expect(result!.compatibility).toBe('Requires poppler and ghostscript');
|
|
47
|
+
expect(result!.allowedTools).toBe('Bash(pdftotext:*) Read');
|
|
48
|
+
expect(result!.metadata).toEqual({ author: 'example-org', version: '1.0' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return null for content without frontmatter', () => {
|
|
52
|
+
const content = `# Just a Markdown File
|
|
53
|
+
|
|
54
|
+
No frontmatter here.`;
|
|
55
|
+
|
|
56
|
+
expect(parseFrontmatter(content)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return null for unclosed frontmatter', () => {
|
|
60
|
+
const content = `---
|
|
61
|
+
name: broken
|
|
62
|
+
description: No closing delimiter`;
|
|
63
|
+
|
|
64
|
+
expect(parseFrontmatter(content)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return null for frontmatter missing required name', () => {
|
|
68
|
+
const content = `---
|
|
69
|
+
description: Has description but no name
|
|
70
|
+
---`;
|
|
71
|
+
|
|
72
|
+
expect(parseFrontmatter(content)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null for frontmatter missing required description', () => {
|
|
76
|
+
const content = `---
|
|
77
|
+
name: has-name
|
|
78
|
+
---`;
|
|
79
|
+
|
|
80
|
+
expect(parseFrontmatter(content)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should handle quoted values', () => {
|
|
84
|
+
const content = `---
|
|
85
|
+
name: my-skill
|
|
86
|
+
description: "A skill with quoted description"
|
|
87
|
+
---`;
|
|
88
|
+
|
|
89
|
+
const result = parseFrontmatter(content);
|
|
90
|
+
expect(result).not.toBeNull();
|
|
91
|
+
expect(result!.description).toBe('A skill with quoted description');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should handle single-quoted values', () => {
|
|
95
|
+
const content = `---
|
|
96
|
+
name: my-skill
|
|
97
|
+
description: 'Single quoted description'
|
|
98
|
+
---`;
|
|
99
|
+
|
|
100
|
+
const result = parseFrontmatter(content);
|
|
101
|
+
expect(result).not.toBeNull();
|
|
102
|
+
expect(result!.description).toBe('Single quoted description');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should skip comments in frontmatter', () => {
|
|
106
|
+
const content = `---
|
|
107
|
+
# This is a comment
|
|
108
|
+
name: my-skill
|
|
109
|
+
description: A skill
|
|
110
|
+
# Another comment
|
|
111
|
+
---`;
|
|
112
|
+
|
|
113
|
+
const result = parseFrontmatter(content);
|
|
114
|
+
expect(result).not.toBeNull();
|
|
115
|
+
expect(result!.name).toBe('my-skill');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should skip empty lines in frontmatter', () => {
|
|
119
|
+
const content = `---
|
|
120
|
+
name: my-skill
|
|
121
|
+
|
|
122
|
+
description: A skill
|
|
123
|
+
|
|
124
|
+
---`;
|
|
125
|
+
|
|
126
|
+
const result = parseFrontmatter(content);
|
|
127
|
+
expect(result).not.toBeNull();
|
|
128
|
+
expect(result!.name).toBe('my-skill');
|
|
129
|
+
expect(result!.description).toBe('A skill');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should handle metadata with tab indentation', () => {
|
|
133
|
+
const content = `---
|
|
134
|
+
name: my-skill
|
|
135
|
+
description: A skill
|
|
136
|
+
metadata:
|
|
137
|
+
\tauthor: test-org
|
|
138
|
+
\tversion: "2.0"
|
|
139
|
+
---`;
|
|
140
|
+
|
|
141
|
+
const result = parseFrontmatter(content);
|
|
142
|
+
expect(result).not.toBeNull();
|
|
143
|
+
expect(result!.metadata).toEqual({ author: 'test-org', version: '2.0' });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('parseFrontmatter — block scalars', () => {
|
|
148
|
+
it('should join folded scalar (>) lines with spaces', () => {
|
|
149
|
+
const content = `---
|
|
150
|
+
name: my-skill
|
|
151
|
+
description: >
|
|
152
|
+
This is a long
|
|
153
|
+
description that
|
|
154
|
+
spans multiple lines.
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
# Skill`;
|
|
158
|
+
|
|
159
|
+
const result = parseFrontmatter(content);
|
|
160
|
+
expect(result).not.toBeNull();
|
|
161
|
+
expect(result!.description).toBe(
|
|
162
|
+
'This is a long description that spans multiple lines.'
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should preserve newlines in literal scalar (|)', () => {
|
|
167
|
+
const content = `---
|
|
168
|
+
name: my-skill
|
|
169
|
+
description: |
|
|
170
|
+
Line one.
|
|
171
|
+
Line two.
|
|
172
|
+
Line three.
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
# Skill`;
|
|
176
|
+
|
|
177
|
+
const result = parseFrontmatter(content);
|
|
178
|
+
expect(result).not.toBeNull();
|
|
179
|
+
expect(result!.description).toBe('Line one.\nLine two.\nLine three.');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should handle mixed single-line and block scalar fields', () => {
|
|
183
|
+
const content = `---
|
|
184
|
+
name: mixed-skill
|
|
185
|
+
description: >
|
|
186
|
+
A multi-line
|
|
187
|
+
folded description.
|
|
188
|
+
license: MIT
|
|
189
|
+
compatibility: |
|
|
190
|
+
Requires Node 18+
|
|
191
|
+
and npm 9+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
# Mixed`;
|
|
195
|
+
|
|
196
|
+
const result = parseFrontmatter(content);
|
|
197
|
+
expect(result).not.toBeNull();
|
|
198
|
+
expect(result!.name).toBe('mixed-skill');
|
|
199
|
+
expect(result!.description).toBe('A multi-line folded description.');
|
|
200
|
+
expect(result!.license).toBe('MIT');
|
|
201
|
+
expect(result!.compatibility).toBe('Requires Node 18+\nand npm 9+');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should still parse existing single-line frontmatter unchanged', () => {
|
|
205
|
+
const content = `---
|
|
206
|
+
name: simple
|
|
207
|
+
description: A simple single-line description.
|
|
208
|
+
license: Apache-2.0
|
|
209
|
+
---`;
|
|
210
|
+
|
|
211
|
+
const result = parseFrontmatter(content);
|
|
212
|
+
expect(result).not.toBeNull();
|
|
213
|
+
expect(result!.name).toBe('simple');
|
|
214
|
+
expect(result!.description).toBe('A simple single-line description.');
|
|
215
|
+
expect(result!.license).toBe('Apache-2.0');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should preserve empty lines within a literal block scalar', () => {
|
|
219
|
+
const content = `---
|
|
220
|
+
name: my-skill
|
|
221
|
+
description: |
|
|
222
|
+
First paragraph.
|
|
223
|
+
|
|
224
|
+
Second paragraph.
|
|
225
|
+
---`;
|
|
226
|
+
|
|
227
|
+
const result = parseFrontmatter(content);
|
|
228
|
+
expect(result).not.toBeNull();
|
|
229
|
+
expect(result!.description).toBe('First paragraph.\n\nSecond paragraph.');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should treat empty lines in folded scalar as paragraph breaks', () => {
|
|
233
|
+
const content = `---
|
|
234
|
+
name: my-skill
|
|
235
|
+
description: >
|
|
236
|
+
First paragraph
|
|
237
|
+
continues here.
|
|
238
|
+
|
|
239
|
+
Second paragraph
|
|
240
|
+
also continues.
|
|
241
|
+
---`;
|
|
242
|
+
|
|
243
|
+
const result = parseFrontmatter(content);
|
|
244
|
+
expect(result).not.toBeNull();
|
|
245
|
+
expect(result!.description).toBe(
|
|
246
|
+
'First paragraph continues here.\n\nSecond paragraph also continues.'
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should handle a block scalar with only one indented line', () => {
|
|
251
|
+
const content = `---
|
|
252
|
+
name: my-skill
|
|
253
|
+
description: >
|
|
254
|
+
Just one line.
|
|
255
|
+
---`;
|
|
256
|
+
|
|
257
|
+
const result = parseFrontmatter(content);
|
|
258
|
+
expect(result).not.toBeNull();
|
|
259
|
+
expect(result!.description).toBe('Just one line.');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should handle literal scalar with only one indented line', () => {
|
|
263
|
+
const content = `---
|
|
264
|
+
name: my-skill
|
|
265
|
+
description: |
|
|
266
|
+
Single literal line.
|
|
267
|
+
---`;
|
|
268
|
+
|
|
269
|
+
const result = parseFrontmatter(content);
|
|
270
|
+
expect(result).not.toBeNull();
|
|
271
|
+
expect(result!.description).toBe('Single literal line.');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should work with block scalar followed by metadata', () => {
|
|
275
|
+
const content = `---
|
|
276
|
+
name: my-skill
|
|
277
|
+
description: >
|
|
278
|
+
A folded
|
|
279
|
+
description here.
|
|
280
|
+
metadata:
|
|
281
|
+
author: test-org
|
|
282
|
+
version: "1.0"
|
|
283
|
+
---`;
|
|
284
|
+
|
|
285
|
+
const result = parseFrontmatter(content);
|
|
286
|
+
expect(result).not.toBeNull();
|
|
287
|
+
expect(result!.description).toBe('A folded description here.');
|
|
288
|
+
expect(result!.metadata).toEqual({ author: 'test-org', version: '1.0' });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should handle block scalar with tab-indented content', () => {
|
|
292
|
+
const content = `---
|
|
293
|
+
name: my-skill
|
|
294
|
+
description: |
|
|
295
|
+
\tTab indented line one.
|
|
296
|
+
\tTab indented line two.
|
|
297
|
+
---`;
|
|
298
|
+
|
|
299
|
+
const result = parseFrontmatter(content);
|
|
300
|
+
expect(result).not.toBeNull();
|
|
301
|
+
expect(result!.description).toBe(
|
|
302
|
+
'Tab indented line one.\nTab indented line two.'
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|