Files
plainleaf/server/shell.test.ts
T
+1 766fb96ff2 Adding support for hosting with URL prefix (#1357)
* Adding support for hosting with URL prefix to make reverse-proxy setup on sub-path possible

* Adjusting recent changes to auth.html (changing to relative URLs)  after rebasing from upstream

* Adjusting service_worker and application manifest to support URL-prefix

* Add notes on Space Lua Pattern Matching (#1366)

* Remove common/space_lua/stdlib/math.md (#1371)

* Escape pipe symbol in string.gsub (#1370)

* feat: configure vim in CONFIG (#1369)

* Fix week calculation (#1368)

* Add more tests for week calculation

* Fix week calculation

* Add more tests

* Implement Lua built-in function os.difftime (#1367)

* Implement Lua built-in function os.difftime

* Test os.difftime

---------

Co-authored-by: Zef Hemel <zef@zef.me>

* Fix index page naming during server initialization (#1365)

* Allow non-zero shell exit codes (#1377)

* Generate version from git describe at build time (#1374)

* Generate version from git describe at build time

* Add 2.0-beta prefix

* Use Deno.Command instead of Deno.run

* Add missing case to math.random and check arguments (#1372)

* Fix paragraphs for the tag page (#1376)

* Render a predefined string for empty markdown table instead of pipe characters (#1387)

* Fix string.find (#1390)

* Fetch tags and history for proper version string generation (#1388)

* Fix support for disabling shortcuts (#1393)

* Add GitHub Actions Cache to reduce build times (#1394)

* Order strings in queries using collation algorithm (#1391)

Configurable with queryCollation, default value maintains old behaviour.
Added tests, schema, example settings, and documentation to query page.

Close: #614
Close: #1316

* Utility function to handle empty query results and templates (#1386)

* Add global Lua function 'some'

Moved documentation for 'each' function to non-standard section.

* Revert "Add optional argument to template.each for empty collection output (#1359)"

This reverts commit b73dadec67.

* Reimplement #1359 using 'some' function

* Update Lua to event.listEvents to match plugos syscall (#1397)

The documentation, example, and other function names use
`event.listEvents`, but `event.list` is being registered with the
client.

* Update widget header to not overflow container (#1410)

Also add a 10px margin below the header to maintain consistent spacing
around the widget contents.

* fix: case insensitive admonitions by default (#1409)

* fix: closing bracket is missing (#1408)

* Add optional priority attribute to PageCreatingContent (#1392)

* Add optional priority attribute to PageCreatingContent

* Minor simplification

* Fix comments

* Revert changes

* Add pageEventHandlers

* Use UTC year, month, and date for strings lacking time components (#1403)

* feat: allow CONFIG.md in any directory (#1398)

* fix: bundle external dependencies (#1379)

* Deno bump

* Copy-edit splash screen text (#1413)

* Fixes #1401 by implementing Lua's select

* Fixes offline capability of PWA (I hope).

Now storing config in localStorage.

* fix lint

* Reduce blast radius of Lua errors

* Fixes #1411: adds 'priority' setting for space style

* Refactor: Merging common/ into web/

* Fixes #1400

* Improves #1312 by rendering Lua expressions (but not widgets)

* Fix minor typos, add configuration to the docs

---------

Co-authored-by: wbhouston <wmbhouston@gmail.com>
Co-authored-by: Lennart Ochel <lennart.ochel@outlook.com>
Co-authored-by: Matthias Benaets <89214559+MatthiasBenaets@users.noreply.github.com>
Co-authored-by: Zef Hemel <zef@zef.me>
Co-authored-by: Marek S. Łukasiewicz <marek@lukasiewicz.tech>
Co-authored-by: Ivan Alejandro <ivanalejandro0@gmail.com>
Co-authored-by: Albert-Jan de Vries <aj+github@albert-jandevries.nl>
Co-authored-by: Cole Stowell <121599022+costowell@users.noreply.github.com>
Co-authored-by: Jim Tittsler <jimt@onjapan.net>
2025-06-04 15:15:14 +02:00

586 lines
17 KiB
TypeScript

import { assert, assertEquals, assertExists } from "@std/assert";
import { HttpServer, type ServerOptions } from "./http_server.ts";
import { AssetBundle } from "$lib/asset_bundle/bundle.ts";
import { MemoryKvPrimitives } from "$lib/data/memory_kv_primitives.ts";
import { sleep } from "../lib/async.ts";
import {
ShellStreamClient,
type ShellStreamEvent,
} from "../web/shell_stream_client.ts";
import { HttpSpacePrimitives } from "../web/spaces/http_space_primitives.ts";
// Create a test script
async function createTestScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "Hello from test script"
echo "This is stderr output" >&2
read input
echo "You said: $input"
exit 0
`;
const scriptPath = `${dir}/test_script.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Create a signal-handling test script
async function createSignalTestScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "Starting signal test script"
# Set up signal handler
trap 'echo "Received SIGTERM"; exit 0' TERM
trap 'echo "Received SIGINT"; exit 0' INT
echo "Signal handlers set up"
# Wait indefinitely
while true; do
sleep 1
done
`;
const scriptPath = `${dir}/signal_test.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Create a long-running test script that writes to a file when it exits
async function createLongRunningScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "Starting long-running script"
# Create a file to track the process ID
echo $$ > "${dir}/process.pid"
# Set up signal handler to detect proper cleanup
trap 'echo "Process terminated properly" > "${dir}/cleanup.log"; exit 0' TERM INT
echo "Ready for long-running operation"
# Wait indefinitely
while true; do
sleep 1
echo "Still running..."
done
`;
const scriptPath = `${dir}/long_running.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Setup a minimal HttpServer for testing
async function setupTestServer(
tempDir: string,
): Promise<{ server: HttpServer; port: number }> {
// Create minimal asset bundles
const clientAssetBundle = new AssetBundle();
const plugAssetBundle = new AssetBundle();
// Create a memory KV store
const kvPrimitives = new MemoryKvPrimitives();
// Find an available port
const port = 9000 + Math.floor(Math.random() * 1000);
// Create server options
const options: ServerOptions = {
hostname: "localhost",
port,
clientAssetBundle,
plugAssetBundle,
baseKvPrimitives: kvPrimitives,
pagesPath: tempDir,
shellBackend: "local", // Use local shell backend
readOnly: false,
indexPage: "index",
enableSpaceScript: false,
hostUrlPrefix: undefined,
};
// Create and start the server
const server = new HttpServer(options);
await server.start();
return { server, port };
}
// Helper function to create a script that fails with an error
async function createErrorScript(dir: string): Promise<string> {
const scriptContent = `#!/bin/sh
echo "This is stdout before the error"
echo "This is stderr output" >&2
exit 1
`;
const scriptPath = `${dir}/error_script.sh`;
await Deno.writeTextFile(scriptPath, scriptContent);
await Deno.chmod(scriptPath, 0o755); // Make executable
return scriptPath;
}
// Run all tests in a single test function to avoid resource tracking issues
// TODO: Fix this, make this more stable
Deno.test("Shell endpoint tests", { ignore: true }, async () => {
// Test 1: Basic functionality with POST endpoint
{
// Test: Basic functionality with POST endpoint
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: "echo",
args: ["Hello, World!"],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for output and exit
await sleep(1000);
// Close the connection and wait for cleanup
client.close();
await sleep(500);
// Verify events
assert(events.length >= 1, "Expected at least 1 event");
// Should have stdout message
const stdoutEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Hello, World!")
);
assertExists(stdoutEvent, "Stdout event not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Cleanup on premature WebSocket close
{
// Test: Cleanup on premature WebSocket close
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a long-running test script
const scriptPath = await createLongRunningScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: scriptPath,
args: [],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for the script to start
await sleep(1000);
// Verify the process is running by checking if the PID file exists
const pidFileExists = await Deno.stat(`${tempDir}/process.pid`)
.then(
() => true,
() => false,
);
assert(pidFileExists, "Process PID file should exist");
// Close the WebSocket connection prematurely
client.close();
// Wait for cleanup to happen
await sleep(2000);
// Check if the cleanup log file was created, indicating proper termination
const cleanupFileExists = await Deno.stat(`${tempDir}/cleanup.log`)
.then(
() => true,
() => false,
);
assert(
cleanupFileExists,
"Cleanup log file should exist, indicating proper process termination",
);
if (cleanupFileExists) {
const cleanupContent = await Deno.readTextFile(
`${tempDir}/cleanup.log`,
);
assert(
cleanupContent.includes("Process terminated properly"),
"Process should have been terminated properly",
);
}
// Verify events
const startupEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Starting long-running script")
);
assertExists(startupEvent, "Startup message not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Unified send method with POST endpoint
{
// Test: Unified send method with POST endpoint
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a test script
const scriptPath = await createTestScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: scriptPath,
args: [],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for initial output
await sleep(500);
// Send input using the unified send() method
client.send("Hello from test");
// Wait for response and exit
await sleep(1000);
// Close the connection and wait for cleanup
client.close();
await sleep(500);
// Verify events
assert(events.length >= 3, "Expected at least 3 events");
// Should have stdout message
const stdoutEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Hello from test script")
);
assertExists(stdoutEvent, "Stdout event not found");
// Should have stderr message
const stderrEvent = events.find((e) =>
e.type === "stderr" &&
typeof e.data === "string" &&
e.data.includes("This is stderr output")
);
assertExists(stderrEvent, "Stderr event not found");
// Should have response to input
const responseEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("You said: Hello from test")
);
assertExists(responseEvent, "Response event not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Send signal to process
{
// Test: Send signal to process
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a signal-handling test script
const scriptPath = await createSignalTestScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create a client with HttpSpacePrimitives
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
const client = new ShellStreamClient({
httpSpacePrimitives,
cmd: scriptPath,
args: [],
});
// Collect events
const events: ShellStreamEvent[] = [];
client.addEventListener("stdout", (event) => events.push(event));
client.addEventListener("stderr", (event) => events.push(event));
client.addEventListener("exit", (event) => events.push(event));
// Connect to the server
await client.start();
// Wait for the script to start and set up signal handlers
await sleep(1000);
// Send a signal to the process
client.kill("SIGTERM");
// Wait for the process to handle the signal and exit
await sleep(1000);
// Close the connection and wait for cleanup
client.close();
await sleep(500);
// Verify events
assert(events.length >= 3, "Expected at least 3 events");
// Should have initial stdout messages
const startupEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Starting signal test script")
);
assertExists(startupEvent, "Startup message not found");
// Should have signal handler message
const signalEvent = events.find((e) =>
e.type === "stdout" &&
typeof e.data === "string" &&
e.data.includes("Received SIGTERM")
);
assertExists(signalEvent, "Signal handler message not found");
// Should have exit event
const exitEvent = events.find((e) => e.type === "exit");
assertExists(exitEvent, "Exit event not found");
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// .shell command with arguments
{
// Test: Command with arguments
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create a test script
const scriptPath = await createTestScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create HTTP space primitives for making requests
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
// Test command with arguments
const response = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.shell`,
{
method: "POST",
body: JSON.stringify({
cmd: scriptPath,
args: [],
}),
},
);
const result = await response.json();
// Verify response
assertEquals(result.code, 0, "Exit code should be 0");
assert(
result.stdout.includes("Hello from test script"),
"Output should contain the script output",
);
assert(
result.stderr.includes("This is stderr output"),
"Stderr should contain the error output",
);
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Error handling
{
// Test: Error handling
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Create an error script
const scriptPath = await createErrorScript(tempDir);
// Set up test server
const { server, port } = await setupTestServer(tempDir);
try {
// Create HTTP space primitives for making requests
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
// Test command that fails
const response = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.shell`,
{
method: "POST",
body: JSON.stringify({
cmd: scriptPath,
args: [],
}),
},
);
const result = await response.json();
// Verify response
assertEquals(result.code, 1, "Exit code should be 1");
assert(
result.stdout.includes("This is stdout before the error"),
"Output should contain the stdout text",
);
assert(
result.stderr.includes("This is stderr output"),
"Stderr should contain the error output",
);
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
// Read-only mode
{
// Test: Read-only mode
// Create a temporary directory for testing
const tempDir = await Deno.makeTempDir();
try {
// Set up test server with read-only mode
const { server, port } = await setupTestServer(tempDir);
// Set server to read-only mode
server.spaceServer.readOnly = true;
try {
// Create HTTP space primitives for making requests
const httpSpacePrimitives = new HttpSpacePrimitives(
`http://localhost:${port}`,
);
// Test command in read-only mode
const response = await httpSpacePrimitives.authenticatedFetch(
`${httpSpacePrimitives.url}/.shell`,
{
method: "POST",
body: JSON.stringify({
cmd: "echo",
args: ["Hello, World!"],
}),
},
);
// Verify response status is 405 Method Not Allowed
assertEquals(
response.status,
405,
"Status should be 405 Method Not Allowed",
);
const text = await response.text();
assert(
text.includes("Read only mode"),
"Response should mention read-only mode",
);
} finally {
// Clean up server
server.stop();
}
} finally {
// Clean up temporary directory
await Deno.remove(tempDir, { recursive: true });
}
}
});