@positronic/cli 0.0.3 → 0.0.4
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/dist/src/commands/helpers.js +11 -25
- package/dist/types/commands/helpers.d.ts.map +1 -1
- package/package.json +5 -1
- package/dist/src/commands/brain.test.js +0 -2936
- package/dist/src/commands/helpers.test.js +0 -832
- package/dist/src/commands/project.test.js +0 -1201
- package/dist/src/commands/resources.test.js +0 -2511
- package/dist/src/commands/schedule.test.js +0 -1235
- package/dist/src/commands/secret.test.d.js +0 -1
- package/dist/src/commands/secret.test.js +0 -761
- package/dist/src/commands/server.test.js +0 -1237
- package/dist/src/commands/test-utils.js +0 -737
- package/dist/src/components/secret-sync.js +0 -303
- package/dist/src/test/mock-api-client.js +0 -371
- package/dist/src/test/test-dev-server.js +0 -1376
- package/dist/types/commands/test-utils.d.ts +0 -45
- package/dist/types/commands/test-utils.d.ts.map +0 -1
- package/dist/types/components/secret-sync.d.ts +0 -9
- package/dist/types/components/secret-sync.d.ts.map +0 -1
- package/dist/types/test/mock-api-client.d.ts +0 -25
- package/dist/types/test/mock-api-client.d.ts.map +0 -1
- package/dist/types/test/test-dev-server.d.ts +0 -129
- package/dist/types/test/test-dev-server.d.ts.map +0 -1
- package/src/cli.ts +0 -997
- package/src/commands/backend.ts +0 -63
- package/src/commands/brain.test.ts +0 -1004
- package/src/commands/brain.ts +0 -215
- package/src/commands/helpers.test.ts +0 -487
- package/src/commands/helpers.ts +0 -870
- package/src/commands/project-config-manager.ts +0 -152
- package/src/commands/project.test.ts +0 -502
- package/src/commands/project.ts +0 -109
- package/src/commands/resources.test.ts +0 -1052
- package/src/commands/resources.ts +0 -97
- package/src/commands/schedule.test.ts +0 -481
- package/src/commands/schedule.ts +0 -65
- package/src/commands/secret.test.ts +0 -210
- package/src/commands/secret.ts +0 -50
- package/src/commands/server.test.ts +0 -493
- package/src/commands/server.ts +0 -353
- package/src/commands/test-utils.ts +0 -324
- package/src/components/brain-history.tsx +0 -198
- package/src/components/brain-list.tsx +0 -105
- package/src/components/brain-rerun.tsx +0 -111
- package/src/components/brain-show.tsx +0 -92
- package/src/components/error.tsx +0 -24
- package/src/components/project-add.tsx +0 -59
- package/src/components/project-create.tsx +0 -83
- package/src/components/project-list.tsx +0 -83
- package/src/components/project-remove.tsx +0 -55
- package/src/components/project-select.tsx +0 -200
- package/src/components/project-show.tsx +0 -58
- package/src/components/resource-clear.tsx +0 -127
- package/src/components/resource-delete.tsx +0 -160
- package/src/components/resource-list.tsx +0 -177
- package/src/components/resource-sync.tsx +0 -170
- package/src/components/resource-types.tsx +0 -55
- package/src/components/resource-upload.tsx +0 -182
- package/src/components/schedule-create.tsx +0 -90
- package/src/components/schedule-delete.tsx +0 -116
- package/src/components/schedule-list.tsx +0 -186
- package/src/components/schedule-runs.tsx +0 -151
- package/src/components/secret-bulk.tsx +0 -79
- package/src/components/secret-create.tsx +0 -49
- package/src/components/secret-delete.tsx +0 -41
- package/src/components/secret-list.tsx +0 -41
- package/src/components/watch.tsx +0 -155
- package/src/hooks/useApi.ts +0 -183
- package/src/positronic.ts +0 -40
- package/src/test/data/resources/config.json +0 -1
- package/src/test/data/resources/data/config.json +0 -1
- package/src/test/data/resources/data/logo.png +0 -2
- package/src/test/data/resources/docs/api.md +0 -3
- package/src/test/data/resources/docs/readme.md +0 -3
- package/src/test/data/resources/example.md +0 -3
- package/src/test/data/resources/file with spaces.txt +0 -1
- package/src/test/data/resources/readme.md +0 -3
- package/src/test/data/resources/test.txt +0 -1
- package/src/test/mock-api-client.ts +0 -145
- package/src/test/test-dev-server.ts +0 -1003
- package/tsconfig.json +0 -11
package/src/commands/brain.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import type { ArgumentsCamelCase } from 'yargs';
|
|
2
|
-
import { apiClient } from './helpers.js';
|
|
3
|
-
import React from 'react';
|
|
4
|
-
import { Text } from 'ink';
|
|
5
|
-
import { Watch } from '../components/watch.js';
|
|
6
|
-
import { BrainList } from '../components/brain-list.js';
|
|
7
|
-
import { BrainHistory } from '../components/brain-history.js';
|
|
8
|
-
import { BrainShow } from '../components/brain-show.js';
|
|
9
|
-
import { BrainRerun } from '../components/brain-rerun.js';
|
|
10
|
-
import { ErrorComponent } from '../components/error.js';
|
|
11
|
-
|
|
12
|
-
interface BrainListArgs {}
|
|
13
|
-
interface BrainHistoryArgs {
|
|
14
|
-
name: string;
|
|
15
|
-
limit: number;
|
|
16
|
-
}
|
|
17
|
-
interface BrainShowArgs {
|
|
18
|
-
name: string;
|
|
19
|
-
}
|
|
20
|
-
interface BrainRerunArgs {
|
|
21
|
-
name: string;
|
|
22
|
-
runId?: string;
|
|
23
|
-
startsAt?: number;
|
|
24
|
-
stopsAfter?: number;
|
|
25
|
-
}
|
|
26
|
-
interface BrainRunArgs {
|
|
27
|
-
name: string;
|
|
28
|
-
watch?: boolean;
|
|
29
|
-
}
|
|
30
|
-
interface BrainWatchArgs {
|
|
31
|
-
runId?: string;
|
|
32
|
-
name?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class BrainCommand {
|
|
36
|
-
list(argv: ArgumentsCamelCase<BrainListArgs>): React.ReactElement {
|
|
37
|
-
return React.createElement(BrainList);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
history({
|
|
41
|
-
name: brainName,
|
|
42
|
-
limit,
|
|
43
|
-
}: ArgumentsCamelCase<BrainHistoryArgs>): React.ReactElement {
|
|
44
|
-
return React.createElement(BrainHistory, { brainName, limit });
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
show({
|
|
48
|
-
name: brainName,
|
|
49
|
-
}: ArgumentsCamelCase<BrainShowArgs>): React.ReactElement {
|
|
50
|
-
return React.createElement(BrainShow, { brainName });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
rerun({
|
|
54
|
-
name: brainName,
|
|
55
|
-
runId,
|
|
56
|
-
startsAt,
|
|
57
|
-
stopsAfter,
|
|
58
|
-
}: ArgumentsCamelCase<BrainRerunArgs>): React.ReactElement {
|
|
59
|
-
return React.createElement(BrainRerun, {
|
|
60
|
-
brainName,
|
|
61
|
-
runId,
|
|
62
|
-
startsAt,
|
|
63
|
-
stopsAfter,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async run({ name: brainName, watch }: ArgumentsCamelCase<BrainRunArgs>): Promise<React.ReactElement> {
|
|
68
|
-
const apiPath = '/brains/runs';
|
|
69
|
-
try {
|
|
70
|
-
const response = await apiClient.fetch(apiPath, {
|
|
71
|
-
method: 'POST',
|
|
72
|
-
headers: {
|
|
73
|
-
'Content-Type': 'application/json',
|
|
74
|
-
},
|
|
75
|
-
body: JSON.stringify({ brainName }),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (response.status === 201) {
|
|
79
|
-
const result = (await response.json()) as { brainRunId: string };
|
|
80
|
-
|
|
81
|
-
if (watch) {
|
|
82
|
-
// Return Watch component for CLI to render
|
|
83
|
-
return this.watch({
|
|
84
|
-
runId: result.brainRunId,
|
|
85
|
-
_: [],
|
|
86
|
-
$0: '',
|
|
87
|
-
});
|
|
88
|
-
} else {
|
|
89
|
-
// Return React element displaying the run ID
|
|
90
|
-
return React.createElement(
|
|
91
|
-
Text,
|
|
92
|
-
null,
|
|
93
|
-
`Run ID: ${result.brainRunId}`
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
} else if (response.status === 404) {
|
|
97
|
-
// Handle brain not found with a helpful message
|
|
98
|
-
return React.createElement(ErrorComponent, {
|
|
99
|
-
error: {
|
|
100
|
-
title: 'Brain Not Found',
|
|
101
|
-
message: `Brain '${brainName}' not found.`,
|
|
102
|
-
details: 'Please check that:\n 1. The brain name is spelled correctly\n 2. The brain exists in your project\n 3. The brain has been properly defined and exported\n\nYou can list available brains with: positronic list'
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
} else {
|
|
106
|
-
const errorText = await response.text();
|
|
107
|
-
console.error(
|
|
108
|
-
`Error starting brain run: ${response.status} ${response.statusText}`
|
|
109
|
-
);
|
|
110
|
-
console.error(`Server response: ${errorText}`);
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
113
|
-
} catch (error: any) {
|
|
114
|
-
console.error(`Error connecting to the local development server.`);
|
|
115
|
-
console.error(
|
|
116
|
-
"Please ensure the server is running ('positronic server' or 'px s')."
|
|
117
|
-
);
|
|
118
|
-
if (error.code === 'ECONNREFUSED') {
|
|
119
|
-
console.error(
|
|
120
|
-
'Reason: Connection refused. The server might not be running or is listening on a different port.'
|
|
121
|
-
);
|
|
122
|
-
} else {
|
|
123
|
-
console.error(`Fetch error details: ${error.message}`);
|
|
124
|
-
}
|
|
125
|
-
process.exit(1);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async watch({
|
|
130
|
-
runId,
|
|
131
|
-
name: brainName,
|
|
132
|
-
}: ArgumentsCamelCase<BrainWatchArgs>): Promise<React.ReactElement> {
|
|
133
|
-
// If a specific run ID is provided, return the Watch component
|
|
134
|
-
if (runId) {
|
|
135
|
-
const port = process.env.POSITRONIC_PORT || '8787';
|
|
136
|
-
return React.createElement(Watch, { runId, port });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// If watching by brain name is requested, look up active runs
|
|
140
|
-
if (brainName) {
|
|
141
|
-
try {
|
|
142
|
-
const apiPath = `/brains/${encodeURIComponent(brainName)}/active-runs`;
|
|
143
|
-
const response = await apiClient.fetch(apiPath, {
|
|
144
|
-
method: 'GET',
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
if (response.status === 200) {
|
|
148
|
-
const result = await response.json() as { runs: Array<{ brainRunId: string; brainTitle: string; status: string; createdAt: number }> };
|
|
149
|
-
|
|
150
|
-
if (result.runs.length === 0) {
|
|
151
|
-
return React.createElement(
|
|
152
|
-
ErrorComponent,
|
|
153
|
-
{
|
|
154
|
-
error: {
|
|
155
|
-
title: 'No Active Runs',
|
|
156
|
-
message: `No currently running brain runs found for brain "${brainName}".`,
|
|
157
|
-
details: `To start a new run, use: positronic run ${brainName}`
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (result.runs.length > 1) {
|
|
164
|
-
return React.createElement(
|
|
165
|
-
ErrorComponent,
|
|
166
|
-
{
|
|
167
|
-
error: {
|
|
168
|
-
title: 'Multiple Active Runs',
|
|
169
|
-
message: `Found ${result.runs.length} active runs for brain "${brainName}".`,
|
|
170
|
-
details: `Please specify a specific run ID with --run-id:\n${result.runs.map(run => ` positronic watch --run-id ${run.brainRunId}`).join('\n')}`
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Exactly one active run found - watch it
|
|
177
|
-
const activeRun = result.runs[0];
|
|
178
|
-
const port = process.env.POSITRONIC_PORT || '8787';
|
|
179
|
-
return React.createElement(Watch, { runId: activeRun.brainRunId, port });
|
|
180
|
-
} else {
|
|
181
|
-
const errorText = await response.text();
|
|
182
|
-
return React.createElement(
|
|
183
|
-
ErrorComponent,
|
|
184
|
-
{
|
|
185
|
-
error: {
|
|
186
|
-
title: 'API Error',
|
|
187
|
-
message: `Failed to get active runs for brain "${brainName}".`,
|
|
188
|
-
details: `Server returned ${response.status}: ${errorText}`
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
} catch (error: any) {
|
|
194
|
-
return React.createElement(
|
|
195
|
-
ErrorComponent,
|
|
196
|
-
{
|
|
197
|
-
error: {
|
|
198
|
-
title: 'Connection Error',
|
|
199
|
-
message: 'Error connecting to the local development server.',
|
|
200
|
-
details: `Please ensure the server is running ('positronic server' or 'px s').\n\nError details: ${error.message}`
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Neither runId nor brainName provided – return an error element.
|
|
208
|
-
return React.createElement(
|
|
209
|
-
Text,
|
|
210
|
-
{ color: 'red' as any }, // Ink Text color prop
|
|
211
|
-
'Error: You must provide either a brain run ID or a brain name.'
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
}
|
|
@@ -1,487 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import * as os from 'os';
|
|
4
|
-
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
5
|
-
import { Response } from 'node-fetch';
|
|
6
|
-
import { syncResources, generateTypes } from './helpers.js';
|
|
7
|
-
import { createMockApiClient } from '../test/mock-api-client.js';
|
|
8
|
-
|
|
9
|
-
describe('Helper Functions Unit Tests', () => {
|
|
10
|
-
let tempDir: string;
|
|
11
|
-
let projectPath: string;
|
|
12
|
-
let mockClient: ReturnType<typeof createMockApiClient>;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'positronic-unit-test-'));
|
|
16
|
-
projectPath = path.join(tempDir, 'test-project');
|
|
17
|
-
fs.mkdirSync(projectPath, { recursive: true });
|
|
18
|
-
mockClient = createMockApiClient();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
-
mockClient.reset();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe('syncResources', () => {
|
|
27
|
-
it('should create resources directory if it does not exist', async () => {
|
|
28
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
29
|
-
expect(fs.existsSync(resourcesDir)).toBe(false);
|
|
30
|
-
|
|
31
|
-
await syncResources(projectPath, mockClient);
|
|
32
|
-
|
|
33
|
-
expect(fs.existsSync(resourcesDir)).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should return zero counts for empty resources directory', async () => {
|
|
37
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
38
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
39
|
-
|
|
40
|
-
const result = await syncResources(projectPath, mockClient);
|
|
41
|
-
|
|
42
|
-
expect(result).toEqual({
|
|
43
|
-
uploadCount: 0,
|
|
44
|
-
skipCount: 0,
|
|
45
|
-
errorCount: 0,
|
|
46
|
-
totalCount: 0,
|
|
47
|
-
deleteCount: 0,
|
|
48
|
-
errors: [],
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Should not make any API calls for empty directory
|
|
52
|
-
expect(mockClient.calls.length).toBe(1); // Only GET /resources call
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should upload new resources', async () => {
|
|
56
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
57
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
58
|
-
|
|
59
|
-
// Create test files
|
|
60
|
-
fs.writeFileSync(path.join(resourcesDir, 'test.txt'), 'Hello World');
|
|
61
|
-
fs.writeFileSync(
|
|
62
|
-
path.join(resourcesDir, 'data.json'),
|
|
63
|
-
'{"key": "value"}'
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
const result = await syncResources(projectPath, mockClient);
|
|
67
|
-
|
|
68
|
-
expect(result.uploadCount).toBe(2);
|
|
69
|
-
expect(result.skipCount).toBe(0);
|
|
70
|
-
expect(result.errorCount).toBe(0);
|
|
71
|
-
expect(result.totalCount).toBe(2);
|
|
72
|
-
|
|
73
|
-
// Verify API calls
|
|
74
|
-
expect(mockClient.calls).toHaveLength(3); // 1 GET + 2 POSTs
|
|
75
|
-
expect(mockClient.calls[0].path).toBe('/resources');
|
|
76
|
-
expect(mockClient.calls[1].path).toBe('/resources');
|
|
77
|
-
expect(mockClient.calls[1].options?.method).toBe('POST');
|
|
78
|
-
|
|
79
|
-
// Verify uploaded resources
|
|
80
|
-
const resources = mockClient.getResources();
|
|
81
|
-
expect(resources).toHaveLength(2);
|
|
82
|
-
expect(resources.find((r) => r.key === 'test.txt')).toBeDefined();
|
|
83
|
-
expect(resources.find((r) => r.key === 'data.json')).toBeDefined();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should skip unchanged resources', async () => {
|
|
87
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
88
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
89
|
-
|
|
90
|
-
// Add existing resource to mock with future lastModified to ensure it's newer than the file
|
|
91
|
-
mockClient.addResource({
|
|
92
|
-
key: 'existing.txt',
|
|
93
|
-
type: 'text',
|
|
94
|
-
size: 8,
|
|
95
|
-
lastModified: new Date(Date.now() + 10000).toISOString(), // 10 seconds in the future
|
|
96
|
-
local: false, // Default to false for existing tests
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// Create matching file
|
|
100
|
-
fs.writeFileSync(path.join(resourcesDir, 'existing.txt'), '12345678'); // 8 bytes
|
|
101
|
-
|
|
102
|
-
const result = await syncResources(projectPath, mockClient);
|
|
103
|
-
|
|
104
|
-
expect(result.uploadCount).toBe(0);
|
|
105
|
-
expect(result.skipCount).toBe(1);
|
|
106
|
-
expect(result.errorCount).toBe(0);
|
|
107
|
-
|
|
108
|
-
// Should only make GET call
|
|
109
|
-
expect(mockClient.calls).toHaveLength(1);
|
|
110
|
-
expect(mockClient.calls[0].path).toBe('/resources');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should upload modified resources based on size', async () => {
|
|
114
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
115
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
116
|
-
|
|
117
|
-
// Add existing resource to mock with different size
|
|
118
|
-
mockClient.addResource({
|
|
119
|
-
key: 'modified.txt',
|
|
120
|
-
type: 'text',
|
|
121
|
-
size: 5,
|
|
122
|
-
lastModified: new Date(Date.now() - 10000).toISOString(),
|
|
123
|
-
local: false, // Default to false for existing tests
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// Create file with different content
|
|
127
|
-
fs.writeFileSync(path.join(resourcesDir, 'modified.txt'), 'New content');
|
|
128
|
-
|
|
129
|
-
const result = await syncResources(projectPath, mockClient);
|
|
130
|
-
|
|
131
|
-
expect(result.uploadCount).toBe(1);
|
|
132
|
-
expect(result.skipCount).toBe(0);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('should upload resources based on modification time when size matches', async () => {
|
|
136
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
137
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
138
|
-
|
|
139
|
-
// Add existing resource to mock with same size but older timestamp
|
|
140
|
-
mockClient.addResource({
|
|
141
|
-
key: 'updated.txt',
|
|
142
|
-
type: 'text',
|
|
143
|
-
size: 11, // Same size as "Same content"
|
|
144
|
-
lastModified: new Date(Date.now() - 10000).toISOString(), // 10 seconds ago
|
|
145
|
-
local: false, // Default to false for existing tests
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Create file with same size but it will have a newer modification time
|
|
149
|
-
fs.writeFileSync(path.join(resourcesDir, 'updated.txt'), 'Same content'); // 11 bytes
|
|
150
|
-
|
|
151
|
-
const result = await syncResources(projectPath, mockClient);
|
|
152
|
-
|
|
153
|
-
expect(result.uploadCount).toBe(1);
|
|
154
|
-
expect(result.skipCount).toBe(0);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('should handle nested directories', async () => {
|
|
158
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
159
|
-
const docsDir = path.join(resourcesDir, 'docs');
|
|
160
|
-
fs.mkdirSync(docsDir, { recursive: true });
|
|
161
|
-
|
|
162
|
-
fs.writeFileSync(path.join(docsDir, 'readme.md'), '# README');
|
|
163
|
-
|
|
164
|
-
const result = await syncResources(projectPath, mockClient);
|
|
165
|
-
|
|
166
|
-
expect(result.uploadCount).toBe(1);
|
|
167
|
-
|
|
168
|
-
const resources = mockClient.getResources();
|
|
169
|
-
expect(resources[0].key).toBe('docs/readme.md');
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('should handle upload errors gracefully', async () => {
|
|
173
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
174
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
175
|
-
|
|
176
|
-
// Create a file that will trigger an error in our mock
|
|
177
|
-
// We'll modify the mock to fail on specific file names
|
|
178
|
-
const badClient = createMockApiClient();
|
|
179
|
-
const originalFetch = badClient.fetch;
|
|
180
|
-
badClient.fetch = async (path, options) => {
|
|
181
|
-
if (
|
|
182
|
-
options?.method === 'POST' &&
|
|
183
|
-
options.body instanceof FormData &&
|
|
184
|
-
options.body.get('key') === 'error.txt'
|
|
185
|
-
) {
|
|
186
|
-
throw new Error('Upload failed');
|
|
187
|
-
}
|
|
188
|
-
return originalFetch.call(badClient, path, options);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
fs.writeFileSync(path.join(resourcesDir, 'error.txt'), 'This will fail');
|
|
192
|
-
fs.writeFileSync(
|
|
193
|
-
path.join(resourcesDir, 'good.txt'),
|
|
194
|
-
'This will succeed'
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
const result = await syncResources(projectPath, badClient);
|
|
198
|
-
|
|
199
|
-
expect(result.uploadCount).toBe(1);
|
|
200
|
-
expect(result.errorCount).toBe(1);
|
|
201
|
-
expect(result.errors).toHaveLength(1);
|
|
202
|
-
expect(result.errors[0]).toEqual({
|
|
203
|
-
file: 'error.txt',
|
|
204
|
-
message: 'Upload failed',
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('should delete server resources with local=true when files are removed', async () => {
|
|
209
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
210
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
211
|
-
|
|
212
|
-
// Create the file that should still exist first
|
|
213
|
-
fs.writeFileSync(
|
|
214
|
-
path.join(resourcesDir, 'still-exists.txt'),
|
|
215
|
-
'I still exist'
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
// Add existing resources to mock - some synced (local=true), some manual (local=false)
|
|
219
|
-
mockClient.addResource({
|
|
220
|
-
key: 'deleted-file.txt',
|
|
221
|
-
type: 'text',
|
|
222
|
-
size: 100,
|
|
223
|
-
lastModified: new Date().toISOString(),
|
|
224
|
-
local: true, // This one should be deleted
|
|
225
|
-
});
|
|
226
|
-
mockClient.addResource({
|
|
227
|
-
key: 'still-exists.txt',
|
|
228
|
-
type: 'text',
|
|
229
|
-
size: 13, // Exact size of "I still exist"
|
|
230
|
-
lastModified: new Date(Date.now() + 10000).toISOString(), // Future timestamp to prevent re-upload
|
|
231
|
-
local: true, // This one should NOT be deleted
|
|
232
|
-
});
|
|
233
|
-
mockClient.addResource({
|
|
234
|
-
key: 'docs/removed-doc.md',
|
|
235
|
-
type: 'text',
|
|
236
|
-
size: 200,
|
|
237
|
-
lastModified: new Date().toISOString(),
|
|
238
|
-
local: true, // This one should be deleted
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const result = await syncResources(projectPath, mockClient);
|
|
242
|
-
|
|
243
|
-
expect(result.deleteCount).toBe(2);
|
|
244
|
-
expect(result.uploadCount).toBe(0); // No new uploads
|
|
245
|
-
expect(result.skipCount).toBe(1); // still-exists.txt is up to date
|
|
246
|
-
|
|
247
|
-
// Verify the correct resources were deleted
|
|
248
|
-
const remainingResources = mockClient.getResources();
|
|
249
|
-
expect(remainingResources).toHaveLength(1);
|
|
250
|
-
expect(remainingResources[0].key).toBe('still-exists.txt');
|
|
251
|
-
|
|
252
|
-
// Verify DELETE API calls were made
|
|
253
|
-
const deleteCalls = mockClient.calls.filter(
|
|
254
|
-
(call) => call.options?.method === 'DELETE'
|
|
255
|
-
);
|
|
256
|
-
expect(deleteCalls).toHaveLength(2);
|
|
257
|
-
expect(deleteCalls[0].path).toBe('/resources/deleted-file.txt');
|
|
258
|
-
expect(deleteCalls[1].path).toBe('/resources/docs%2Fremoved-doc.md'); // URL encoded
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('should preserve manually uploaded resources (local=false)', async () => {
|
|
262
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
263
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
264
|
-
|
|
265
|
-
// Add existing resources to mock
|
|
266
|
-
mockClient.addResource({
|
|
267
|
-
key: 'manual-upload.txt',
|
|
268
|
-
type: 'text',
|
|
269
|
-
size: 100,
|
|
270
|
-
lastModified: new Date().toISOString(),
|
|
271
|
-
local: false, // Manually uploaded - should NOT be deleted
|
|
272
|
-
});
|
|
273
|
-
mockClient.addResource({
|
|
274
|
-
key: 'another-manual.png',
|
|
275
|
-
type: 'binary',
|
|
276
|
-
size: 5000,
|
|
277
|
-
lastModified: new Date().toISOString(),
|
|
278
|
-
local: false, // Manually uploaded - should NOT be deleted
|
|
279
|
-
});
|
|
280
|
-
mockClient.addResource({
|
|
281
|
-
key: 'synced-file.txt',
|
|
282
|
-
type: 'text',
|
|
283
|
-
size: 75,
|
|
284
|
-
lastModified: new Date().toISOString(),
|
|
285
|
-
local: true, // Synced file that was deleted locally
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// Don't create any local files - all are "deleted" locally
|
|
289
|
-
|
|
290
|
-
const result = await syncResources(projectPath, mockClient);
|
|
291
|
-
|
|
292
|
-
expect(result.deleteCount).toBe(1); // Only the synced file
|
|
293
|
-
expect(result.uploadCount).toBe(0);
|
|
294
|
-
expect(result.skipCount).toBe(0);
|
|
295
|
-
|
|
296
|
-
// Verify manual uploads are still there
|
|
297
|
-
const remainingResources = mockClient.getResources();
|
|
298
|
-
expect(remainingResources).toHaveLength(2);
|
|
299
|
-
expect(
|
|
300
|
-
remainingResources.find((r) => r.key === 'manual-upload.txt')
|
|
301
|
-
).toBeDefined();
|
|
302
|
-
expect(
|
|
303
|
-
remainingResources.find((r) => r.key === 'another-manual.png')
|
|
304
|
-
).toBeDefined();
|
|
305
|
-
expect(
|
|
306
|
-
remainingResources.find((r) => r.key === 'synced-file.txt')
|
|
307
|
-
).toBeUndefined();
|
|
308
|
-
|
|
309
|
-
// Verify only one DELETE call was made
|
|
310
|
-
const deleteCalls = mockClient.calls.filter(
|
|
311
|
-
(call) => call.options?.method === 'DELETE'
|
|
312
|
-
);
|
|
313
|
-
expect(deleteCalls).toHaveLength(1);
|
|
314
|
-
expect(deleteCalls[0].path).toBe('/resources/synced-file.txt');
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('should handle delete errors gracefully', async () => {
|
|
318
|
-
const resourcesDir = path.join(projectPath, 'resources');
|
|
319
|
-
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
320
|
-
|
|
321
|
-
// Create a client that fails on DELETE requests
|
|
322
|
-
const badClient = createMockApiClient();
|
|
323
|
-
badClient.addResource({
|
|
324
|
-
key: 'will-fail-delete.txt',
|
|
325
|
-
type: 'text',
|
|
326
|
-
size: 100,
|
|
327
|
-
lastModified: new Date().toISOString(),
|
|
328
|
-
local: true,
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
const originalFetch = badClient.fetch;
|
|
332
|
-
badClient.fetch = (async (path: string, options?: RequestInit) => {
|
|
333
|
-
if (options?.method === 'DELETE') {
|
|
334
|
-
return new Response('Internal Server Error', { status: 500 });
|
|
335
|
-
}
|
|
336
|
-
return originalFetch.call(badClient, path, options);
|
|
337
|
-
}) as typeof badClient.fetch;
|
|
338
|
-
|
|
339
|
-
// Don't create the file locally so it should be deleted
|
|
340
|
-
|
|
341
|
-
const result = await syncResources(projectPath, badClient);
|
|
342
|
-
|
|
343
|
-
expect(result.deleteCount).toBe(0); // Failed to delete
|
|
344
|
-
expect(result.errorCount).toBe(1);
|
|
345
|
-
expect(result.errors).toHaveLength(1);
|
|
346
|
-
expect(result.errors[0]).toEqual({
|
|
347
|
-
file: 'will-fail-delete.txt',
|
|
348
|
-
message: 'Failed to delete: Delete failed: 500 Internal Server Error',
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
describe('generateTypes', () => {
|
|
354
|
-
it('should generate types file for empty resources', async () => {
|
|
355
|
-
const result = await generateTypes(projectPath, mockClient);
|
|
356
|
-
|
|
357
|
-
const typesPath = path.join(projectPath, 'resources.d.ts');
|
|
358
|
-
expect(fs.existsSync(typesPath)).toBe(true);
|
|
359
|
-
|
|
360
|
-
const content = fs.readFileSync(typesPath, 'utf-8');
|
|
361
|
-
expect(content).toContain("declare module '@positronic/core'");
|
|
362
|
-
expect(content).toContain('interface Resources');
|
|
363
|
-
expect(content).toContain('loadText(path: string): Promise<string>');
|
|
364
|
-
expect(content).toContain('loadBinary(path: string): Promise<Buffer>');
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it('should generate types for text and binary resources', async () => {
|
|
368
|
-
// Add mock resources
|
|
369
|
-
mockClient.addResource({
|
|
370
|
-
key: 'readme.md',
|
|
371
|
-
type: 'text',
|
|
372
|
-
size: 100,
|
|
373
|
-
lastModified: new Date().toISOString(),
|
|
374
|
-
local: false, // Default to false for existing tests
|
|
375
|
-
});
|
|
376
|
-
mockClient.addResource({
|
|
377
|
-
key: 'logo.png',
|
|
378
|
-
type: 'binary',
|
|
379
|
-
size: 1000,
|
|
380
|
-
lastModified: new Date().toISOString(),
|
|
381
|
-
local: false, // Default to false for existing tests
|
|
382
|
-
});
|
|
383
|
-
mockClient.addResource({
|
|
384
|
-
key: 'data.json',
|
|
385
|
-
type: 'text',
|
|
386
|
-
size: 50,
|
|
387
|
-
lastModified: new Date().toISOString(),
|
|
388
|
-
local: false, // Default to false for existing tests
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
await generateTypes(projectPath, mockClient);
|
|
392
|
-
|
|
393
|
-
const typesPath = path.join(projectPath, 'resources.d.ts');
|
|
394
|
-
const content = fs.readFileSync(typesPath, 'utf-8');
|
|
395
|
-
|
|
396
|
-
// Check resource declarations
|
|
397
|
-
expect(content).toContain('readme: TextResource;');
|
|
398
|
-
expect(content).toContain('logo: BinaryResource;');
|
|
399
|
-
expect(content).toContain('data: TextResource;');
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('should handle nested resources', async () => {
|
|
403
|
-
mockClient.addResource({
|
|
404
|
-
key: 'docs/api.md',
|
|
405
|
-
type: 'text',
|
|
406
|
-
size: 100,
|
|
407
|
-
lastModified: new Date().toISOString(),
|
|
408
|
-
local: false, // Default to false for existing tests
|
|
409
|
-
});
|
|
410
|
-
mockClient.addResource({
|
|
411
|
-
key: 'docs/images/diagram.png',
|
|
412
|
-
type: 'binary',
|
|
413
|
-
size: 500,
|
|
414
|
-
lastModified: new Date().toISOString(),
|
|
415
|
-
local: false, // Default to false for existing tests
|
|
416
|
-
});
|
|
417
|
-
mockClient.addResource({
|
|
418
|
-
key: 'config/settings.json',
|
|
419
|
-
type: 'text',
|
|
420
|
-
size: 200,
|
|
421
|
-
lastModified: new Date().toISOString(),
|
|
422
|
-
local: false, // Default to false for existing tests
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
await generateTypes(projectPath, mockClient);
|
|
426
|
-
|
|
427
|
-
const typesPath = path.join(projectPath, 'resources.d.ts');
|
|
428
|
-
const content = fs.readFileSync(typesPath, 'utf-8');
|
|
429
|
-
|
|
430
|
-
// Check nested structure
|
|
431
|
-
expect(content).toContain('docs: {');
|
|
432
|
-
expect(content).toContain('api: TextResource;');
|
|
433
|
-
expect(content).toContain('images: {');
|
|
434
|
-
expect(content).toContain('diagram: BinaryResource;');
|
|
435
|
-
expect(content).toContain('config: {');
|
|
436
|
-
expect(content).toContain('settings: TextResource;');
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('should exclude invalid JavaScript identifiers', async () => {
|
|
440
|
-
mockClient.addResource({
|
|
441
|
-
key: '123invalid.txt',
|
|
442
|
-
type: 'text',
|
|
443
|
-
size: 100,
|
|
444
|
-
lastModified: new Date().toISOString(),
|
|
445
|
-
local: false, // Default to false for existing tests
|
|
446
|
-
});
|
|
447
|
-
mockClient.addResource({
|
|
448
|
-
key: 'file-with-dash.txt',
|
|
449
|
-
type: 'text',
|
|
450
|
-
size: 100,
|
|
451
|
-
lastModified: new Date().toISOString(),
|
|
452
|
-
local: false, // Default to false for existing tests
|
|
453
|
-
});
|
|
454
|
-
mockClient.addResource({
|
|
455
|
-
key: 'valid_file.txt',
|
|
456
|
-
type: 'text',
|
|
457
|
-
size: 100,
|
|
458
|
-
lastModified: new Date().toISOString(),
|
|
459
|
-
local: false, // Default to false for existing tests
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
await generateTypes(projectPath, mockClient);
|
|
463
|
-
|
|
464
|
-
const typesPath = path.join(projectPath, 'resources.d.ts');
|
|
465
|
-
const content = fs.readFileSync(typesPath, 'utf-8');
|
|
466
|
-
|
|
467
|
-
// Should include valid identifier
|
|
468
|
-
expect(content).toContain('valid_file: TextResource;');
|
|
469
|
-
|
|
470
|
-
// Should not include invalid identifiers
|
|
471
|
-
expect(content).not.toContain('123invalid');
|
|
472
|
-
expect(content).not.toContain('file-with-dash');
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it('should handle API errors gracefully', async () => {
|
|
476
|
-
// Create a client that returns an error
|
|
477
|
-
const errorClient = createMockApiClient();
|
|
478
|
-
errorClient.fetch = (async (path: string, options?: RequestInit) => {
|
|
479
|
-
return new Response('Internal Server Error', { status: 500 });
|
|
480
|
-
}) as any;
|
|
481
|
-
|
|
482
|
-
await expect(generateTypes(projectPath, errorClient)).rejects.toThrow(
|
|
483
|
-
'Failed to fetch resources: 500 Internal Server Error'
|
|
484
|
-
);
|
|
485
|
-
});
|
|
486
|
-
});
|
|
487
|
-
});
|