test: backend integration
This commit is contained in:
parent
3142cb026a
commit
8f7af1c0c3
13 changed files with 1030 additions and 0 deletions
|
|
@ -97,6 +97,7 @@ COPY . .
|
|||
|
||||
RUN bun run build
|
||||
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
|
||||
RUN bun run build:backend-integration
|
||||
|
||||
FROM base AS production
|
||||
|
||||
|
|
@ -123,3 +124,7 @@ COPY ./LICENSE ./LICENSE.md
|
|||
EXPOSE 4096
|
||||
|
||||
CMD ["bun", ".output/server/index.mjs"]
|
||||
|
||||
FROM production AS backend-integration
|
||||
|
||||
CMD ["bun", ".output/backend-integration/index.js"]
|
||||
|
|
|
|||
26
app/test/backend-integration/README.md
Normal file
26
app/test/backend-integration/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Backend Integration
|
||||
|
||||
This runner executes isolated source-level integration tests for Zerobyte volume backends and repository backends inside a Docker container that shares the same runtime tooling as the main image.
|
||||
|
||||
## What it verifies
|
||||
|
||||
For each scenario the runner will:
|
||||
|
||||
1. Mount the configured volume backend into an isolated temp workspace.
|
||||
2. Read fixture files directly from the mounted filesystem.
|
||||
3. Verify mounted file ownership and permission bits.
|
||||
4. Run a restic backup directly against the configured repository backend.
|
||||
5. Inspect the created snapshot with `restic ls` and verify snapshot metadata.
|
||||
6. Restore the snapshot into an isolated temp directory.
|
||||
7. Verify restored content, ownership, and permission bits.
|
||||
8. Unmount and clean up local test artifacts.
|
||||
|
||||
## Bootstrap a Debian target
|
||||
|
||||
This folder includes `setup-target.sh`, which connects to a VM host and configures:
|
||||
|
||||
- NFS export at `/srv/zerobyte-backend-integration/fixtures`
|
||||
- Samba share `//<host>/zerobyte-backend-integration`
|
||||
- WebDAV endpoint at `http://<host>/zerobyte-backend-integration`
|
||||
- SFTP access for both fixture reads and a reusable restic repository
|
||||
- A generated local keypair, `known_hosts`, password files, and a ready-to-run config under `artifacts/192.168.2.41/`
|
||||
2
app/test/backend-integration/artifacts/.gitignore
vendored
Normal file
2
app/test/backend-integration/artifacts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
18
app/test/backend-integration/index.ts
Normal file
18
app/test/backend-integration/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
function ensureIntegrationEnv(): void {
|
||||
process.env.NODE_ENV ??= "production";
|
||||
process.env.LOG_LEVEL ??= "warn";
|
||||
process.env.BASE_URL ??= "http://localhost:4096";
|
||||
process.env.TRUSTED_ORIGINS ??= process.env.BASE_URL;
|
||||
process.env.APP_SECRET ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
ensureIntegrationEnv();
|
||||
|
||||
const { runBackendIntegration } = await import("./runner");
|
||||
await runBackendIntegration();
|
||||
}
|
||||
|
||||
await main();
|
||||
|
||||
export {};
|
||||
34
app/test/backend-integration/run.sh
Executable file
34
app/test/backend-integration/run.sh
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../../.." && pwd)"
|
||||
default_config_path="$script_dir/artifacts/192.168.2.41/config.generated.json"
|
||||
config_path="${1:-$default_config_path}"
|
||||
image_tag="zerobyte-backend-integration"
|
||||
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
if [[ "$config_path" == "$default_config_path" ]]; then
|
||||
echo "Generated config not found: $config_path" >&2
|
||||
echo "Run the target bootstrap first:" >&2
|
||||
echo " bash app/test/backend-integration/setup-debian-target.sh" >&2
|
||||
else
|
||||
echo "Config file not found: $config_path" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
config_path="$(realpath "$config_path")"
|
||||
else
|
||||
config_dir="$(cd "$(dirname "$config_path")" && pwd)"
|
||||
config_path="$config_dir/$(basename "$config_path")"
|
||||
fi
|
||||
|
||||
docker build --target backend-integration -t "$image_tag" "$repo_root"
|
||||
docker run --rm \
|
||||
--cap-add SYS_ADMIN \
|
||||
--device /dev/fuse:/dev/fuse \
|
||||
-e ZEROBYTE_INTEGRATION_CONFIG=/config/config.json \
|
||||
-v "$config_path:/config/config.json:ro" \
|
||||
"$image_tag"
|
||||
318
app/test/backend-integration/runner.ts
Normal file
318
app/test/backend-integration/runner.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Effect } from "effect";
|
||||
import { createRestic } from "@zerobyte/core/restic/server";
|
||||
import { RCLONE_CONFIG_FILE } from "~/server/core/constants";
|
||||
import type { VolumeBackend } from "~/server/modules/backends/backend";
|
||||
import { makeDirectoryBackend } from "~/server/modules/backends/directory/directory-backend";
|
||||
import { makeNfsBackend } from "~/server/modules/backends/nfs/nfs-backend";
|
||||
import { makeRcloneBackend } from "~/server/modules/backends/rclone/rclone-backend";
|
||||
import { makeSftpBackend } from "~/server/modules/backends/sftp/sftp-backend";
|
||||
import { makeSmbBackend } from "~/server/modules/backends/smb/smb-backend";
|
||||
import { makeWebdavBackend } from "~/server/modules/backends/webdav/webdav-backend";
|
||||
import { configSchema, type IntegrationScenario } from "./runner/config";
|
||||
import {
|
||||
formatError,
|
||||
logScenario,
|
||||
printRunSummary,
|
||||
type IntegrationReport,
|
||||
type ScenarioReport,
|
||||
type StageReport,
|
||||
} from "./runner/reporting";
|
||||
import { verifyFilesystemEntries, verifySnapshotEntries } from "./runner/verification";
|
||||
|
||||
const WORKDIR_ROOT = "/tmp/zerobyte-integration";
|
||||
const INTEGRATION_ORGANIZATION_ID = "backend-integration";
|
||||
|
||||
function createVolumeBackend(config: IntegrationScenario["volume"], mountPath: string): VolumeBackend {
|
||||
switch (config.backend) {
|
||||
case "nfs":
|
||||
return makeNfsBackend(config, mountPath);
|
||||
case "smb":
|
||||
return makeSmbBackend(config, mountPath);
|
||||
case "directory":
|
||||
return makeDirectoryBackend(config, mountPath);
|
||||
case "webdav":
|
||||
return makeWebdavBackend(config, mountPath);
|
||||
case "rclone":
|
||||
return makeRcloneBackend(config, mountPath);
|
||||
case "sftp":
|
||||
return makeSftpBackend(config, mountPath);
|
||||
}
|
||||
}
|
||||
|
||||
function createResticSession(scenarioWorkspace: string): {
|
||||
organizationId: string;
|
||||
client: ReturnType<typeof createRestic>;
|
||||
} {
|
||||
const password = crypto.randomBytes(32).toString("hex");
|
||||
const cacheDir = path.join(scenarioWorkspace, "restic-cache");
|
||||
const passFile = path.join(scenarioWorkspace, "restic.pass");
|
||||
|
||||
return {
|
||||
organizationId: INTEGRATION_ORGANIZATION_ID,
|
||||
client: createRestic({
|
||||
resolveSecret: async (value: string) => value,
|
||||
getOrganizationResticPassword: async () => password,
|
||||
resticCacheDir: cacheDir,
|
||||
resticPassFile: passFile,
|
||||
defaultExcludes: [],
|
||||
rcloneConfigFile: RCLONE_CONFIG_FILE,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runStage<T>(stages: StageReport[], name: string, fn: () => Promise<T>): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
stages.push({
|
||||
name,
|
||||
status: "passed",
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
stages.push({
|
||||
name,
|
||||
status: "failed",
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: formatError(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function mountBackend(backend: VolumeBackend) {
|
||||
const mountResult = await backend.mount();
|
||||
if (mountResult.status !== "mounted") {
|
||||
throw new Error(mountResult.error ?? `Mount returned ${mountResult.status}`);
|
||||
}
|
||||
|
||||
const healthResult = await backend.checkHealth();
|
||||
if (healthResult.status !== "mounted") {
|
||||
throw new Error(healthResult.error ?? `Health check returned ${healthResult.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function backupSnapshot(
|
||||
resticClient: ReturnType<typeof createRestic>,
|
||||
repositoryConfig: IntegrationScenario["repository"],
|
||||
fixtureRootPath: string,
|
||||
organizationId: string,
|
||||
backupOptions: IntegrationScenario["backup"],
|
||||
uniqueBackupTag: string,
|
||||
) {
|
||||
const execution = await Effect.runPromise(
|
||||
resticClient.backup(repositoryConfig, fixtureRootPath, {
|
||||
organizationId,
|
||||
compressionMode: backupOptions?.compressionMode,
|
||||
customResticParams: backupOptions?.customResticParams,
|
||||
tags: [...(backupOptions?.tags ?? []), uniqueBackupTag],
|
||||
}),
|
||||
);
|
||||
|
||||
if (execution.exitCode !== 0) {
|
||||
throw new Error(`restic backup returned exit code ${execution.exitCode}`);
|
||||
}
|
||||
|
||||
if (!execution.result?.snapshot_id) {
|
||||
throw new Error("restic backup completed without a snapshot id");
|
||||
}
|
||||
|
||||
if (execution.warningDetails) {
|
||||
throw new Error(`restic backup reported warnings: ${execution.warningDetails}`);
|
||||
}
|
||||
|
||||
return execution.result.snapshot_id;
|
||||
}
|
||||
|
||||
async function verifySnapshot(
|
||||
resticClient: ReturnType<typeof createRestic>,
|
||||
repositoryConfig: IntegrationScenario["repository"],
|
||||
organizationId: string,
|
||||
snapshotId: string,
|
||||
uniqueBackupTag: string,
|
||||
fixtureRootPath: string,
|
||||
scenario: IntegrationScenario,
|
||||
) {
|
||||
const snapshots = await resticClient.snapshots(repositoryConfig, {
|
||||
organizationId,
|
||||
tags: [uniqueBackupTag],
|
||||
});
|
||||
|
||||
const snapshot = snapshots.find((candidate) => candidate.id === snapshotId || candidate.short_id === snapshotId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Unable to find snapshot ${snapshotId} by integration tag ${uniqueBackupTag}`);
|
||||
}
|
||||
|
||||
const res = await resticClient.ls(repositoryConfig, snapshotId, organizationId, undefined, {});
|
||||
|
||||
await verifySnapshotEntries(fixtureRootPath, res.nodes, scenario.expectedEntries);
|
||||
}
|
||||
|
||||
async function restoreSnapshot(
|
||||
resticClient: ReturnType<typeof createRestic>,
|
||||
repositoryConfig: IntegrationScenario["repository"],
|
||||
snapshotId: string,
|
||||
restoreTarget: string,
|
||||
organizationId: string,
|
||||
fixtureRootPath: string,
|
||||
restoreOptions: IntegrationScenario["restore"],
|
||||
) {
|
||||
await fs.mkdir(restoreTarget, { recursive: true });
|
||||
await resticClient.restore(repositoryConfig, snapshotId, restoreTarget, {
|
||||
organizationId,
|
||||
basePath: fixtureRootPath,
|
||||
excludeXattr: restoreOptions?.excludeXattr,
|
||||
overwrite: restoreOptions?.overwrite,
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupScenario(backend: VolumeBackend, restoreTarget: string, scenarioWorkspace: string) {
|
||||
await backend.unmount();
|
||||
await fs.rm(restoreTarget, { recursive: true, force: true });
|
||||
await fs.rm(scenarioWorkspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function runScenario(scenario: IntegrationScenario, runId: string): Promise<ScenarioReport> {
|
||||
const startedAt = Date.now();
|
||||
const stages: StageReport[] = [];
|
||||
const scenarioWorkspace = path.join(WORKDIR_ROOT, `${scenario.id}-${runId}`);
|
||||
const mountPath = path.join(scenarioWorkspace, "volume", "_data");
|
||||
const restoreTarget = path.join(scenarioWorkspace, "restore");
|
||||
|
||||
const uniqueBackupTag = `zerobyte-integration-${scenario.id}-${runId}`;
|
||||
const { client: resticClient, organizationId } = createResticSession(scenarioWorkspace);
|
||||
const backend = createVolumeBackend(scenario.volume, mountPath);
|
||||
|
||||
let snapshotId = "";
|
||||
let status: ScenarioReport["status"] = "passed";
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
await fs.mkdir(scenarioWorkspace, { recursive: true });
|
||||
logScenario(scenario.id, "running...");
|
||||
|
||||
try {
|
||||
await runStage(stages, "mount", async () => {
|
||||
await mountBackend(backend);
|
||||
});
|
||||
|
||||
const fixtureRootPath = path.resolve(mountPath, scenario.fixtureRoot);
|
||||
await runStage(stages, "verify-mounted-source", async () => {
|
||||
await verifyFilesystemEntries(fixtureRootPath, scenario.expectedEntries, "mounted source");
|
||||
});
|
||||
|
||||
await runStage(stages, "init-repository", async () => {
|
||||
if (scenario.repository.isExistingRepository) return;
|
||||
|
||||
const initResult = await resticClient.init(scenario.repository, organizationId, undefined);
|
||||
if (!initResult.success) {
|
||||
throw new Error(initResult.error ?? "restic init failed");
|
||||
}
|
||||
});
|
||||
|
||||
snapshotId = await runStage(stages, "backup", async () => {
|
||||
return await backupSnapshot(
|
||||
resticClient,
|
||||
scenario.repository,
|
||||
fixtureRootPath,
|
||||
organizationId,
|
||||
scenario.backup,
|
||||
uniqueBackupTag,
|
||||
);
|
||||
});
|
||||
|
||||
if (!snapshotId) {
|
||||
throw new Error("restic backup completed without a snapshot id");
|
||||
}
|
||||
|
||||
await runStage(stages, "inspect-snapshot", async () => {
|
||||
await verifySnapshot(
|
||||
resticClient,
|
||||
scenario.repository,
|
||||
organizationId,
|
||||
snapshotId,
|
||||
uniqueBackupTag,
|
||||
fixtureRootPath,
|
||||
scenario,
|
||||
);
|
||||
});
|
||||
|
||||
await runStage(stages, "restore", async () => {
|
||||
await restoreSnapshot(
|
||||
resticClient,
|
||||
scenario.repository,
|
||||
snapshotId,
|
||||
restoreTarget,
|
||||
organizationId,
|
||||
fixtureRootPath,
|
||||
scenario.restore,
|
||||
);
|
||||
});
|
||||
|
||||
await runStage(stages, "verify-restore", async () => {
|
||||
await verifyFilesystemEntries(restoreTarget, scenario.expectedEntries, "restored output");
|
||||
});
|
||||
|
||||
logScenario(scenario.id, "passed");
|
||||
} catch (error) {
|
||||
status = "failed";
|
||||
errorMessage = formatError(error);
|
||||
logScenario(scenario.id, `failed: ${errorMessage}`);
|
||||
} finally {
|
||||
await runStage(stages, "cleanup", async () => {
|
||||
await cleanupScenario(backend, restoreTarget, scenarioWorkspace);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: scenario.id,
|
||||
volumeBackend: scenario.volume.backend,
|
||||
repositoryBackend: scenario.repository.backend,
|
||||
status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
stages,
|
||||
snapshotId,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runBackendIntegration(): Promise<void> {
|
||||
const runStartedAt = Date.now();
|
||||
const runId = crypto.randomBytes(6).toString("hex");
|
||||
|
||||
await fs.mkdir(WORKDIR_ROOT, { recursive: true });
|
||||
|
||||
const content = await fs.readFile("/config/config.json", "utf8");
|
||||
const { scenarios } = configSchema.parse(JSON.parse(content));
|
||||
|
||||
process.stdout.write(
|
||||
`Running ${scenarios.length} integration scenario${scenarios.length === 1 ? "" : "s"} (run ${runId})\n`,
|
||||
);
|
||||
|
||||
const scenarioReports: ScenarioReport[] = [];
|
||||
for (const scenario of scenarios) {
|
||||
scenarioReports.push(await runScenario(scenario, runId));
|
||||
}
|
||||
|
||||
const passed = scenarioReports.filter((scenario) => scenario.status === "passed").length;
|
||||
const failed = scenarioReports.length - passed;
|
||||
const report: IntegrationReport = {
|
||||
runId,
|
||||
startedAt: new Date(runStartedAt).toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - runStartedAt,
|
||||
passed,
|
||||
failed,
|
||||
scenarios: scenarioReports,
|
||||
};
|
||||
|
||||
printRunSummary(report);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
84
app/test/backend-integration/runner/config.ts
Normal file
84
app/test/backend-integration/runner/config.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
COMPRESSION_MODES,
|
||||
OVERWRITE_MODES,
|
||||
repositoryConfigSchema,
|
||||
type RepositoryConfig,
|
||||
} from "@zerobyte/core/restic";
|
||||
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const modeSchema = z
|
||||
.union([z.number(), z.string().transform((value) => Number.parseInt(value, 8))])
|
||||
.transform((value) => value & 0o7777);
|
||||
|
||||
const entryTypeSchema = z.enum(["file", "directory", "symlink"]);
|
||||
|
||||
const expectedEntrySchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
type: entryTypeSchema,
|
||||
uid: z.number().optional(),
|
||||
gid: z.number().optional(),
|
||||
mode: modeSchema.optional(),
|
||||
sha256: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
linkTarget: z.string().min(1).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.sha256 && value.text) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Specify either sha256 or text, not both",
|
||||
path: ["sha256"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type !== "file" && (value.sha256 || value.text)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Only file entries can define sha256 or text expectations",
|
||||
path: ["type"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type !== "symlink" && value.linkTarget) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Only symlink entries can define linkTarget",
|
||||
path: ["linkTarget"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const backupOptionsSchema = z.object({
|
||||
compressionMode: z.enum(COMPRESSION_MODES).optional(),
|
||||
tags: z.array(z.string().min(1)).optional(),
|
||||
customResticParams: z.array(z.string().min(1)).optional(),
|
||||
});
|
||||
|
||||
const restoreOptionsSchema = z.object({
|
||||
excludeXattr: z.array(z.string().min(1)).optional(),
|
||||
overwrite: z.enum(OVERWRITE_MODES).optional(),
|
||||
});
|
||||
|
||||
const scenarioSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
volume: volumeConfigSchema,
|
||||
repository: repositoryConfigSchema,
|
||||
fixtureRoot: z.string().min(1).default("."),
|
||||
expectedEntries: z.array(expectedEntrySchema).min(1),
|
||||
backup: backupOptionsSchema.optional(),
|
||||
restore: restoreOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export const configSchema = z.object({
|
||||
version: z.literal(1).default(1),
|
||||
scenarios: z.array(scenarioSchema).min(1),
|
||||
});
|
||||
|
||||
export type ExpectedEntry = z.infer<typeof expectedEntrySchema>;
|
||||
export type IntegrationScenario = z.infer<typeof scenarioSchema> & {
|
||||
volume: BackendConfig;
|
||||
repository: RepositoryConfig;
|
||||
};
|
||||
82
app/test/backend-integration/runner/reporting.ts
Normal file
82
app/test/backend-integration/runner/reporting.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export type StageReport = {
|
||||
name: string;
|
||||
status: "passed" | "failed";
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ScenarioReport = {
|
||||
id: string;
|
||||
volumeBackend: string;
|
||||
repositoryBackend: string;
|
||||
status: "passed" | "failed";
|
||||
durationMs: number;
|
||||
stages: StageReport[];
|
||||
snapshotId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type IntegrationReport = {
|
||||
runId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
scenarios: ScenarioReport[];
|
||||
};
|
||||
|
||||
export function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
export function logScenario(scenarioId: string, message: string): void {
|
||||
log(`[${scenarioId}] ${message}`);
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`;
|
||||
}
|
||||
|
||||
if (durationMs < 10_000) {
|
||||
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
return `${(durationMs / 1000).toFixed(0)}s`;
|
||||
}
|
||||
|
||||
function formatScenarioResult(scenario: ScenarioReport, labelWidth: number): string {
|
||||
const status = scenario.status === "passed" ? "✔︎ PASS" : "⨯ FAIL";
|
||||
const id = scenario.id.padEnd(labelWidth);
|
||||
return `${status} ${id} ${formatDuration(scenario.durationMs)} ${scenario.volumeBackend} -> ${scenario.repositoryBackend}`;
|
||||
}
|
||||
|
||||
export function printRunSummary(report: IntegrationReport): void {
|
||||
const labelWidth = report.scenarios.reduce((max, scenario) => Math.max(max, scenario.id.length), 4);
|
||||
|
||||
log("");
|
||||
log("Scenario Results");
|
||||
|
||||
for (const scenario of report.scenarios) {
|
||||
log(formatScenarioResult(scenario, labelWidth));
|
||||
|
||||
const failedStage = scenario.stages.find((stage) => stage.status === "failed");
|
||||
if (failedStage?.error) {
|
||||
log(` ${failedStage.name}: ${failedStage.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
log("");
|
||||
log(
|
||||
`${report.failed === 0 ? "PASS" : "FAIL"} ${report.passed}/${report.scenarios.length} scenarios passed in ${formatDuration(report.durationMs)} (run ${report.runId})`,
|
||||
);
|
||||
}
|
||||
150
app/test/backend-integration/runner/verification.ts
Normal file
150
app/test/backend-integration/runner/verification.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import crypto from "node:crypto";
|
||||
import type { Stats } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ExpectedEntry } from "./config";
|
||||
|
||||
export type SnapshotNode = {
|
||||
name: string;
|
||||
type: string;
|
||||
path: string;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
mode?: number;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
function normalizeRelativePath(input: string): string {
|
||||
const normalized = path.posix.normalize(input);
|
||||
if (normalized === "") {
|
||||
return ".";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEntryType(type: string): string {
|
||||
if (type === "dir" || type === "directory") {
|
||||
return "directory";
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
async function readSha256(filePath: string): Promise<string> {
|
||||
const digest = crypto.createHash("sha256");
|
||||
digest.update(await fs.readFile(filePath));
|
||||
return digest.digest("hex");
|
||||
}
|
||||
|
||||
function assertMetadata(
|
||||
entryLabel: string,
|
||||
expected: ExpectedEntry,
|
||||
actual: { uid?: number; gid?: number; mode?: number },
|
||||
) {
|
||||
if (expected.uid && actual.uid !== expected.uid) {
|
||||
throw new Error(`${entryLabel} uid mismatch: expected ${expected.uid}, got ${String(actual.uid)}`);
|
||||
}
|
||||
|
||||
if (expected.gid && actual.gid !== expected.gid) {
|
||||
throw new Error(`${entryLabel} gid mismatch: expected ${expected.gid}, got ${String(actual.gid)}`);
|
||||
}
|
||||
|
||||
if (!expected.mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!actual.mode) {
|
||||
throw new Error(`${entryLabel} mode is missing`);
|
||||
}
|
||||
|
||||
const permissionBits = actual.mode & 0o7777;
|
||||
if (permissionBits !== expected.mode) {
|
||||
throw new Error(
|
||||
`${entryLabel} mode mismatch: expected ${expected.mode.toString(8)}, got ${permissionBits.toString(8)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getFilesystemEntryType(stats: Stats): ExpectedEntry["type"] {
|
||||
if (stats.isDirectory()) {
|
||||
return "directory";
|
||||
}
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
return "symlink";
|
||||
}
|
||||
|
||||
return "file";
|
||||
}
|
||||
|
||||
export async function verifyFilesystemEntries(basePath: string, expectedEntries: ExpectedEntry[], label: string) {
|
||||
for (const expected of expectedEntries) {
|
||||
const targetPath = path.resolve(basePath, expected.path);
|
||||
const stats = await fs.lstat(targetPath);
|
||||
|
||||
const actualType = getFilesystemEntryType(stats);
|
||||
if (actualType !== expected.type) {
|
||||
throw new Error(`${label}: ${expected.path} type mismatch: expected ${expected.type}, got ${actualType}`);
|
||||
}
|
||||
|
||||
assertMetadata(`${label}: ${expected.path}`, expected, stats);
|
||||
|
||||
if (expected.type === "file" && expected.text !== undefined) {
|
||||
const actualText = await fs.readFile(targetPath, "utf8");
|
||||
if (actualText !== expected.text) {
|
||||
throw new Error(`${label}: ${expected.path} text content mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expected.type === "file" && expected.sha256 !== undefined) {
|
||||
const actualSha = await readSha256(targetPath);
|
||||
if (actualSha !== expected.sha256.toLowerCase()) {
|
||||
throw new Error(`${label}: ${expected.path} sha256 mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expected.type !== "symlink" || expected.linkTarget === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualTarget = await fs.readlink(targetPath);
|
||||
if (actualTarget !== expected.linkTarget) {
|
||||
throw new Error(
|
||||
`${label}: ${expected.path} symlink target mismatch: expected ${expected.linkTarget}, got ${actualTarget}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySnapshotEntries(
|
||||
snapshotRootPath: string,
|
||||
nodes: SnapshotNode[],
|
||||
expectedEntries: ExpectedEntry[],
|
||||
) {
|
||||
const nodesByRelativePath = new Map<string, SnapshotNode>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const relativePath = path.posix.relative(snapshotRootPath, node.path);
|
||||
if (!relativePath || relativePath === "." || relativePath === ".." || relativePath.startsWith("../")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodesByRelativePath.set(normalizeRelativePath(relativePath), node);
|
||||
}
|
||||
|
||||
for (const expected of expectedEntries) {
|
||||
const normalizedExpectedPath = normalizeRelativePath(expected.path);
|
||||
const node = nodesByRelativePath.get(normalizedExpectedPath);
|
||||
if (!node) {
|
||||
throw new Error(`snapshot: missing ${expected.path}`);
|
||||
}
|
||||
|
||||
const actualType = normalizeEntryType(node.type);
|
||||
if (actualType !== expected.type) {
|
||||
throw new Error(`snapshot: ${expected.path} type mismatch: expected ${expected.type}, got ${actualType}`);
|
||||
}
|
||||
|
||||
assertMetadata(`snapshot: ${expected.path}`, expected, node);
|
||||
}
|
||||
}
|
||||
177
app/test/backend-integration/setup-target.sh
Executable file
177
app/test/backend-integration/setup-target.sh
Executable file
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_HOST="192.168.2.41"
|
||||
TARGET="root@$TARGET_HOST"
|
||||
FIXTURE_UID="1000"
|
||||
FIXTURE_GID="1000"
|
||||
|
||||
ARTIFACTS_DIR="$SCRIPT_DIR/artifacts/$TARGET_HOST"
|
||||
KEY_PATH="$ARTIFACTS_DIR/zerobyte-sftp-ed25519"
|
||||
KNOWN_HOSTS_PATH="$ARTIFACTS_DIR/known_hosts"
|
||||
CONFIG_PATH="$ARTIFACTS_DIR/config.generated.json"
|
||||
|
||||
SMB_PASSWORD_FILE="$ARTIFACTS_DIR/smb-password.txt"
|
||||
SFTP_PASSWORD_FILE="$ARTIFACTS_DIR/sftp-password.txt"
|
||||
WEBDAV_PASSWORD_FILE="$ARTIFACTS_DIR/webdav-password.txt"
|
||||
RESTIC_PASSWORD_FILE="$ARTIFACTS_DIR/restic-password.txt"
|
||||
|
||||
read_or_create_secret() {
|
||||
local file_path="$1"
|
||||
|
||||
if [[ -f "$file_path" ]]; then
|
||||
cat "$file_path"
|
||||
else
|
||||
openssl rand -hex 12 >"$file_path"
|
||||
chmod 600 "$file_path"
|
||||
cat "$file_path"
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$ARTIFACTS_DIR"
|
||||
chmod 700 "$ARTIFACTS_DIR"
|
||||
|
||||
SMB_PASSWORD="$(read_or_create_secret "$SMB_PASSWORD_FILE")"
|
||||
SFTP_PASSWORD="$(read_or_create_secret "$SFTP_PASSWORD_FILE")"
|
||||
WEBDAV_PASSWORD="$(read_or_create_secret "$WEBDAV_PASSWORD_FILE")"
|
||||
RESTIC_PASSWORD="$(read_or_create_secret "$RESTIC_PASSWORD_FILE")"
|
||||
|
||||
if [[ ! -f "$KEY_PATH" || ! -f "$KEY_PATH.pub" ]]; then
|
||||
ssh-keygen -q -t ed25519 -N "" -C "zerobyte-backend-integration@$TARGET_HOST" -f "$KEY_PATH"
|
||||
chmod 600 "$KEY_PATH"
|
||||
fi
|
||||
|
||||
PUBLIC_KEY_BASE64="$(base64 <"$KEY_PATH.pub" | tr -d '\n')"
|
||||
|
||||
ssh "$TARGET" bash -s -- "$FIXTURE_UID" "$FIXTURE_GID" "$SMB_PASSWORD" "$SFTP_PASSWORD" "$WEBDAV_PASSWORD" "$RESTIC_PASSWORD" "$PUBLIC_KEY_BASE64" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
fixture_uid="$1"
|
||||
fixture_gid="$2"
|
||||
smb_password="$3"
|
||||
sftp_password="$4"
|
||||
webdav_password="$5"
|
||||
restic_password="$6"
|
||||
public_key="$(printf '%s' "$7" | base64 -d)"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
write_file() {
|
||||
local file_path="$1"
|
||||
cat >"$file_path"
|
||||
}
|
||||
|
||||
apt-get update
|
||||
apt-get install -y apache2 apache2-utils nfs-kernel-server openssh-server restic rpcbind samba
|
||||
|
||||
id -u zerobyte-sftp >/dev/null 2>&1 || useradd --create-home --home-dir /home/zerobyte-sftp --shell /bin/bash zerobyte-sftp
|
||||
id -u zerobyte-smb >/dev/null 2>&1 || useradd --create-home --home-dir /home/zerobyte-smb --shell /bin/bash zerobyte-smb
|
||||
|
||||
install -d -m 0755 /srv/zerobyte-backend-integration/fixtures/case-a/docs
|
||||
printf 'hello from zerobyte integration\n' >/srv/zerobyte-backend-integration/fixtures/case-a/hello.txt
|
||||
printf 'fixture documentation\n' >/srv/zerobyte-backend-integration/fixtures/case-a/docs/readme.md
|
||||
chown -R "$fixture_uid:$fixture_gid" /srv/zerobyte-backend-integration/fixtures
|
||||
find /srv/zerobyte-backend-integration/fixtures -type d -exec chmod 0755 {} +
|
||||
find /srv/zerobyte-backend-integration/fixtures -type f -exec chmod 0644 {} +
|
||||
|
||||
install -d -o zerobyte-sftp -g zerobyte-sftp -m 0700 /srv/zerobyte-backend-integration/restic-repo
|
||||
install -d -o zerobyte-sftp -g zerobyte-sftp -m 0700 /home/zerobyte-sftp
|
||||
install -d -o zerobyte-sftp -g zerobyte-sftp -m 0700 /home/zerobyte-sftp/.ssh
|
||||
printf '%s\n' "$public_key" >/home/zerobyte-sftp/.ssh/authorized_keys
|
||||
chown zerobyte-sftp:zerobyte-sftp /home/zerobyte-sftp/.ssh/authorized_keys
|
||||
chmod 0600 /home/zerobyte-sftp/.ssh/authorized_keys
|
||||
|
||||
printf '%s\n%s\n' "$smb_password" "$smb_password" | smbpasswd -a -s zerobyte-smb >/dev/null
|
||||
smbpasswd -e zerobyte-smb >/dev/null
|
||||
printf 'zerobyte-sftp:%s\n' "$sftp_password" | chpasswd
|
||||
passwd -u zerobyte-sftp >/dev/null 2>&1 || true
|
||||
htpasswd -bc /etc/apache2/zerobyte-backend-integration.htpasswd zerobyte-webdav "$webdav_password" >/dev/null
|
||||
|
||||
if [[ ! -f /srv/zerobyte-backend-integration/restic-repo/config ]]; then
|
||||
password_file="$(mktemp)"
|
||||
printf '%s\n' "$restic_password" >"$password_file"
|
||||
chown zerobyte-sftp:zerobyte-sftp "$password_file"
|
||||
chmod 0600 "$password_file"
|
||||
su -s /bin/sh -c "restic init --repo /srv/zerobyte-backend-integration/restic-repo --password-file '$password_file'" zerobyte-sftp
|
||||
rm -f "$password_file"
|
||||
fi
|
||||
|
||||
write_file /etc/exports <<'EOF'
|
||||
/srv/zerobyte-backend-integration/fixtures *(ro,sync,no_subtree_check,insecure)
|
||||
EOF
|
||||
exportfs -ra
|
||||
systemctl unmask rpcbind rpcbind.socket >/dev/null 2>&1
|
||||
systemctl start rpcbind.socket
|
||||
systemctl start rpcbind
|
||||
systemctl start proc-fs-nfsd.mount
|
||||
systemctl restart nfs-kernel-server
|
||||
|
||||
write_file /etc/samba/smb.conf <<'EOF'
|
||||
[zerobyte-backend-integration]
|
||||
path = /srv/zerobyte-backend-integration/fixtures
|
||||
browseable = yes
|
||||
read only = yes
|
||||
guest ok = no
|
||||
valid users = zerobyte-smb
|
||||
EOF
|
||||
|
||||
install -d -o www-data -g www-data -m 0755 /var/lib/dav
|
||||
a2enmod dav dav_fs auth_basic >/dev/null
|
||||
printf 'ServerName localhost\n' >/etc/apache2/conf-available/zerobyte-backend-integration-servername.conf
|
||||
a2enconf zerobyte-backend-integration-servername >/dev/null
|
||||
write_file /etc/apache2/sites-available/zerobyte-backend-integration-dav.conf <<'EOF'
|
||||
Alias /zerobyte-backend-integration /srv/zerobyte-backend-integration/fixtures
|
||||
|
||||
DAVLockDB /var/lib/dav/lockdb
|
||||
|
||||
<Location /zerobyte-backend-integration>
|
||||
DAV On
|
||||
AuthType Basic
|
||||
AuthName "Zerobyte Backend Integration WebDAV"
|
||||
AuthUserFile /etc/apache2/zerobyte-backend-integration.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
|
||||
<Directory /srv/zerobyte-backend-integration/fixtures>
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
EOF
|
||||
a2ensite zerobyte-backend-integration-dav >/dev/null
|
||||
apache2ctl configtest
|
||||
|
||||
install -d -m 0755 /etc/ssh/sshd_config.d
|
||||
write_file /etc/ssh/sshd_config.d/zerobyte-backend-integration.conf <<'EOF'
|
||||
Match User zerobyte-sftp
|
||||
PasswordAuthentication yes
|
||||
PubkeyAuthentication yes
|
||||
PermitTTY no
|
||||
X11Forwarding no
|
||||
AllowTcpForwarding no
|
||||
ForceCommand internal-sftp
|
||||
EOF
|
||||
sshd -t
|
||||
|
||||
systemctl restart apache2
|
||||
systemctl restart smbd
|
||||
systemctl restart ssh
|
||||
REMOTE
|
||||
|
||||
ssh-keyscan "$TARGET_HOST" >"$KNOWN_HOSTS_PATH" 2>/dev/null
|
||||
|
||||
INTEGRATION_HOST="$TARGET_HOST" \
|
||||
FIXTURE_UID="$FIXTURE_UID" \
|
||||
FIXTURE_GID="$FIXTURE_GID" \
|
||||
SMB_PASSWORD="$SMB_PASSWORD" \
|
||||
WEBDAV_PASSWORD="$WEBDAV_PASSWORD" \
|
||||
RESTIC_PASSWORD="$RESTIC_PASSWORD" \
|
||||
SFTP_KEY_PATH="$KEY_PATH" \
|
||||
KNOWN_HOSTS_PATH="$KNOWN_HOSTS_PATH" \
|
||||
CONFIG_PATH="$CONFIG_PATH" \
|
||||
bun run "$SCRIPT_DIR/write-generated-config.ts"
|
||||
|
||||
echo "Provisioned $TARGET"
|
||||
echo "Generated config: $CONFIG_PATH"
|
||||
echo "Artifacts: $ARTIFACTS_DIR"
|
||||
130
app/test/backend-integration/write-generated-config.ts
Normal file
130
app/test/backend-integration/write-generated-config.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import fs from "node:fs";
|
||||
|
||||
function getRequiredEnv(name: string) {
|
||||
return process.env[name]!;
|
||||
}
|
||||
|
||||
function getRequiredNumberEnv(name: string) {
|
||||
return Number.parseInt(getRequiredEnv(name), 10);
|
||||
}
|
||||
|
||||
const host = getRequiredEnv("INTEGRATION_HOST");
|
||||
const fixtureUid = getRequiredNumberEnv("FIXTURE_UID");
|
||||
const fixtureGid = getRequiredNumberEnv("FIXTURE_GID");
|
||||
const smbPassword = getRequiredEnv("SMB_PASSWORD");
|
||||
const webdavPassword = getRequiredEnv("WEBDAV_PASSWORD");
|
||||
const resticPassword = getRequiredEnv("RESTIC_PASSWORD");
|
||||
const privateKey = fs.readFileSync(getRequiredEnv("SFTP_KEY_PATH"), "utf8");
|
||||
const knownHosts = fs.readFileSync(getRequiredEnv("KNOWN_HOSTS_PATH"), "utf8");
|
||||
const configPath = getRequiredEnv("CONFIG_PATH");
|
||||
|
||||
const fileText = "hello from zerobyte integration\n";
|
||||
const readmeText = "fixture documentation\n";
|
||||
|
||||
const nfsEntries = [
|
||||
{ path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText },
|
||||
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "0755" },
|
||||
{ path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText },
|
||||
];
|
||||
|
||||
const contentOnlyEntries = [
|
||||
{ path: "hello.txt", type: "file", text: fileText },
|
||||
{ path: "docs", type: "directory" },
|
||||
{ path: "docs/readme.md", type: "file", text: readmeText },
|
||||
];
|
||||
|
||||
const config = {
|
||||
version: 1,
|
||||
scenarios: [
|
||||
{
|
||||
id: "nfs-local-repo",
|
||||
volume: {
|
||||
backend: "nfs",
|
||||
server: host,
|
||||
exportPath: "/srv/zerobyte-backend-integration/fixtures",
|
||||
port: 2049,
|
||||
version: "4.1",
|
||||
readOnly: true,
|
||||
},
|
||||
repository: { backend: "local", path: "repo-nfs" },
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: nfsEntries,
|
||||
},
|
||||
{
|
||||
id: "smb-local-repo",
|
||||
volume: {
|
||||
backend: "smb",
|
||||
server: host,
|
||||
share: "zerobyte-backend-integration",
|
||||
username: "zerobyte-smb",
|
||||
password: smbPassword,
|
||||
vers: "3.0",
|
||||
port: 445,
|
||||
readOnly: true,
|
||||
},
|
||||
repository: { backend: "local", path: "repo-smb" },
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: nfsEntries,
|
||||
},
|
||||
{
|
||||
id: "sftp-local-repo",
|
||||
volume: {
|
||||
backend: "sftp",
|
||||
host,
|
||||
port: 22,
|
||||
username: "zerobyte-sftp",
|
||||
privateKey,
|
||||
path: "/srv/zerobyte-backend-integration/fixtures",
|
||||
readOnly: true,
|
||||
skipHostKeyCheck: false,
|
||||
knownHosts,
|
||||
},
|
||||
repository: { backend: "local", path: "repo-sftp-volume" },
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: contentOnlyEntries,
|
||||
},
|
||||
{
|
||||
id: "webdav-local-repo",
|
||||
volume: {
|
||||
backend: "webdav",
|
||||
server: host,
|
||||
path: "/zerobyte-backend-integration",
|
||||
username: "zerobyte-webdav",
|
||||
password: webdavPassword,
|
||||
port: 80,
|
||||
readOnly: true,
|
||||
ssl: false,
|
||||
},
|
||||
repository: { backend: "local", path: "repo-webdav" },
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: contentOnlyEntries,
|
||||
},
|
||||
{
|
||||
id: "nfs-sftp-repository",
|
||||
volume: {
|
||||
backend: "nfs",
|
||||
server: host,
|
||||
exportPath: "/srv/zerobyte-backend-integration/fixtures",
|
||||
port: 2049,
|
||||
version: "4.1",
|
||||
readOnly: true,
|
||||
},
|
||||
repository: {
|
||||
backend: "sftp",
|
||||
host,
|
||||
port: 22,
|
||||
user: "zerobyte-sftp",
|
||||
path: "/srv/zerobyte-backend-integration/restic-repo",
|
||||
privateKey,
|
||||
skipHostKeyCheck: false,
|
||||
knownHosts,
|
||||
isExistingRepository: true,
|
||||
customPassword: resticPassword,
|
||||
},
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: nfsEntries,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
|
|
@ -13,7 +13,9 @@
|
|||
"check": "vp check",
|
||||
"dev": "NODE_ENV=development bunx --bun vite",
|
||||
"build": "bunx --bun vite build",
|
||||
"build:backend-integration": "bun build app/test/backend-integration/index.ts --outdir .output/backend-integration --target bun",
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"test:integration:backends": "bash app/test/backend-integration/run.sh",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"cli:dev": "bun run app/server/cli/main.ts",
|
||||
"cli": "ZEROBYTE_CLI=1 bun .output/server/_ssr/ssr.mjs",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const safeExec = async ({ command, args = [], env = {}, ...rest }: ExecPr
|
|||
const { stdout, stderr } = await promisify(execFile)(command, args, {
|
||||
...options,
|
||||
...rest,
|
||||
shell: false,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
|
|
@ -78,6 +79,7 @@ export function safeSpawn(params: SafeSpawnParams): Promise<SpawnResult> {
|
|||
return new Promise<SpawnResult>((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
env: { ...process.env, ...env },
|
||||
shell: false,
|
||||
signal: signal,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue