Files
kandev/apps/web/e2e/tests/ssh/launch-task.spec.ts
T
José Almeida c5d678a37a test(e2e): add SSH executor Playwright suite (17 specs, ~75 cases)
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.
2026-05-17 23:45:07 +01:00

199 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});