raycast-rsync-extension 1.0.1
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/.eslintrc.js +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/dependabot.yml +35 -0
- package/.github/workflows/ci.yml +105 -0
- package/.github/workflows/publish.yml +269 -0
- package/.github/workflows/update-copyright-year.yml +70 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/assets/icon.png +0 -0
- package/eslint.config.js +23 -0
- package/metadata/browse-remote-path.png +0 -0
- package/metadata/browse-remote.png +0 -0
- package/metadata/download-local-path.png +0 -0
- package/metadata/download-remote-path.png +0 -0
- package/metadata/extension.png +0 -0
- package/metadata/upload-local-path.png +0 -0
- package/metadata/upload-remote-path.png +0 -0
- package/metadata/upload-search-host.png +0 -0
- package/package.json +93 -0
- package/src/__mocks__/raycast-api.ts +84 -0
- package/src/browse.tsx +378 -0
- package/src/components/FileList.test.tsx +73 -0
- package/src/components/FileList.tsx +61 -0
- package/src/download.tsx +353 -0
- package/src/e2e/browse.e2e.test.ts +295 -0
- package/src/e2e/download.e2e.test.ts +193 -0
- package/src/e2e/error-handling.e2e.test.ts +292 -0
- package/src/e2e/rsync-options.e2e.test.ts +348 -0
- package/src/e2e/upload.e2e.test.ts +207 -0
- package/src/index.tsx +21 -0
- package/src/test-setup.ts +1 -0
- package/src/types/server.ts +60 -0
- package/src/upload.tsx +404 -0
- package/src/utils/__tests__/sshConfig.test.ts +352 -0
- package/src/utils/__tests__/validation.test.ts +139 -0
- package/src/utils/preferences.ts +24 -0
- package/src/utils/rsync.test.ts +490 -0
- package/src/utils/rsync.ts +517 -0
- package/src/utils/shellEscape.test.ts +98 -0
- package/src/utils/shellEscape.ts +36 -0
- package/src/utils/ssh.test.ts +209 -0
- package/src/utils/ssh.ts +187 -0
- package/src/utils/sshConfig.test.ts +191 -0
- package/src/utils/sshConfig.ts +212 -0
- package/src/utils/validation.test.ts +224 -0
- package/src/utils/validation.ts +115 -0
- package/tsconfig.json +27 -0
- package/vitest.config.ts +8 -0
package/src/download.tsx
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {
|
|
2
|
+
List,
|
|
3
|
+
ActionPanel,
|
|
4
|
+
Action,
|
|
5
|
+
Form,
|
|
6
|
+
showToast,
|
|
7
|
+
Toast,
|
|
8
|
+
popToRoot,
|
|
9
|
+
} from "@raycast/api";
|
|
10
|
+
import React, { useState, useEffect } from "react";
|
|
11
|
+
import { parseSSHConfig } from "./utils/sshConfig";
|
|
12
|
+
import { executeRsync } from "./utils/rsync";
|
|
13
|
+
import { validateRemotePath, validateHostConfig } from "./utils/validation";
|
|
14
|
+
import {
|
|
15
|
+
SSHHostConfig,
|
|
16
|
+
TransferDirection,
|
|
17
|
+
TransferOptions,
|
|
18
|
+
RsyncOptions,
|
|
19
|
+
} from "./types/server";
|
|
20
|
+
import { getRsyncPreferences } from "./utils/preferences";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Main download command component
|
|
24
|
+
* Displays list of SSH hosts from config file
|
|
25
|
+
*/
|
|
26
|
+
export default function Command() {
|
|
27
|
+
const [hosts, setHosts] = useState<SSHHostConfig[]>([]);
|
|
28
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
loadHosts();
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
async function loadHosts() {
|
|
36
|
+
try {
|
|
37
|
+
const parsedHosts = parseSSHConfig();
|
|
38
|
+
|
|
39
|
+
if (parsedHosts.length === 0) {
|
|
40
|
+
const errorMsg = "No host entries found in SSH config file";
|
|
41
|
+
setError(errorMsg);
|
|
42
|
+
console.warn("SSH config parsed but no hosts found");
|
|
43
|
+
} else {
|
|
44
|
+
setHosts(parsedHosts);
|
|
45
|
+
console.log(`Loaded ${parsedHosts.length} SSH host(s)`);
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const errorMessage =
|
|
49
|
+
err instanceof Error ? err.message : "Failed to parse SSH config";
|
|
50
|
+
console.error("Error loading SSH hosts:", err);
|
|
51
|
+
setError(errorMessage);
|
|
52
|
+
await showToast({
|
|
53
|
+
style: Toast.Style.Failure,
|
|
54
|
+
title: "Error Loading SSH Config",
|
|
55
|
+
message: errorMessage,
|
|
56
|
+
});
|
|
57
|
+
} finally {
|
|
58
|
+
setIsLoading(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (error) {
|
|
63
|
+
return (
|
|
64
|
+
<List>
|
|
65
|
+
<List.EmptyView title="Error Loading SSH Config" description={error} />
|
|
66
|
+
</List>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<List isLoading={isLoading} searchBarPlaceholder="Search hosts...">
|
|
72
|
+
{hosts.map((host: SSHHostConfig) => (
|
|
73
|
+
<List.Item
|
|
74
|
+
key={host.host}
|
|
75
|
+
title={host.host}
|
|
76
|
+
subtitle={host.hostName}
|
|
77
|
+
accessories={[
|
|
78
|
+
{ text: host.user ? `User: ${host.user}` : "" },
|
|
79
|
+
{ text: host.port ? `Port: ${host.port}` : "" },
|
|
80
|
+
]}
|
|
81
|
+
actions={
|
|
82
|
+
<ActionPanel>
|
|
83
|
+
<Action.Push
|
|
84
|
+
title="Enter Remote Path"
|
|
85
|
+
target={<RemotePathForm hostConfig={host} />}
|
|
86
|
+
/>
|
|
87
|
+
</ActionPanel>
|
|
88
|
+
}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</List>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remote path input form
|
|
97
|
+
* Allows user to specify source path on remote server
|
|
98
|
+
*/
|
|
99
|
+
function RemotePathForm({ hostConfig }: { hostConfig: SSHHostConfig }) {
|
|
100
|
+
const [remotePath, setRemotePath] = useState<string>("");
|
|
101
|
+
const [remotePathError, setRemotePathError] = useState<string | undefined>();
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Form
|
|
105
|
+
actions={
|
|
106
|
+
<ActionPanel>
|
|
107
|
+
<Action.Push
|
|
108
|
+
title="Continue"
|
|
109
|
+
target={
|
|
110
|
+
<LocalPathForm hostConfig={hostConfig} remotePath={remotePath} />
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
</ActionPanel>
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
<Form.TextField
|
|
117
|
+
id="remotePath"
|
|
118
|
+
title="Remote Path"
|
|
119
|
+
placeholder="/path/to/remote/file"
|
|
120
|
+
value={remotePath}
|
|
121
|
+
onChange={(value: string) => {
|
|
122
|
+
setRemotePath(value);
|
|
123
|
+
setRemotePathError(undefined);
|
|
124
|
+
}}
|
|
125
|
+
error={remotePathError}
|
|
126
|
+
info="Enter the path to the file or directory on the remote server"
|
|
127
|
+
/>
|
|
128
|
+
<Form.Description
|
|
129
|
+
title="Host Details"
|
|
130
|
+
text={`Downloading from: ${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
|
|
131
|
+
/>
|
|
132
|
+
{hostConfig.user && (
|
|
133
|
+
<Form.Description title="User" text={hostConfig.user} />
|
|
134
|
+
)}
|
|
135
|
+
{hostConfig.port && (
|
|
136
|
+
<Form.Description title="Port" text={hostConfig.port.toString()} />
|
|
137
|
+
)}
|
|
138
|
+
{hostConfig.identityFile && (
|
|
139
|
+
<Form.Description
|
|
140
|
+
title="Identity File"
|
|
141
|
+
text={hostConfig.identityFile}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
</Form>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Local destination path form
|
|
150
|
+
* Allows user to specify destination directory on local system
|
|
151
|
+
*/
|
|
152
|
+
function LocalPathForm({
|
|
153
|
+
hostConfig,
|
|
154
|
+
remotePath,
|
|
155
|
+
}: {
|
|
156
|
+
hostConfig: SSHHostConfig;
|
|
157
|
+
remotePath: string;
|
|
158
|
+
}) {
|
|
159
|
+
const [localPath, setLocalPath] = useState<string>("");
|
|
160
|
+
const [localPathError, setLocalPathError] = useState<string | undefined>();
|
|
161
|
+
|
|
162
|
+
// Initialize rsync options with global preferences
|
|
163
|
+
const defaultRsyncOptions = getRsyncPreferences();
|
|
164
|
+
const [humanReadable, setHumanReadable] = useState<boolean>(
|
|
165
|
+
defaultRsyncOptions.humanReadable ?? false,
|
|
166
|
+
);
|
|
167
|
+
const [progress, setProgress] = useState<boolean>(
|
|
168
|
+
defaultRsyncOptions.progress ?? false,
|
|
169
|
+
);
|
|
170
|
+
const [deleteExtra, setDeleteExtra] = useState<boolean>(
|
|
171
|
+
defaultRsyncOptions.delete ?? false,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
async function handleSubmit(values: {
|
|
175
|
+
localPath: string;
|
|
176
|
+
humanReadable: boolean;
|
|
177
|
+
progress: boolean;
|
|
178
|
+
deleteExtra: boolean;
|
|
179
|
+
}) {
|
|
180
|
+
const localPathValue = values.localPath.trim();
|
|
181
|
+
|
|
182
|
+
if (!localPathValue) {
|
|
183
|
+
console.error("Local path is empty");
|
|
184
|
+
setLocalPathError("Local path is required");
|
|
185
|
+
await showToast({
|
|
186
|
+
style: Toast.Style.Failure,
|
|
187
|
+
title: "Invalid Local Path",
|
|
188
|
+
message: "Please enter a destination path for the downloaded files",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Validate remote path
|
|
194
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
195
|
+
if (!remoteValidation.valid) {
|
|
196
|
+
console.error("Remote path validation failed:", remoteValidation.error);
|
|
197
|
+
await showToast({
|
|
198
|
+
style: Toast.Style.Failure,
|
|
199
|
+
title: "Invalid Remote Path",
|
|
200
|
+
message: remoteValidation.error || "The remote path format is invalid",
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Validate host config
|
|
206
|
+
const hostValidation = validateHostConfig(hostConfig);
|
|
207
|
+
if (!hostValidation.valid) {
|
|
208
|
+
console.error("Host config validation failed:", hostValidation.error);
|
|
209
|
+
await showToast({
|
|
210
|
+
style: Toast.Style.Failure,
|
|
211
|
+
title: "Invalid Host Configuration",
|
|
212
|
+
message:
|
|
213
|
+
hostValidation.error ||
|
|
214
|
+
"The host configuration is incomplete or invalid",
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Execute transfer using form values
|
|
220
|
+
await executeTransfer(hostConfig, remotePath, localPathValue, {
|
|
221
|
+
humanReadable: values.humanReadable,
|
|
222
|
+
progress: values.progress,
|
|
223
|
+
delete: values.deleteExtra,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function executeTransfer(
|
|
228
|
+
hostConfig: SSHHostConfig,
|
|
229
|
+
remotePath: string,
|
|
230
|
+
localPath: string,
|
|
231
|
+
rsyncOptions: RsyncOptions,
|
|
232
|
+
) {
|
|
233
|
+
// Show initial progress toast
|
|
234
|
+
await showToast({
|
|
235
|
+
style: Toast.Style.Animated,
|
|
236
|
+
title: "Transferring files...",
|
|
237
|
+
message: `Downloading from ${hostConfig.host}`,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
console.log("Starting download:", {
|
|
241
|
+
host: hostConfig.host,
|
|
242
|
+
remotePath,
|
|
243
|
+
localPath,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const options: TransferOptions = {
|
|
248
|
+
hostConfig,
|
|
249
|
+
localPath,
|
|
250
|
+
remotePath,
|
|
251
|
+
direction: TransferDirection.DOWNLOAD,
|
|
252
|
+
rsyncOptions,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Progress callback to update toast in real-time
|
|
256
|
+
const progressCallback = async (progressMessage: string) => {
|
|
257
|
+
await showToast({
|
|
258
|
+
style: Toast.Style.Animated,
|
|
259
|
+
title: "Transferring files...",
|
|
260
|
+
message: progressMessage,
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const result = await executeRsync(options, progressCallback);
|
|
265
|
+
|
|
266
|
+
if (result.success) {
|
|
267
|
+
console.log("Download completed successfully");
|
|
268
|
+
// Show formatted rsync output message (includes file sizes and progress if flags enabled)
|
|
269
|
+
await showToast({
|
|
270
|
+
style: Toast.Style.Success,
|
|
271
|
+
title: "Download Successful",
|
|
272
|
+
message: result.message,
|
|
273
|
+
});
|
|
274
|
+
// Log full output for debugging
|
|
275
|
+
if (result.stdout) {
|
|
276
|
+
console.log("Rsync output:", result.stdout);
|
|
277
|
+
}
|
|
278
|
+
// Close the extension after successful download
|
|
279
|
+
await popToRoot();
|
|
280
|
+
} else {
|
|
281
|
+
console.error("Download failed:", result.message);
|
|
282
|
+
await showToast({
|
|
283
|
+
style: Toast.Style.Failure,
|
|
284
|
+
title: "Download Failed",
|
|
285
|
+
message: result.message,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const errorMessage =
|
|
290
|
+
err instanceof Error ? err.message : "Unknown error occurred";
|
|
291
|
+
console.error("Download error:", err);
|
|
292
|
+
await showToast({
|
|
293
|
+
style: Toast.Style.Failure,
|
|
294
|
+
title: "Download Failed",
|
|
295
|
+
message: errorMessage,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<Form
|
|
302
|
+
actions={
|
|
303
|
+
<ActionPanel>
|
|
304
|
+
<Action.SubmitForm title="Download" onSubmit={handleSubmit} />
|
|
305
|
+
</ActionPanel>
|
|
306
|
+
}
|
|
307
|
+
>
|
|
308
|
+
<Form.TextField
|
|
309
|
+
id="localPath"
|
|
310
|
+
title="Local Destination Path"
|
|
311
|
+
placeholder="/path/to/local/destination"
|
|
312
|
+
value={localPath}
|
|
313
|
+
onChange={(value: string) => {
|
|
314
|
+
setLocalPath(value);
|
|
315
|
+
setLocalPathError(undefined);
|
|
316
|
+
}}
|
|
317
|
+
error={localPathError}
|
|
318
|
+
info="Enter the destination directory on your local system"
|
|
319
|
+
/>
|
|
320
|
+
<Form.Description title="Remote Path" text={remotePath} />
|
|
321
|
+
<Form.Description
|
|
322
|
+
title="Host"
|
|
323
|
+
text={`${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
|
|
324
|
+
/>
|
|
325
|
+
<Form.Separator />
|
|
326
|
+
<Form.Description
|
|
327
|
+
title="Rsync Options"
|
|
328
|
+
text="Configure options for this transfer"
|
|
329
|
+
/>
|
|
330
|
+
<Form.Checkbox
|
|
331
|
+
id="humanReadable"
|
|
332
|
+
label="Human-readable file sizes (-h)"
|
|
333
|
+
value={humanReadable}
|
|
334
|
+
onChange={setHumanReadable}
|
|
335
|
+
info="Display file sizes in human-readable format (e.g., 1.5M, 500K)"
|
|
336
|
+
/>
|
|
337
|
+
<Form.Checkbox
|
|
338
|
+
id="progress"
|
|
339
|
+
label="Show progress (-P)"
|
|
340
|
+
value={progress}
|
|
341
|
+
onChange={setProgress}
|
|
342
|
+
info="Display progress information and support partial transfers"
|
|
343
|
+
/>
|
|
344
|
+
<Form.Checkbox
|
|
345
|
+
id="deleteExtra"
|
|
346
|
+
label="Delete extraneous files (--delete)"
|
|
347
|
+
value={deleteExtra}
|
|
348
|
+
onChange={setDeleteExtra}
|
|
349
|
+
info="Delete files in destination that don't exist in source (use with caution)"
|
|
350
|
+
/>
|
|
351
|
+
</Form>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateRemotePath, validateHostConfig } from "../utils/validation";
|
|
3
|
+
import { SSHHostConfig } from "../types/server";
|
|
4
|
+
|
|
5
|
+
describe("Browse E2E Flow", () => {
|
|
6
|
+
const mockHostConfig: SSHHostConfig = {
|
|
7
|
+
host: "browseserver",
|
|
8
|
+
hostName: "browse.example.com",
|
|
9
|
+
user: "browseuser",
|
|
10
|
+
port: 22,
|
|
11
|
+
identityFile: "~/.ssh/browse_key",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
it("should complete full browse workflow with valid inputs", () => {
|
|
15
|
+
// Step 1: Validate remote path (home directory)
|
|
16
|
+
const remotePath = "~";
|
|
17
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
18
|
+
expect(remoteValidation.valid).toBe(true);
|
|
19
|
+
expect(remoteValidation.error).toBeUndefined();
|
|
20
|
+
|
|
21
|
+
// Step 2: Validate host config
|
|
22
|
+
const hostValidation = validateHostConfig(mockHostConfig);
|
|
23
|
+
expect(hostValidation.valid).toBe(true);
|
|
24
|
+
expect(hostValidation.error).toBeUndefined();
|
|
25
|
+
|
|
26
|
+
// Step 3: Verify path construction for navigation
|
|
27
|
+
const subdirectory = "documents";
|
|
28
|
+
const constructedPath = remotePath.endsWith("/")
|
|
29
|
+
? `${remotePath}${subdirectory}`
|
|
30
|
+
: `${remotePath}/${subdirectory}`;
|
|
31
|
+
expect(constructedPath).toBe("~/documents");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should handle absolute remote path", () => {
|
|
35
|
+
const remotePath = "/home/user/documents";
|
|
36
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
37
|
+
expect(remoteValidation.valid).toBe(true);
|
|
38
|
+
expect(remoteValidation.error).toBeUndefined();
|
|
39
|
+
|
|
40
|
+
const hostValidation = validateHostConfig(mockHostConfig);
|
|
41
|
+
expect(hostValidation.valid).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should handle relative remote path", () => {
|
|
45
|
+
const remotePath = "documents/files";
|
|
46
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
47
|
+
expect(remoteValidation.valid).toBe(true);
|
|
48
|
+
expect(remoteValidation.error).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should handle empty remote path (defaults to ~)", () => {
|
|
52
|
+
// In the browse form, empty path defaults to "~"
|
|
53
|
+
const remotePath = "";
|
|
54
|
+
const remotePathValue = remotePath.trim() || "~";
|
|
55
|
+
expect(remotePathValue).toBe("~");
|
|
56
|
+
|
|
57
|
+
const remoteValidation = validateRemotePath(remotePathValue);
|
|
58
|
+
expect(remoteValidation.valid).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should construct paths correctly for directory navigation", () => {
|
|
62
|
+
const testCases = [
|
|
63
|
+
{ current: "~", subdir: "documents", expected: "~/documents" },
|
|
64
|
+
{
|
|
65
|
+
current: "/home/user",
|
|
66
|
+
subdir: "documents",
|
|
67
|
+
expected: "/home/user/documents",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
current: "/home/user/",
|
|
71
|
+
subdir: "documents",
|
|
72
|
+
expected: "/home/user/documents",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
current: "~/projects",
|
|
76
|
+
subdir: "my-project",
|
|
77
|
+
expected: "~/projects/my-project",
|
|
78
|
+
},
|
|
79
|
+
{ current: "/var/www", subdir: "html", expected: "/var/www/html" },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
testCases.forEach(({ current, subdir, expected }) => {
|
|
83
|
+
const constructedPath = current.endsWith("/")
|
|
84
|
+
? `${current}${subdir}`
|
|
85
|
+
: `${current}/${subdir}`;
|
|
86
|
+
expect(constructedPath).toBe(expected);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should handle invalid remote path in browse workflow", () => {
|
|
91
|
+
// Try to validate invalid remote path (empty without default)
|
|
92
|
+
const remoteValidation = validateRemotePath("");
|
|
93
|
+
|
|
94
|
+
// Should fail validation
|
|
95
|
+
expect(remoteValidation.valid).toBe(false);
|
|
96
|
+
expect(remoteValidation.error).toBeDefined();
|
|
97
|
+
expect(remoteValidation.error).toContain("cannot be empty");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle invalid remote path with control characters", () => {
|
|
101
|
+
const invalidPath = "/remote/path\x00/directory";
|
|
102
|
+
const remoteValidation = validateRemotePath(invalidPath);
|
|
103
|
+
|
|
104
|
+
// Should fail validation
|
|
105
|
+
expect(remoteValidation.valid).toBe(false);
|
|
106
|
+
expect(remoteValidation.error).toBeDefined();
|
|
107
|
+
expect(remoteValidation.error).toContain("control characters");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should validate all inputs before allowing browse", () => {
|
|
111
|
+
const browseHost: SSHHostConfig = {
|
|
112
|
+
host: "browseserver",
|
|
113
|
+
hostName: "browse.example.com",
|
|
114
|
+
user: "browseuser",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// All validations should pass
|
|
118
|
+
const remoteValidation = validateRemotePath("~");
|
|
119
|
+
const hostValidation = validateHostConfig(browseHost);
|
|
120
|
+
|
|
121
|
+
expect(remoteValidation.valid).toBe(true);
|
|
122
|
+
expect(hostValidation.valid).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should handle host without optional properties", () => {
|
|
126
|
+
const minimalHost: SSHHostConfig = {
|
|
127
|
+
host: "minimal-server",
|
|
128
|
+
hostName: "minimal.example.com",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Should validate successfully even without user, port, and identityFile
|
|
132
|
+
const hostValidation = validateHostConfig(minimalHost);
|
|
133
|
+
expect(hostValidation.valid).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should handle various remote path formats for browsing", () => {
|
|
137
|
+
const validPaths = [
|
|
138
|
+
"/absolute/path/directory",
|
|
139
|
+
"relative/path/directory",
|
|
140
|
+
"/path/with spaces/directory",
|
|
141
|
+
"/path/with-dashes/directory",
|
|
142
|
+
"/path/with_underscores/directory",
|
|
143
|
+
"/path/with.dots/directory",
|
|
144
|
+
"~/home/path/directory",
|
|
145
|
+
"~",
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
validPaths.forEach((remotePath) => {
|
|
149
|
+
const validation = validateRemotePath(remotePath);
|
|
150
|
+
expect(validation.valid).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should handle path construction for nested directories", () => {
|
|
155
|
+
// Simulate navigating through multiple directory levels
|
|
156
|
+
let currentPath = "~";
|
|
157
|
+
const directories = ["documents", "projects", "my-app"];
|
|
158
|
+
|
|
159
|
+
directories.forEach((dir) => {
|
|
160
|
+
currentPath = currentPath.endsWith("/")
|
|
161
|
+
? `${currentPath}${dir}`
|
|
162
|
+
: `${currentPath}/${dir}`;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(currentPath).toBe("~/documents/projects/my-app");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should handle path construction with trailing slashes", () => {
|
|
169
|
+
const pathsWithTrailingSlash = [
|
|
170
|
+
{
|
|
171
|
+
path: "/home/user/",
|
|
172
|
+
subdir: "documents",
|
|
173
|
+
expected: "/home/user/documents",
|
|
174
|
+
},
|
|
175
|
+
{ path: "~/", subdir: "projects", expected: "~/projects" },
|
|
176
|
+
{ path: "/var/www/", subdir: "html", expected: "/var/www/html" },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
pathsWithTrailingSlash.forEach(({ path, subdir, expected }) => {
|
|
180
|
+
const constructedPath = path.endsWith("/")
|
|
181
|
+
? `${path}${subdir}`
|
|
182
|
+
: `${path}/${subdir}`;
|
|
183
|
+
expect(constructedPath).toBe(expected);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should validate host config with invalid port", () => {
|
|
188
|
+
const invalidHost: SSHHostConfig = {
|
|
189
|
+
host: "browseserver",
|
|
190
|
+
hostName: "browse.example.com",
|
|
191
|
+
port: 99999, // Invalid port
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const hostValidation = validateHostConfig(invalidHost);
|
|
195
|
+
expect(hostValidation.valid).toBe(false);
|
|
196
|
+
expect(hostValidation.error).toBeDefined();
|
|
197
|
+
expect(hostValidation.error).toContain("must be between 1 and 65535");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("should handle missing hostname in host config", () => {
|
|
201
|
+
const hostWithoutHostname: SSHHostConfig = {
|
|
202
|
+
host: "browseserver",
|
|
203
|
+
// hostName is optional, so this should still be valid
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const hostValidation = validateHostConfig(hostWithoutHostname);
|
|
207
|
+
// Hostname is optional, so validation should pass
|
|
208
|
+
expect(hostValidation.valid).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("Remote file listing workflow", () => {
|
|
212
|
+
it("should handle path validation before listing", () => {
|
|
213
|
+
const remotePath = "/home/user/documents";
|
|
214
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
215
|
+
expect(remoteValidation.valid).toBe(true);
|
|
216
|
+
|
|
217
|
+
const hostValidation = validateHostConfig(mockHostConfig);
|
|
218
|
+
expect(hostValidation.valid).toBe(true);
|
|
219
|
+
|
|
220
|
+
// Both validations must pass before attempting to list files
|
|
221
|
+
expect(remoteValidation.valid && hostValidation.valid).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should handle home directory path", () => {
|
|
225
|
+
const remotePath = "~";
|
|
226
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
227
|
+
expect(remoteValidation.valid).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should construct correct paths for file operations", () => {
|
|
231
|
+
const basePath = "/home/user";
|
|
232
|
+
const fileName = "document.txt";
|
|
233
|
+
const filePath = basePath.endsWith("/")
|
|
234
|
+
? `${basePath}${fileName}`
|
|
235
|
+
: `${basePath}/${fileName}`;
|
|
236
|
+
expect(filePath).toBe("/home/user/document.txt");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should construct correct paths for directory operations", () => {
|
|
240
|
+
const basePath = "/home/user";
|
|
241
|
+
const dirName = "documents";
|
|
242
|
+
const dirPath = basePath.endsWith("/")
|
|
243
|
+
? `${basePath}${dirName}`
|
|
244
|
+
: `${basePath}/${dirName}`;
|
|
245
|
+
expect(dirPath).toBe("/home/user/documents");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("Error handling in browse workflow", () => {
|
|
250
|
+
it("should reject empty remote path", () => {
|
|
251
|
+
const remoteValidation = validateRemotePath("");
|
|
252
|
+
expect(remoteValidation.valid).toBe(false);
|
|
253
|
+
expect(remoteValidation.error).toBeDefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("should reject remote path with only whitespace", () => {
|
|
257
|
+
const remoteValidation = validateRemotePath(" ");
|
|
258
|
+
expect(remoteValidation.valid).toBe(false);
|
|
259
|
+
expect(remoteValidation.error).toBeDefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should handle invalid host configuration", () => {
|
|
263
|
+
const invalidHost: SSHHostConfig = {
|
|
264
|
+
host: "", // Empty host should fail
|
|
265
|
+
hostName: "example.com",
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const hostValidation = validateHostConfig(invalidHost);
|
|
269
|
+
expect(hostValidation.valid).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("Path normalization", () => {
|
|
274
|
+
it("should handle paths with multiple slashes", () => {
|
|
275
|
+
// In real usage, paths might have multiple slashes that need normalization
|
|
276
|
+
const remotePath = "/home//user///documents";
|
|
277
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
278
|
+
// Validation should pass (normalization happens at SSH level)
|
|
279
|
+
expect(remoteValidation.valid).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("should handle paths with dots", () => {
|
|
283
|
+
const pathsWithDots = [
|
|
284
|
+
"/home/user/./documents",
|
|
285
|
+
"/home/user/../documents",
|
|
286
|
+
"/home/user/././documents",
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
pathsWithDots.forEach((path) => {
|
|
290
|
+
const validation = validateRemotePath(path);
|
|
291
|
+
expect(validation.valid).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|