c5d678a37a
Every spec drives the real sshd container set up by the ssh-test-base fixture; no mocks. Specs are grouped by concern (file per group), mirroring the docker/ directory style. Each one maps 1:1 to a row in docs/specs/ssh-executor/e2e-plan.md. UI surface: - connection-form.spec.ts — field rendering, save/test gating, identity-source toggle, default port. - test-result.spec.ts — successful + failed Test Connection paths, per-step badges, fingerprint surfacing, cached/will-upload copy. - trust-gate.spec.ts — save disabled until trust ticked, edits reset result + trust, fingerprint-change warning when pinned != observed. - sessions-card.spec.ts — empty state, row rendering, manual refresh, status badges, truncation, polling pickup. - persistence.spec.ts — fingerprint persists across reload, new form starts blank, executor list label, partial state doesn't leak. API contract: - test-endpoint.spec.ts — POST /api/v1/ssh/test step ordering, invalid body, host-required, handshake short-circuit, cached/uploaded states. - sessions-endpoint.spec.ts — GET /api/v1/ssh/executors/:id/sessions filters to ssh-runtime rows, populates metadata, monotonic uptime. - executor-crud.spec.ts — type=ssh persists fingerprint, profile workdir round-trip, edit-with-running-sessions keeps snapshots. Real-SSH integration: - launch-task.spec.ts — full smoke: agentctl upload + sha256 cache hit on second launch, task/session dir layout, stop cleans session dir. - recovery.spec.ts — backend restart reconnects, dead pid surfaces failure, persisted metadata keys present. - auth-methods.spec.ts — key + valid, missing key, passphrase-protected rejection, agent without SSH_AUTH_SOCK, wrong-key auth failure. - concurrency.spec.ts — two sessions share task dir distinct ports, two tasks distinct dirs, traffic drop + restore exercises eviction + reconnect. - hostkey-rotation.spec.ts — regenerate host key surfaces new fingerprint, stale pinned fingerprint blocks launch verbatim, re-trust restores function. - config-inheritance.spec.ts — ~/.ssh/config HostName / Port / IdentityFile / ProxyJump inheritance with form-overrides-config rules. - workdir-per-profile.spec.ts — per-profile ssh_workdir_root routes to separate remote workdir trees; default falls back to ~/.kandev. - proxy-jump.spec.ts — 2-container bastion topology asserts target is unreachable directly, reachable via ProxyJump, chained ProxyJump fails. - error-surfacing.spec.ts — TCP refused, auth failed, bastion unreachable surface verbatim; backend 400 stays a 400 not a synthesised failure. Also adds an AGENTS.md (CLAUDE.md symlink) line pointing at the e2e/README.md so future agents discover the containers project + KANDEV_E2E_CONTAINERS env var without grepping playwright.config.ts.
199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
import { test, expect } from "../../fixtures/ssh-test-base";
|
||
import { listRemoteDir, readRemoteFile, remotePathExists } from "../../helpers/ssh";
|
||
import { waitForLatestSessionDone } from "../../helpers/session";
|
||
|
||
/**
|
||
* Full end-to-end task launch on the real sshd container. The smoke test
|
||
* for the SSH executor: upload agentctl, mkdir the per-task dir, clone the
|
||
* repo, launch the per-session agentctl, port-forward, run the agent to
|
||
* completion, observe the on-remote filesystem layout.
|
||
*
|
||
* Covers e2e-plan.md group H (H1–H8).
|
||
*/
|
||
test.describe("ssh executor — task launch", () => {
|
||
test("launches a session and records ssh runtime on the task environment", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
test.setTimeout(180_000);
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H1 launch",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
|
||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for first SSH session");
|
||
|
||
const env = await apiClient.getTaskEnvironment(task.id);
|
||
expect(env).not.toBeNull();
|
||
expect(env!.executor_type).toBe("ssh");
|
||
});
|
||
|
||
test("agentctl is uploaded on first launch and sha256 sidecar lands", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
test.setTimeout(180_000);
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H2 upload agentctl",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for upload");
|
||
|
||
expect(remotePathExists(seedData.sshTarget, "/home/kandev/.kandev/bin/agentctl")).toBe(true);
|
||
expect(remotePathExists(seedData.sshTarget, "/home/kandev/.kandev/bin/agentctl.sha256")).toBe(
|
||
true,
|
||
);
|
||
const sha = readRemoteFile(seedData.sshTarget, "/home/kandev/.kandev/bin/agentctl.sha256");
|
||
expect(sha.trim()).toMatch(/^[0-9a-f]{64}$/);
|
||
});
|
||
|
||
test("second launch on the same host skips the upload (sha matches)", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
test.setTimeout(240_000);
|
||
// First launch primes the cache.
|
||
const first = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H3 first launch",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
await waitForLatestSessionDone(apiClient, first.id, 1, "First launch");
|
||
|
||
// Inspect mtime BEFORE the second launch.
|
||
const beforeMtime = readRemoteFile(
|
||
seedData.sshTarget,
|
||
"/home/kandev/.kandev/bin/agentctl.sha256",
|
||
);
|
||
|
||
const second = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H3 second launch",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
await waitForLatestSessionDone(apiClient, second.id, 1, "Second launch");
|
||
|
||
// The sha256 file should be byte-identical (same hash, content reused).
|
||
const afterMtime = readRemoteFile(
|
||
seedData.sshTarget,
|
||
"/home/kandev/.kandev/bin/agentctl.sha256",
|
||
);
|
||
expect(afterMtime).toBe(beforeMtime);
|
||
});
|
||
|
||
test("per-task workdir lives at <workdir>/tasks/<task-dir>/", async ({ apiClient, seedData }) => {
|
||
test.setTimeout(180_000);
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H4 task workdir",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for workdir");
|
||
|
||
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
const row = sessions.find((s) => s.task_id === task.id);
|
||
expect(row?.remote_task_dir).toMatch(/\/tasks\/[^/]+$/);
|
||
expect(remotePathExists(seedData.sshTarget, row!.remote_task_dir!)).toBe(true);
|
||
});
|
||
|
||
test("per-session runtime dir holds the agentctl pid and port files", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
test.setTimeout(180_000);
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H5 session runtime",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for session runtime");
|
||
|
||
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
const row = sessions.find((s) => s.task_id === task.id);
|
||
expect(row).toBeDefined();
|
||
|
||
const sessionDir = `${row!.remote_task_dir}/.kandev/sessions/${row!.session_id}`;
|
||
expect(remotePathExists(seedData.sshTarget, sessionDir)).toBe(true);
|
||
const entries = listRemoteDir(seedData.sshTarget, sessionDir);
|
||
expect(entries).toEqual(expect.arrayContaining(["agentctl.pid", "agentctl.port"]));
|
||
});
|
||
|
||
test("stopping the session cleans up the session runtime dir but leaves the task dir", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
test.setTimeout(180_000);
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"H7 stop cleanup",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait before stop");
|
||
|
||
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
const row = sessions.find((s) => s.task_id === task.id);
|
||
expect(row).toBeDefined();
|
||
const sessionDir = `${row!.remote_task_dir}/.kandev/sessions/${row!.session_id}`;
|
||
|
||
// Archive (or otherwise end) the task to trigger StopInstance.
|
||
await apiClient.archiveTask(task.id);
|
||
|
||
await expect
|
||
.poll(() => remotePathExists(seedData.sshTarget, sessionDir), { timeout: 30_000 })
|
||
.toBe(false);
|
||
// Task dir intact — v1 spec, no auto-clean on last session stop.
|
||
expect(remotePathExists(seedData.sshTarget, row!.remote_task_dir!)).toBe(true);
|
||
});
|
||
});
|