fix: unify executor profile editor routes (#3740) (#3762)

This commit is contained in:
Carlos Florêncio
2026-09-17 15:24:36 +01:00
committed by GitHub
parent 25d4a977f4
commit c277822ac1
26 changed files with 1117 additions and 657 deletions
@@ -1,565 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { SettingsPageHeader, SettingsFieldLabel } from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { serializeSettingsRevision } from "@/components/settings/settings-save-revision";
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
import {
updateExecutorProfile,
deleteExecutorProfile,
listScriptPlaceholders,
} from "@/lib/api/domains/settings-api";
import type { ScriptPlaceholder } from "@/lib/api/domains/settings-api";
import {
upsertExecutorProfile,
type SaveStatus,
} from "@/components/settings/profile-edit/profile-edit-page-chrome";
import {
SpritesConnectionCard,
SpritesInstancesCard,
} from "@/components/settings/sprites-settings";
import { ScriptCard } from "@/components/settings/profile-edit/script-card";
import type { Executor, ExecutorProfile, ProfileEnvVar } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
type EnvVarRow = {
key: string;
mode: "value" | "secret";
value: string;
secretId: string;
};
function envVarsToRows(envVars?: ProfileEnvVar[]): EnvVarRow[] {
if (!envVars || envVars.length === 0) return [];
return envVars.map((ev) => ({
key: ev.key,
mode: ev.secret_id ? "secret" : "value",
value: ev.value ?? "",
secretId: ev.secret_id ?? "",
}));
}
function rowsToEnvVars(rows: EnvVarRow[]): ProfileEnvVar[] {
return rows
.filter((r) => r.key.trim())
.map((r) => {
if (r.mode === "secret" && r.secretId) {
return { key: r.key.trim(), secret_id: r.secretId };
}
return { key: r.key.trim(), value: r.value };
});
}
export default function ProfileDetailPage({
executorId,
profileId,
}: {
executorId: string;
profileId: string;
}) {
const { t } = useTranslation();
const router = useRouter();
const executor = useAppStore(
(state) => state.executors.items.find((e: Executor) => e.id === executorId) ?? null,
);
const profile = executor?.profiles?.find((p: ExecutorProfile) => p.id === profileId) ?? null;
if (!executor || !profile) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
<Button
className="mt-4 cursor-pointer"
onClick={() => router.push(`/settings/executor/${executorId}`)}
>
{t("executors:backToExecutor")}
</Button>
</CardContent>
</Card>
);
}
return <ProfileEditForm key={profile.id} executor={executor} profile={profile} />;
}
function ProfileDetailsCard({
name,
baselineName,
onNameChange,
}: {
name: string;
baselineName: string;
onNameChange: (v: string) => void;
}) {
const { t } = useTranslation();
const isDirty = name.trim() !== baselineName.trim();
return (
<SettingsCard isDirty={isDirty}>
<SettingsCardHeader title={t("executors:profileDetails")} />
<CardContent className="space-y-4">
<div className="space-y-2">
<SettingsFieldLabel htmlFor="profile-name">{t("executors:name")}</SettingsFieldLabel>
<Input
id="profile-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
data-settings-dirty={isDirty}
/>
</div>
</CardContent>
</SettingsCard>
);
}
function EnvVarRow({
row,
index,
secrets,
onUpdate,
onRemove,
baselineRow,
}: {
row: EnvVarRow;
index: number;
secrets: { id: string; name: string }[];
onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void;
onRemove: (index: number) => void;
baselineRow?: EnvVarRow;
}) {
const { t } = useTranslation();
const isDirty = !baselineRow || JSON.stringify(row) !== JSON.stringify(baselineRow);
return (
<div
className="flex items-start gap-2"
data-settings-dirty={isDirty}
data-settings-dirty-level="container"
>
<Input
value={row.key}
onChange={(e) => onUpdate(index, "key", e.target.value)}
placeholder="KEY"
className="font-mono text-xs flex-[2]"
data-settings-dirty={!baselineRow || row.key !== baselineRow.key}
/>
<Select value={row.mode} onValueChange={(v) => onUpdate(index, "mode", v)}>
<SelectTrigger
className="w-[100px] text-xs"
data-settings-dirty={!baselineRow || row.mode !== baselineRow.mode}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="value">{t("executors:value")}</SelectItem>
<SelectItem value="secret">{t("executors:secret")}</SelectItem>
</SelectContent>
</Select>
{row.mode === "value" ? (
<Input
value={row.value}
onChange={(e) => onUpdate(index, "value", e.target.value)}
placeholder="value"
className="font-mono text-xs flex-[3]"
data-settings-dirty={!baselineRow || row.value !== baselineRow.value}
/>
) : (
<Select value={row.secretId} onValueChange={(v) => onUpdate(index, "secretId", v)}>
<SelectTrigger
className="flex-[3] text-xs"
data-settings-dirty={!baselineRow || row.secretId !== baselineRow.secretId}
>
<SelectValue placeholder={t("executors:selectSecret")} />
</SelectTrigger>
<SelectContent>
{secrets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="cursor-pointer shrink-0"
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
);
}
function EnvVarsCard({
rows,
baselineRows,
secrets,
onAdd,
onUpdate,
onRemove,
}: {
rows: EnvVarRow[];
baselineRows: EnvVarRow[];
secrets: { id: string; name: string }[];
onAdd: () => void;
onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void;
onRemove: (index: number) => void;
}) {
const { t } = useTranslation();
const isDirty =
JSON.stringify(rowsToEnvVars(rows)) !== JSON.stringify(rowsToEnvVars(baselineRows));
return (
<SettingsCard isDirty={isDirty}>
<SettingsCardHeader
title={t("executors:environmentVariables")}
description={t("executors:injectedIntoTheExecutionEnvironmentVariables")}
actions={
<Button
type="button"
variant="outline"
onClick={onAdd}
className={settingsActionClassName("cursor-pointer")}
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
{t("executors:add")}
</Button>
}
/>
<CardContent className="space-y-3">
{rows.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("executors:noEnvironmentVariablesConfigured")}
</p>
)}
{rows.map((row, idx) => (
<EnvVarRow
key={idx}
row={row}
baselineRow={baselineRows[idx]}
index={idx}
secrets={secrets}
onUpdate={onUpdate}
onRemove={onRemove}
/>
))}
</CardContent>
</SettingsCard>
);
}
function ProfileActions({
executorId,
onRequestDelete,
}: {
executorId: string;
onRequestDelete: () => void;
}) {
const { t } = useTranslation();
const router = useRouter();
return (
<div className="flex items-center justify-between">
<Button
variant="destructive"
onClick={onRequestDelete}
className={settingsActionClassName("cursor-pointer")}
>
<IconTrash className="h-4 w-4 mr-1" />
{t("executors:deleteProfile")}
</Button>
<Button
variant="outline"
onClick={() => router.push(`/settings/executor/${executorId}`)}
className="cursor-pointer"
>
{t("common:cancel")}
</Button>
</div>
);
}
function DeleteProfileDialog({
open,
onOpenChange,
onDelete,
deleting,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onDelete: () => void;
deleting: boolean;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("executors:deleteProfile")}</DialogTitle>
<DialogDescription>{t("executors:areYouSureYouWantTo")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common:cancel")}
</Button>
<Button variant="destructive" onClick={onDelete} disabled={deleting}>
{deleting ? t("executors:deleting") : t("executors:delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
const { t } = useTranslation();
const router = useRouter();
const { toast } = useToast();
const executors = useAppStore((state) => state.executors.items);
const setExecutors = useAppStore((state) => state.setExecutors);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const save = useCallback(
async (data: {
name: string;
prepare_script: string;
cleanup_script: string;
env_vars: ProfileEnvVar[];
}) => {
setSaveStatus("loading");
setError(null);
try {
const updated = await updateExecutorProfile(executor.id, profile.id, data);
setSaveStatus("success");
toast({ title: t("executors:profileSaved"), variant: "success" });
setExecutors(upsertExecutorProfile(executors, executor, updated));
window.setTimeout(() => setSaveStatus("idle"), 1500);
} catch (err) {
const message = err instanceof Error ? err.message : t("executors:failedToSaveProfile");
setError(message);
setSaveStatus("error");
toast({
title: t("executors:failedToSaveProfile"),
description: message,
variant: "error",
});
throw err;
}
},
[executor, profile.id, executors, setExecutors, toast],
);
const remove = useCallback(async () => {
setDeleting(true);
try {
await deleteExecutorProfile(executor.id, profile.id);
setExecutors(
executors.map((e: Executor) =>
e.id === executor.id
? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) }
: e,
),
);
runWithNavigationBlockerBypassed(() => router.push(`/settings/executor/${executor.id}`));
} catch {
setDeleting(false);
setDeleteDialogOpen(false);
}
}, [executor.id, profile.id, executors, setExecutors, router]);
return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove };
}
function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
const { t } = useTranslation();
const [name, setName] = useState(profile.name);
const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? "");
const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? "");
const [envVarRows, setEnvVarRows] = useState<EnvVarRow[]>(() => envVarsToRows(profile.env_vars));
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
const isRemote =
executor.type === "sprites" ||
executor.type === "local_docker" ||
executor.type === "remote_docker";
const isSprites = executor.type === "sprites";
const spritesSecretId = useMemo(() => {
const tokenVar = envVarRows.find((r) => r.key === "SPRITES_API_TOKEN" && r.mode === "secret");
return tokenVar?.secretId;
}, [envVarRows]);
useEffect(() => {
listScriptPlaceholders()
.then((res) => setPlaceholders(res.placeholders ?? []))
.catch(() => {});
}, []);
const addEnvVar = useCallback(() => {
setEnvVarRows((prev) => [...prev, { key: "", mode: "value", value: "", secretId: "" }]);
}, []);
const removeEnvVar = useCallback((index: number) => {
setEnvVarRows((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateEnvVar = useCallback((index: number, field: keyof EnvVarRow, val: string) => {
setEnvVarRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: val } : row)));
}, []);
// `{{` is the editor's placeholder-completion trigger, not an interpolation
// token, so it is interpolated as a value rather than written into the
// catalog — i18next would otherwise try to resolve it.
const prepareDesc = isRemote
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
: t("executors:prepareScriptDescriptionLocal");
return {
name,
setName,
prepareScript,
setPrepareScript,
cleanupScript,
setCleanupScript,
envVarRows,
addEnvVar,
removeEnvVar,
updateEnvVar,
placeholders,
isRemote,
isSprites,
spritesSecretId,
prepareDesc,
};
}
function ProfileEditHeader({ executor, profileName }: { executor: Executor; profileName: string }) {
const { t } = useTranslation();
const router = useRouter();
return (
<SettingsPageHeader
title={profileName}
description={t("executors:profileForExecutor", { name: executor.name })}
actions={
<Button
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={() => router.push(`/settings/executor/${executor.id}`)}
>
{t("executors:backToExecutor")}
</Button>
}
/>
);
}
function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) {
const { t } = useTranslation();
const { items: secrets } = useSecrets();
const persistence = useProfilePersistence(executor, profile);
const form = useProfileFormState(executor, profile);
const savePayload = {
name: form.name.trim(),
prepare_script: form.prepareScript,
cleanup_script: form.cleanupScript,
env_vars: rowsToEnvVars(form.envVarRows),
};
const saveRevision = serializeSettingsRevision(savePayload);
const [savedRevision, setSavedRevision] = useState(saveRevision);
const handleSave = async () => {
await persistence.save(savePayload);
setSavedRevision(saveRevision);
};
useSettingsSaveContributor({
id: `legacy-executor-profile:${profile.id}`,
revision: saveRevision,
isDirty: saveRevision !== savedRevision,
canSave: Boolean(form.name.trim()),
invalidReason: form.name.trim() ? undefined : t("executors:profileNameIsRequired"),
save: handleSave,
discard: () => undefined,
});
return (
<div className="space-y-8">
<ProfileEditHeader executor={executor} profileName={profile.name} />
<Separator />
<ProfileDetailsCard
name={form.name}
baselineName={profile.name}
onNameChange={form.setName}
/>
{form.isSprites && form.spritesSecretId && (
<>
<SpritesConnectionCard secretId={form.spritesSecretId} />
<SpritesInstancesCard secretId={form.spritesSecretId} />
</>
)}
<EnvVarsCard
rows={form.envVarRows}
baselineRows={envVarsToRows(profile.env_vars)}
secrets={secrets}
onAdd={form.addEnvVar}
onUpdate={form.updateEnvVar}
onRemove={form.removeEnvVar}
/>
<ScriptCard
title={t("executors:prepareScript")}
description={form.prepareDesc}
value={form.prepareScript}
baselineValue={profile.prepare_script ?? ""}
onChange={form.setPrepareScript}
height="300px"
placeholders={form.placeholders}
executorType={executor.type}
/>
{form.isRemote && (
<ScriptCard
title={t("executors:cleanupScript")}
description={t("executors:runsAfterTheAgentSessionEnds")}
value={form.cleanupScript}
baselineValue={profile.cleanup_script ?? ""}
onChange={form.setCleanupScript}
height="200px"
placeholders={form.placeholders}
executorType={executor.type}
/>
)}
{persistence.error && <p className="text-sm text-destructive">{persistence.error}</p>}
<ProfileActions
executorId={executor.id}
onRequestDelete={() => persistence.setDeleteDialogOpen(true)}
/>
<DeleteProfileDialog
open={persistence.deleteDialogOpen}
onOpenChange={persistence.setDeleteDialogOpen}
onDelete={persistence.remove}
deleting={persistence.deleting}
/>
</div>
);
}
@@ -58,11 +58,7 @@ export default function KubernetesExecutorPage({ executorId }: { executorId: str
)?.profiles?.[0]?.id ?? null,
);
if (profileId) {
return (
<SettingsRedirect
to={executorProfileSettingsPath({ id: executorId, type: "k8s" }, profileId)}
/>
);
return <SettingsRedirect to={executorProfileSettingsPath(profileId)} />;
}
return <KubernetesExecutorRecoveryPage executorId={executorId} />;
}
@@ -71,7 +71,7 @@ export function KubernetesCreatePage() {
try {
const created = await resource.create(payload);
runWithNavigationBlockerBypassed(() =>
router.push(executorProfileSettingsPath(created.executor, created.profile.id)),
router.push(executorProfileSettingsPath(created.profile.id)),
);
} catch (cause) {
setError(cause);
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { Badge } from "@kandev/ui/badge";
@@ -407,7 +408,9 @@ function useCreateProfileSave(executorId: string) {
e.id === executorId ? { ...e, profiles: [...(e.profiles ?? []), profile] } : e,
),
);
runWithNavigationBlockerBypassed(() => router.push(`/settings/executors/${profile.id}`));
runWithNavigationBlockerBypassed(() =>
router.push(executorProfileSettingsPath(profile.id)),
);
} catch (err) {
setError(err instanceof Error ? err.message : t("executors:failedToCreateProfile"));
throw err;
@@ -2,6 +2,7 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
import { useRouter } from "@/lib/routing/client-router";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -68,7 +69,7 @@ export function SSHCreatePage() {
const current = store.getState().executors.items;
const merged = current.some((e) => e.id === next.id) ? current : [...current, next];
store.getState().setExecutors(merged);
router.push(`/settings/executors/${profile.id}`);
router.push(executorProfileSettingsPath(profile.id));
},
[router, store],
);
+2 -1
View File
@@ -22,6 +22,7 @@ import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
import { KubernetesReadOnlyNotice } from "@/components/settings/kubernetes-read-only-notice";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
type ProfileWithExecutor = ExecutorProfile & {
executor_type: string;
@@ -114,7 +115,7 @@ function ProfileCard({
return (
<Card
className="group cursor-pointer transition-colors hover:bg-muted/50"
onClick={() => router.push(`/settings/executors/${profile.id}`)}
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
>
<CardContent className="flex items-center gap-3 p-4">
<ExecutorIconBadge type={profile.executor_type} />
@@ -331,13 +331,11 @@ describe("buildAgentsBranch", () => {
});
describe("buildExecutorsBranch", () => {
it("uses the executor-scoped profile route, the one with an executor crumb", () => {
it("uses the canonical profile route", () => {
const [executor] = buildExecutorsBranch(EXECUTORS);
expect(executor.href).toBe("/settings/executor/exec-1");
expect(hrefsOf(executor.children ?? [])).toEqual([
"/settings/executor/exec-1/profile/exec-profile-1",
]);
expect(hrefsOf(executor.children ?? [])).toEqual(["/settings/executors/exec-profile-1"]);
});
it("makes a configured Kubernetes executor disclosure-only and links its profile root", () => {
@@ -446,12 +444,12 @@ describe("findActiveNodePath", () => {
});
it("stops at the row when the route has no node — the row stays active", () => {
// The install catalogue is not an agent, and the flat profile spelling has
// no executor to nest under. Both are the row's own pages.
// The install catalogue is not an agent, and an unknown profile has no
// executor to nest under. Both are the row's own pages.
expect(findActiveNodePath(forest, "/settings/agents/browse")).toEqual([
settingsMenuRowKey("/settings/agents"),
]);
expect(findActiveNodePath(forest, "/settings/executors/exec-profile-1")).toEqual([
expect(findActiveNodePath(forest, "/settings/executors/unknown-profile")).toEqual([
settingsMenuRowKey(EXECUTORS_HREF),
]);
});
@@ -316,11 +316,11 @@ export function buildAgentsBranch(
/**
* One node per executor, holding its profiles.
*
* Most executors use the scoped legacy spellings so their executor breadcrumb
* matches the branch. A configured Kubernetes executor only discloses its
* profile children: connection, diagnostics, sessions and workload settings
* all live on that profile page. Its standalone connection route remains
* reachable only when there is no profile to recover through.
* Profile children always open the canonical profile editor. A configured
* Kubernetes executor only discloses its profile children: connection,
* diagnostics, sessions and workload settings all live on that profile page.
* Its standalone connection route remains reachable only when there is no
* profile to recover through.
*/
export function buildExecutorsBranch(executors: ReadonlyArray<BranchExecutor>): SettingsMenuNode[] {
return executors.map((executor) => {
@@ -337,7 +337,7 @@ export function buildExecutorsBranch(executors: ReadonlyArray<BranchExecutor>):
// Same split as agents: the executor ships with kandev, the profiles do not.
children: profiles.map((profile) => ({
key: `executor:${executor.id}:profile:${profile.id}`,
href: executorProfileSettingsPath(executor, profile.id),
href: executorProfileSettingsPath(profile.id),
label: { text: profile.name },
isUserRecord: true,
})),
@@ -0,0 +1,114 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ExecutorsHubPage from "@/app/settings/executors/page";
import { StateProvider } from "@/components/state-provider";
import { ExecutorEnvironmentDisclosure } from "@/components/task/executor-environment-disclosure";
import type { TaskEnvironment } from "@/lib/api/domains/task-environment-api";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
const { push } = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push }),
}));
vi.mock("@/components/task/executor-environment-info", () => ({
EnvironmentInfo: ({ kubernetesActions }: { kubernetesActions?: ReactNode }) => (
<>{kubernetesActions}</>
),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const TIMESTAMP = "2026-08-24T00:00:00Z";
afterEach(() => {
cleanup();
push.mockReset();
});
function profile(executorId: string): ExecutorProfile {
return {
id: "profile/primary",
executor_id: executorId,
executor_type: "local_docker",
name: "Primary profile",
config: {},
prepare_script: "",
cleanup_script: "",
env_vars: [],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
}
function executor(): Executor {
const id = "executor/primary";
return {
id,
name: "Primary executor",
type: "local_docker",
status: "active",
is_system: false,
config: {},
profiles: [profile(id)],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
}
const kubernetesEnvironment = {
id: "environment-1",
task_id: "task-1",
repository_id: "repository-1",
executor_type: "k8s",
executor_id: "executor/primary",
executor_profile_id: "profile/primary",
agent_execution_id: "execution-1",
control_port: 0,
status: "running",
created_at: TIMESTAMP,
} satisfies TaskEnvironment;
describe("executor profile navigation", () => {
it("opens the hub profile card in the encoded canonical editor", () => {
const record = executor();
render(
<StateProvider initialState={{ executors: { items: [record] } }}>
<ExecutorsHubPage />
</StateProvider>,
);
fireEvent.click(screen.getByText("Primary profile"));
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
});
it("opens the task disclosure settings action in the encoded canonical editor", () => {
render(
<ExecutorEnvironmentDisclosure
env={kubernetesEnvironment}
container={null}
ssh={null}
kubernetes={null}
kubernetesLoaded={false}
kubernetesError={null}
loading={false}
refreshing={false}
isResetting={false}
onRefresh={async () => {}}
onReset={vi.fn()}
/>,
);
expect(screen.getByTestId("executor-settings-link").getAttribute("href")).toBe(
"/settings/executors/profile%2Fprimary",
);
});
});
@@ -63,13 +63,11 @@ describe("ExecutorProfilesCard profile navigation", () => {
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
});
it("preserves the scoped legacy profile route for SSH", () => {
it("opens an SSH profile in the canonical editor", () => {
renderCard(executor("ssh"));
fireEvent.click(screen.getByText("Primary profile"));
expect(push).toHaveBeenCalledWith(
"/settings/executor/executor%2Fprimary/profile/profile%2Fprimary",
);
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
});
});
@@ -61,7 +61,6 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
const [dialogOpen, setDialogOpen] = useState(false);
const executors = useAppStore((state) => state.executors.items);
const setExecutors = useAppStore((state) => state.setExecutors);
const executorType = executors.find((executor) => executor.id === executorId)?.type;
const refreshProfiles = useCallback(async () => {
try {
@@ -81,9 +80,9 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
const handleProfileCreated = useCallback(
(profile: ExecutorProfile) => {
refreshProfiles();
router.push(executorProfileSettingsPath({ id: executorId, type: executorType }, profile.id));
router.push(executorProfileSettingsPath(profile.id));
},
[executorId, executorType, refreshProfiles, router],
[refreshProfiles, router],
);
const handleDelete = useCallback(
@@ -117,14 +116,7 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
<div
key={profile.id}
className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() =>
router.push(
executorProfileSettingsPath(
{ id: executorId, type: executorType },
profile.id,
),
)
}
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">{profile.name}</span>
@@ -1,10 +1,12 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
import { LegacyExecutorSettingsRoute } from "./legacy-executor-settings-route";
const LEGACY_EXECUTOR_TESTID = "legacy-executor";
const { replace } = vi.hoisted(() => ({ replace: vi.fn() }));
vi.mock("@/lib/routing/client-router", () => ({
@@ -13,27 +15,25 @@ vi.mock("@/lib/routing/client-router", () => ({
vi.mock("@/app/settings/executor/[id]/page", () => ({
default: ({ executorId }: { executorId: string }) => (
<div data-testid="legacy-executor">{executorId}</div>
),
}));
vi.mock("@/app/settings/executor/[id]/profile/[profileId]/page", () => ({
default: ({ profileId }: { profileId: string }) => (
<div data-testid="legacy-profile">{profileId}</div>
<div data-testid={LEGACY_EXECUTOR_TESTID}>{executorId}</div>
),
}));
const TIMESTAMP = "2026-08-24T00:00:00Z";
const PROFILE_ID = "profile/primary";
const CANONICAL_PROFILE_PATH = "/settings/executors/profile%2Fprimary";
const PROFILE_UNAVAILABLE_TESTID = "executor-profile-unavailable";
afterEach(() => {
cleanup();
replace.mockReset();
window.history.replaceState({}, "", "/settings");
});
function executor(type: Executor["type"]): Executor {
const id = "executor/primary";
const profile: ExecutorProfile = {
id: "profile/primary",
id: PROFILE_ID,
executor_id: id,
executor_type: type,
name: "Primary profile",
@@ -57,11 +57,17 @@ function executor(type: Executor["type"]): Executor {
};
}
function renderRoute(record: Executor, profileId?: string) {
function renderRoute(
record: Executor | null,
profileId?: string,
executorId?: string,
options: { executorsLoaded?: boolean } = {},
) {
render(
<StateProvider
initialState={{
executors: { items: [record] },
executors: { items: record ? [record] : [] },
settingsData: { executorsLoaded: options.executorsLoaded ?? true, agentsLoaded: true },
auth: {
mode: "enabled",
authenticated: true,
@@ -75,19 +81,36 @@ function renderRoute(record: Executor, profileId?: string) {
},
}}
>
<LegacyExecutorSettingsRoute executorId={record.id} profileId={profileId} />
<LegacyExecutorSettingsRoute
executorId={executorId ?? record?.id ?? "executor/missing"}
profileId={profileId}
/>
</StateProvider>,
);
}
function HydrateExecutors({ record }: { record: Executor }) {
const store = useAppStoreApi();
useEffect(() => {
store.getState().setExecutors([record]);
store.getState().setSettingsData({ executorsLoaded: true });
}, [record, store]);
return null;
}
it("waits for executor hydration before rendering an unavailable profile state", () => {
renderRoute(null, PROFILE_ID, "executor/missing", { executorsLoaded: false });
expect(screen.queryByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeNull();
expect(replace).not.toHaveBeenCalled();
});
describe("LegacyExecutorSettingsRoute", () => {
it("redirects a member's bookmarked Kubernetes executor before mounting legacy controls", async () => {
renderRoute(executor("k8s"));
await waitFor(() =>
expect(replace).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary"),
);
expect(screen.queryByTestId("legacy-executor")).toBeNull();
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
});
it("keeps an orphaned Kubernetes executor on the connection recovery page", async () => {
@@ -97,32 +120,106 @@ describe("LegacyExecutorSettingsRoute", () => {
await waitFor(() =>
expect(replace).toHaveBeenCalledWith("/settings/executors/k8s/executor%2Fprimary"),
);
expect(screen.queryByTestId("legacy-executor")).toBeNull();
expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
});
it("redirects a bookmarked Kubernetes profile to the Kubernetes-aware editor", async () => {
renderRoute(executor("k8s"), "profile/primary");
renderRoute(executor("k8s"), PROFILE_ID);
await waitFor(() =>
expect(replace).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary"),
);
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
expect(screen.queryByTestId("legacy-profile")).toBeNull();
});
it("does not redirect a Kubernetes executor to a profile owned by another executor", () => {
renderRoute(executor("k8s"), "profile/from-another-executor");
expect(screen.getByTestId("legacy-profile")).toBeTruthy();
expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
expect(replace).not.toHaveBeenCalled();
});
it.each([
{ profileId: undefined, testId: "legacy-executor" },
{ profileId: "profile/primary", testId: "legacy-profile" },
])("keeps SSH on its legacy scoped page", ({ profileId, testId }) => {
renderRoute(executor("ssh"), profileId);
"local",
"local_docker",
"ssh",
"sprites",
"local_pc",
"worktree",
"remote_docker",
] as const)("redirects a %s profile bookmark to the canonical editor", async (type) => {
renderRoute(executor(type), PROFILE_ID);
expect(screen.getByTestId(testId)).toBeTruthy();
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
});
it("keeps an executor-only SSH route on the connection editor", () => {
renderRoute(executor("ssh"));
expect(screen.getByTestId(LEGACY_EXECUTOR_TESTID)).toBeTruthy();
expect(replace).not.toHaveBeenCalled();
});
it("renders an unavailable state for a missing profile without choosing another profile", () => {
const record = executor("local_docker");
record.profiles = [
{
...record.profiles![0],
id: "profile/other",
},
];
renderRoute(record, PROFILE_ID);
expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
expect(replace).not.toHaveBeenCalled();
});
it("renders an unavailable state for a missing executor", () => {
renderRoute(null, PROFILE_ID, "executor/missing");
expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
expect(replace).not.toHaveBeenCalled();
});
it("preserves query parameters and fragments while replacing a valid bookmark", async () => {
window.history.replaceState(
{},
"",
"/settings/executor/executor%2Fprimary/profile/profile%2Fprimary?tab=advanced#docker",
);
renderRoute(executor("ssh"), PROFILE_ID);
await waitFor(() =>
expect(replace).toHaveBeenCalledWith(
"/settings/executors/profile%2Fprimary?tab=advanced#docker",
),
);
});
it("retries a valid bookmark when executor state hydrates after the route mounts", async () => {
const record = executor("ssh");
render(
<StateProvider
initialState={{
executors: { items: [] },
auth: {
mode: "enabled",
authenticated: true,
user: {
id: "member-1",
email: "member@example.com",
display_name: "Member",
role: "member",
status: "active",
},
},
}}
>
<LegacyExecutorSettingsRoute executorId={record.id} profileId={PROFILE_ID} />
<HydrateExecutors record={record} />
</StateProvider>,
);
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
});
});
@@ -1,13 +1,36 @@
"use client";
import ExecutorEditPage from "@/app/settings/executor/[id]/page";
import ProfileDetailPage from "@/app/settings/executor/[id]/profile/[profileId]/page";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { useAppStore } from "@/components/state-provider";
import { useRouter } from "@/lib/routing/client-router";
import {
executorConnectionSettingsPath,
executorProfileSettingsPath,
} from "@/lib/settings/executor-settings-routes";
import type { Executor } from "@/lib/types/http";
import { SettingsRedirect } from "@/src/settings-route-helpers";
import { useTranslation } from "react-i18next";
const EXECUTORS_ROUTE = "/settings/executors";
function ProfileUnavailableState({ executor }: { executor: Executor | null }) {
const { t } = useTranslation();
const router = useRouter();
const destination = executor ? executorConnectionSettingsPath(executor) : EXECUTORS_ROUTE;
return (
<Card data-testid="executor-profile-unavailable">
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
<Button className="mt-4 cursor-pointer" onClick={() => router.push(destination)}>
{executor ? t("executors:backToExecutor") : t("executors:backToExecutors")}
</Button>
</CardContent>
</Card>
);
}
export function LegacyExecutorSettingsRoute({
executorId,
@@ -19,23 +42,23 @@ export function LegacyExecutorSettingsRoute({
const executor = useAppStore(
(state) => state.executors.items.find((item) => item.id === executorId) ?? null,
);
const executorsLoaded = useAppStore((state) => state.settingsData.executorsLoaded);
if (profileId !== undefined) {
if (!executorsLoaded) return null;
const profile = executor?.profiles?.find(
(candidate) => candidate.id === profileId && candidate.executor_id === executorId,
);
if (!executor || !profile) return <ProfileUnavailableState executor={executor} />;
return <SettingsRedirect to={executorProfileSettingsPath(profile.id)} />;
}
if (executor?.type === "k8s") {
const destinationProfileId = profileId
? executor.profiles?.find((profile) => profile.id === profileId)?.id
: executor.profiles?.[0]?.id;
if (profileId && !destinationProfileId) {
return <ProfileDetailPage executorId={executorId} profileId={profileId} />;
}
const destination = destinationProfileId
? executorProfileSettingsPath(executor, destinationProfileId)
const destination = executor.profiles?.[0]?.id
? executorProfileSettingsPath(executor.profiles[0].id)
: executorConnectionSettingsPath(executor);
return <SettingsRedirect to={destination} />;
}
return profileId ? (
<ProfileDetailPage executorId={executorId} profileId={profileId} />
) : (
<ExecutorEditPage executorId={executorId} />
);
return <ExecutorEditPage executorId={executorId} />;
}
@@ -18,6 +18,7 @@ import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview";
import { RUNNER_INELIGIBLE_REASON_KEYS } from "@/components/task-create-dialog-helpers";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
import { useTranslation } from "react-i18next";
type SelectorOption = {
@@ -89,7 +90,7 @@ type AgentColumnProps = Pick<
>;
function credentialsHref(executorProfileId: string): string {
return executorProfileId ? `/settings/executors/${executorProfileId}` : "/settings/executors";
return executorProfileId ? executorProfileSettingsPath(executorProfileId) : "/settings/executors";
}
function useExecutorTarget(executorProfileName: string | null): string {
@@ -93,10 +93,7 @@ function KubernetesActions({
touch ? "h-11 w-11" : "h-10 w-10",
);
const settingsPath = env.executor_profile_id
? executorProfileSettingsPath(
{ id: env.executor_id, type: env.executor_type },
env.executor_profile_id,
)
? executorProfileSettingsPath(env.executor_profile_id)
: kubernetesExecutorSettingsPath(env.executor_id);
return (
<div
@@ -0,0 +1,123 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { setSettingsMenuMode } from "../../helpers/settings-menu";
const INITIAL_IMAGE_TAG = "kandev/e2e:routing-initial";
const EDITED_IMAGE_TAG = "kandev/e2e:routing-edited";
const DRAFT_IMAGE_TAG = "kandev/e2e:routing-draft";
const DOCKERFILE = "FROM busybox\nWORKDIR /workspace\n";
function canonicalProfilePath(profileId: string): string {
return `/settings/executors/${encodeURIComponent(profileId)}`;
}
async function expectDockerProfileEditor(testPage: Page, profileId: string) {
await expect(testPage).toHaveURL(canonicalProfilePath(profileId));
await expect(testPage.locator("#image-tag")).toBeVisible();
await expect(testPage.getByText("Dockerfile", { exact: true })).toBeVisible();
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
}
test.describe("executor profile routing", () => {
test("converges desktop entry points on the complete Docker profile editor", async ({
testPage,
apiClient,
}) => {
test.setTimeout(60_000);
const executor = await apiClient.createExecutor("E2E profile routing executor", "local_docker");
const profile = await apiClient.createExecutorProfile(executor.id, {
name: "E2E profile routing Docker",
config: { dockerfile: DOCKERFILE, image_tag: INITIAL_IMAGE_TAG },
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
await testPage.route("**/api/v1/docker/build", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/x-ndjson",
body: JSON.stringify({ stream: "Successfully built\n" }) + "\n",
});
});
try {
await testPage.goto("/settings/executors");
await testPage.getByText(profile.name, { exact: true }).click();
await expectDockerProfileEditor(testPage, profile.id);
const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, {
predicate: (response) => response.ok(),
});
await testPage.getByRole("button", { name: "Build Image" }).click();
await buildResponse;
await expect(testPage.getByText("Success", { exact: true })).toBeVisible();
await testPage.locator("#image-tag").fill(EDITED_IMAGE_TAG);
const floatingSave = testPage.getByTestId("settings-floating-save");
const saveResponse = waitForHttp(
testPage,
"PATCH",
/^\/api\/v1\/executors\/[^/]+\/profiles\/[^/]+$/,
{ predicate: (response) => response.ok() },
);
await floatingSave.getByRole("button", { name: "Save changes" }).click();
await saveResponse;
await expect(testPage.getByText("Profile saved")).toBeVisible();
await expect
.poll(
async () =>
(await apiClient.getExecutorProfile(executor.id, profile.id)).config?.image_tag,
)
.toBe(EDITED_IMAGE_TAG);
await testPage.reload();
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
await setSettingsMenuMode(testPage, "accordion");
const settingsTree = testPage.getByTestId("app-sidebar-settings-mode");
const expandExecutors = settingsTree.getByRole("button", { name: /Expand Executors/i });
if (await expandExecutors.count()) await expandExecutors.click();
const expandExecutor = settingsTree.getByRole("button", {
name: `Expand ${executor.name}`,
});
if (await expandExecutor.count()) await expandExecutor.click();
const profileLink = settingsTree.locator(`a[href="${canonicalProfilePath(profile.id)}"]`);
await expect(profileLink).toBeVisible({ timeout: 10_000 });
await profileLink.click();
await expectDockerProfileEditor(testPage, profile.id);
await testPage.goto(`/settings/executor/${encodeURIComponent(executor.id)}`);
await testPage.getByText(profile.name, { exact: true }).click();
await expectDockerProfileEditor(testPage, profile.id);
const legacyBookmark =
`/settings/executor/${encodeURIComponent(executor.id)}/profile/${encodeURIComponent(profile.id)}` +
"?tab=advanced#docker";
await testPage.goto(legacyBookmark);
await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
await testPage.locator("#image-tag").fill(DRAFT_IMAGE_TAG);
const executorsLink = settingsTree.locator('a[href="/settings/executors"]');
await executorsLink.click();
const leaveDialog = testPage.getByRole("alertdialog");
await expect(leaveDialog).toBeVisible();
await leaveDialog.getByRole("button", { name: "Continue editing" }).click();
await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
await expect(testPage.locator("#image-tag")).toHaveValue(DRAFT_IMAGE_TAG);
await executorsLink.click();
await expect(leaveDialog).toBeVisible();
await leaveDialog.getByRole("button", { name: "Discard and leave" }).click();
await expect(testPage).toHaveURL(/\/settings\/executors$/);
await testPage.goto(canonicalProfilePath(profile.id));
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
} finally {
await apiClient.deleteExecutor(executor.id).catch(() => {});
}
});
});
@@ -0,0 +1,98 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
const INITIAL_IMAGE_TAG = "kandev/e2e:mobile-routing-initial";
const EDITED_IMAGE_TAG = "kandev/e2e:mobile-routing-edited";
const DOCKERFILE = "FROM busybox\nWORKDIR /workspace\n";
function canonicalProfilePath(profileId: string): string {
return `/settings/executors/${encodeURIComponent(profileId)}`;
}
async function expectTouchTarget(locator: Locator, label: string) {
const box = await locator.boundingBox();
expect(box, `${label} must have geometry`).not.toBeNull();
expect(box!.height, `${label} must be at least 44px tall`).toBeGreaterThanOrEqual(44);
}
test("phone profile navigation reaches the complete Docker editor", async ({
testPage,
apiClient,
}) => {
test.setTimeout(60_000);
const executor = await apiClient.createExecutor(
"E2E mobile profile routing executor",
"local_docker",
);
const profile = await apiClient.createExecutorProfile(executor.id, {
name: "E2E mobile profile routing Docker",
config: { dockerfile: DOCKERFILE, image_tag: INITIAL_IMAGE_TAG },
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
await testPage.route("**/api/v1/docker/build", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/x-ndjson",
body: JSON.stringify({ stream: "Successfully built\n" }) + "\n",
});
});
try {
await testPage.goto("/settings/executors");
const profileCard = testPage.getByText(profile.name, { exact: true });
await expect(profileCard).toBeVisible();
await profileCard.tap();
await expect(testPage).toHaveURL(canonicalProfilePath(profile.id));
await expect(testPage.locator("#image-tag")).toBeVisible();
await expect(testPage.getByText("Dockerfile", { exact: true })).toBeVisible();
const buildButton = testPage.getByRole("button", { name: "Build Image" });
await expect(buildButton).toBeVisible();
await expectTouchTarget(buildButton, "Mobile Docker build button");
const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, {
predicate: (response) => response.ok(),
});
await buildButton.tap();
await buildResponse;
await expect(testPage.getByText("Success", { exact: true })).toBeVisible();
await testPage.locator("#image-tag").fill(EDITED_IMAGE_TAG);
const floatingSave = testPage.getByTestId("settings-floating-save");
const saveButton = floatingSave.getByRole("button", { name: "Save changes" });
await expectTouchTarget(saveButton, "Mobile profile save button");
const saveResponse = waitForHttp(
testPage,
"PATCH",
/^\/api\/v1\/executors\/[^/]+\/profiles\/[^/]+$/,
{ predicate: (response) => response.ok() },
);
await saveButton.tap();
await saveResponse;
await expect(testPage.getByText("Profile saved")).toBeVisible();
await expect
.poll(
async () => (await apiClient.getExecutorProfile(executor.id, profile.id)).config?.image_tag,
)
.toBe(EDITED_IMAGE_TAG);
await testPage.reload();
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
await assertNoDocumentHorizontalOverflow(testPage, "mobile canonical executor profile editor");
const legacyBookmark =
`/settings/executor/${encodeURIComponent(executor.id)}/profile/${encodeURIComponent(profile.id)}` +
"?tab=advanced#docker";
await testPage.goto(legacyBookmark);
await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "mobile legacy executor profile bookmark");
} finally {
await apiClient.deleteExecutor(executor.id).catch(() => {});
}
});
@@ -197,9 +197,13 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
],
executors: [
{
type: "docker",
type: "local_docker",
profiles: [{ id: "executor / one", name: "Docker Local" }],
},
{
type: "ssh",
profiles: [{ id: "ssh profile / two", name: "SSH Remote" }],
},
],
});
@@ -212,6 +216,9 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
expect(resolved.find((entry) => entry.id === "executor-profile:executor / one")?.href).toBe(
"/settings/executors/executor%20%2F%20one",
);
expect(resolved.find((entry) => entry.id === "executor-profile:ssh profile / two")?.href).toBe(
"/settings/executors/ssh%20profile%20%2F%20two",
);
expect(resolved.find((entry) => entry.id === "workspace:workspace / one:name")?.href).toBe(
"/settings/workspaces/workspace%20%2F%20one#setting-workspace-workspace%20%2F%20one-name",
);
+2 -1
View File
@@ -11,6 +11,7 @@ import {
executorProfileDiscoveryTarget,
workspaceDiscoveryTarget,
} from "./dynamic-targets";
import { executorProfileSettingsPath } from "../settings/executor-settings-routes";
function workspaceDefinitions(
workspaces: SettingsDiscoveryContext["workspaces"],
@@ -147,7 +148,7 @@ function executorProfileDefinitions(
for (const [executorIndex, executor] of executors.entries()) {
for (const [profileIndex, profile] of (executor.profiles ?? []).entries()) {
const profileId = `executor-profile:${profile.id}`;
const href = `/settings/executors/${encodeURIComponent(profile.id)}`;
const href = executorProfileSettingsPath(profile.id);
const order = 30_000 + executorIndex * 100 + profileIndex * 10;
entries.push({
id: profileId,
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { executorProfileSettingsPath } from "./executor-settings-routes";
describe("executorProfileSettingsPath", () => {
// @covers AC-EXECUTORS-PROFILE-EDITOR-001.1
it("routes every profile to the canonical editor", () => {
expect(executorProfileSettingsPath("profile/primary")).toBe(
"/settings/executors/profile%2Fprimary",
);
});
});
@@ -13,11 +13,6 @@ export function executorConnectionSettingsPath(executor: ExecutorRouteIdentity):
: `/settings/executor/${encodeURIComponent(executor.id)}`;
}
export function executorProfileSettingsPath(
executor: ExecutorRouteIdentity,
profileId: string,
): string {
return executor.type === "k8s"
? `/settings/executors/${encodeURIComponent(profileId)}`
: `${executorConnectionSettingsPath(executor)}/profile/${encodeURIComponent(profileId)}`;
export function executorProfileSettingsPath(profileId: string): string {
return `/settings/executors/${encodeURIComponent(profileId)}`;
}