open-design/deploy/tests/install.test.ts
epic e8b5dd8aaf
feat(deploy): add one-click Docker/Podman Compose installer for Linux… (#2414)
* feat(deploy): add one-click Docker/Podman Compose installer for Linux and macOS

- Add install.sh with interactive wizard, Podman/Docker runtime detection,
  port conflict check, health verification, and systemd user unit creation
- Add update.sh for image pull and restart with health check
- Add uninstall.sh with interactive user data backup before removal
- Unify CLI output styling with step/ok/warn/error/info helpers
- Add install-guide.md documentation
- Add install.test.ts integration test suite

* feat(deploy): add one-click Docker/Podman Compose installer

- interactive setup wizard with port, image, CORS, memory prompts
- automatic Docker/Podman detection with install guidance
- systemd user unit for Linux, health check polling
- update.sh (pull + restart + prune) and uninstall.sh (backup + cleanup)
- node:test integration suite and install-guide.md

* style(deploy): improve POSIX sh compatibility and systemd unit handling

- unify shell shebangs to #!/usr/bin/env bash

- add pipefail option for better error handling

- fix systemd unit for Podman: remove After/Requires when no service

- correct documentation to match actual uninstall behavior

* fix(deploy): address review feedback for installer scripts

- remove curl | sh path, document clone-first only

- isolate tests via docker-compose.override.yml with unique names

- support both --image <ref> and --image=<ref> in update.sh

- add running container detection before install

* docs(install): remove demo scripts and add MCP note
2026-05-22 14:04:16 +08:00

222 lines
6.8 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, '../..');
const installScript = join(repoRoot, 'deploy/scripts/install.sh');
const uninstallScript = join(repoRoot, 'deploy/scripts/uninstall.sh');
const updateScript = join(repoRoot, 'deploy/scripts/update.sh');
// Skip entire suite if Docker is not available
function isDockerAvailable(): Promise<boolean> {
return new Promise((resolve) => {
execFile('docker', ['info'], { timeout: 5000 }, (err) => resolve(!err));
});
}
const dockerAvailable = await isDockerAvailable();
// Unique test identifier to isolate from real deployments
const TEST_ID = `od-test-${process.pid}`;
async function waitForHealth(port: number, timeoutMs = 30000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(`http://127.0.0.1:${port}/api/health`);
if (resp.ok) return true;
} catch {}
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
interface TestContext {
tmpDir: string;
port: number;
projectName: string;
containerName: string;
volumeName: string;
}
async function setupTestDir(port: number): Promise<TestContext> {
const projectName = `${TEST_ID}-${port}`;
const containerName = projectName;
const volumeName = `${projectName}-data`;
const tmpDir = await mkdtemp(join(tmpdir(), `${TEST_ID}-`));
await execFileAsync('cp', ['-r', join(repoRoot, 'deploy/.'), tmpDir]);
// Write a compose override that replaces the hardcoded names
const override = {
name: projectName,
services: {
'open-design': {
container_name: containerName,
volumes: [`${volumeName}:/app/.od`],
},
},
volumes: {
[volumeName]: {},
},
};
await writeFile(
join(tmpDir, 'docker-compose.override.yml'),
JSON.stringify(override),
);
return { tmpDir, port, projectName, containerName, volumeName };
}
function testEnv(ctx: TestContext): Record<string, string> {
return {
...process.env as Record<string, string>,
COMPOSE_PROJECT_NAME: ctx.projectName,
};
}
async function teardownTestDir(ctx: TestContext): Promise<void> {
const script = join(ctx.tmpDir, 'scripts/uninstall.sh');
const override = join(ctx.tmpDir, 'docker-compose.override.yml');
// Run uninstall with the same override file so it targets the test container
try {
await readFile(override);
await execFileAsync('bash', [script, '--non-interactive'], {
timeout: 60_000,
env: testEnv(ctx),
});
} catch {}
// Force-remove the named volume as a safety net
try {
await execFileAsync('docker', ['volume', 'rm', '-f', ctx.volumeName], {
timeout: 10_000,
});
} catch {}
if (ctx.tmpDir) await rm(ctx.tmpDir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// help flag tests — do not require Docker
// ---------------------------------------------------------------------------
test('install.sh --help exits 0', async () => {
const { stdout } = await execFileAsync('bash', [installScript, '--help']);
assert.match(stdout, /Usage/);
assert.match(stdout, /--non-interactive/);
assert.match(stdout, /--port/);
});
test('uninstall.sh --help exits 0', async () => {
const { stdout } = await execFileAsync('bash', [uninstallScript, '--help']);
assert.match(stdout, /Usage/);
assert.match(stdout, /--keep-data/);
});
test('update.sh --help exits 0', async () => {
const { stdout } = await execFileAsync('bash', [updateScript, '--help']);
assert.match(stdout, /Usage/);
assert.match(stdout, /--image/);
});
// ---------------------------------------------------------------------------
// Docker integration tests — skipped when Docker is unavailable
// ---------------------------------------------------------------------------
test('install.sh --non-interactive creates .env and starts container', { skip: !dockerAvailable ? 'Docker not available' : false }, async () => {
const ctx = await setupTestDir(17456);
try {
const script = join(ctx.tmpDir, 'scripts/install.sh');
await execFileAsync('bash', [
script,
'--non-interactive',
`--port=${ctx.port}`,
'--no-systemd',
], {
timeout: 120_000,
env: testEnv(ctx),
});
// .env should contain the port
const envContent = await readFile(join(ctx.tmpDir, '.env'), 'utf8');
assert.match(envContent, new RegExp(`OPEN_DESIGN_PORT=${ctx.port}`));
// Container should be healthy
const healthy = await waitForHealth(ctx.port, 60_000);
assert.ok(healthy, 'daemon did not become healthy within 60s');
} finally {
await teardownTestDir(ctx);
}
});
test('update.sh restarts service and remains healthy', { skip: !dockerAvailable ? 'Docker not available' : false }, async () => {
const ctx = await setupTestDir(17457);
try {
const installRun = join(ctx.tmpDir, 'scripts/install.sh');
await execFileAsync('bash', [
installRun,
'--non-interactive',
`--port=${ctx.port}`,
'--no-systemd',
], {
timeout: 120_000,
env: testEnv(ctx),
});
await waitForHealth(ctx.port, 30_000);
// Update
await execFileAsync('bash', [
join(ctx.tmpDir, 'scripts/update.sh'),
], {
timeout: 120_000,
cwd: ctx.tmpDir,
env: testEnv(ctx),
});
const healthy = await waitForHealth(ctx.port, 30_000);
assert.ok(healthy, 'daemon not healthy after update');
} finally {
await teardownTestDir(ctx);
}
});
test('uninstall.sh removes containers and .env', { skip: !dockerAvailable ? 'Docker not available' : false }, async () => {
const ctx = await setupTestDir(17458);
try {
const installRun = join(ctx.tmpDir, 'scripts/install.sh');
await execFileAsync('bash', [
installRun,
'--non-interactive',
`--port=${ctx.port}`,
'--no-systemd',
], {
timeout: 120_000,
env: testEnv(ctx),
});
// Uninstall
await execFileAsync('bash', [
join(ctx.tmpDir, 'scripts/uninstall.sh'),
'--non-interactive',
], {
timeout: 60_000,
env: testEnv(ctx),
});
// .env should be gone
const envGone = await readFile(join(ctx.tmpDir, '.env'), 'utf8').catch(() => null);
assert.equal(envGone, null, '.env should have been removed');
// Container should not be running
const { stdout: containers } = await execFileAsync('docker', ['ps', '--format', '{{.Names}}']);
assert.ok(!containers.includes(ctx.containerName), 'container should not be running after uninstall');
} finally {
await teardownTestDir(ctx);
}
});