f5dc6e2322
* fix: persist task create last-used selections Record final task-create selections server-side. Make dialog restore wait for backend settings before localStorage fallback. Update pinned-action verification tests and split dockview rendering for lint. * fix: address task create PR feedback * fix: resolve task create review follow-ups * test: cover task create service recording * fix: address task create review regressions * fix: avoid recording generated fresh branches * fix: skip repo-only last-used task records * fix: make task create settings updates atomic * fix: clear stale task create cache * fix: clear synced task create overlay * fix: address task create review feedback * fix: queue cached repository autopick * fix: preserve partial task create settings * fix: replace task create queued overlay * fix: clear branch when recording repository * test: cover task create queued payload * test: remove unused user service helper --------- Co-authored-by: root <root@kandev.ts.net>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
import { useDefaultSelectionsEffect } from "./task-create-dialog-effects";
|
|
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
|
|
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
const PROFILE_DOCKER = "profile-docker";
|
|
const PROFILE_LOCAL = "profile-local";
|
|
const PROFILE_WORKTREE = "profile-worktree";
|
|
const PROFILE_WORKTREE_B = "profile-worktree-b";
|
|
|
|
type DefaultSelFake = Pick<
|
|
DialogFormState,
|
|
| "agentProfileId"
|
|
| "workflowAgentProfileId"
|
|
| "selectedWorkflowId"
|
|
| "executorId"
|
|
| "executorProfileId"
|
|
| "setAgentProfileId"
|
|
| "setExecutorId"
|
|
| "setExecutorProfileId"
|
|
| "noRepository"
|
|
| "repositories"
|
|
| "remoteRepos"
|
|
| "useRemote"
|
|
>;
|
|
|
|
function makeDefaultSelFs(overrides: Partial<DefaultSelFake> = {}): DialogFormState {
|
|
return {
|
|
agentProfileId: "",
|
|
workflowAgentProfileId: "",
|
|
selectedWorkflowId: null,
|
|
executorId: "exec-1",
|
|
executorProfileId: "profile-1",
|
|
setAgentProfileId: vi.fn(),
|
|
setExecutorId: vi.fn(),
|
|
setExecutorProfileId: vi.fn(),
|
|
noRepository: false,
|
|
repositories: [],
|
|
remoteRepos: [],
|
|
useRemote: false,
|
|
...overrides,
|
|
} as unknown as DialogFormState;
|
|
}
|
|
|
|
function makeSel(overrides: Partial<StoreSelections> = {}): StoreSelections {
|
|
return {
|
|
agentProfiles: [],
|
|
compatibleAgentProfiles: [],
|
|
authLoaded: true,
|
|
executors: [],
|
|
workspaceDefaults: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function localExecutor(): StoreSelections["executors"][number] {
|
|
return {
|
|
id: "exec-local",
|
|
type: "local",
|
|
profiles: [{ id: PROFILE_LOCAL, executor_type: "local" }],
|
|
} as unknown as StoreSelections["executors"][number];
|
|
}
|
|
|
|
function dockerExecutor(): StoreSelections["executors"][number] {
|
|
return {
|
|
id: "exec-docker",
|
|
type: "local_docker",
|
|
profiles: [{ id: PROFILE_DOCKER, executor_type: "local_docker" }],
|
|
} as unknown as StoreSelections["executors"][number];
|
|
}
|
|
|
|
function worktreeExecutor(): StoreSelections["executors"][number] {
|
|
return {
|
|
id: "exec-worktree",
|
|
type: "worktree",
|
|
profiles: [
|
|
{ id: PROFILE_WORKTREE, executor_type: "worktree" },
|
|
{ id: PROFILE_WORKTREE_B, executor_type: "worktree" },
|
|
],
|
|
} as unknown as StoreSelections["executors"][number];
|
|
}
|
|
|
|
describe("useDefaultSelectionsEffect - executor profile defaults", () => {
|
|
it("defaults repo-backed tasks to the worktree profile when no profile was saved", async () => {
|
|
const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "" });
|
|
const local = localExecutor();
|
|
const worktree = worktreeExecutor();
|
|
const sel = makeSel({ executors: [local, worktree] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(worktree.id));
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE));
|
|
});
|
|
|
|
it("defaults repo-less tasks to a local profile because worktree needs a repo", async () => {
|
|
const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "", noRepository: true });
|
|
const worktree = worktreeExecutor();
|
|
const local = localExecutor();
|
|
const sel = makeSel({ executors: [worktree, local] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id));
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL));
|
|
});
|
|
|
|
it("defaults explicit local-path tasks to a local profile when no profile was saved", async () => {
|
|
const fs = makeDefaultSelFs({
|
|
executorId: "",
|
|
executorProfileId: "",
|
|
repositories: [{ key: "row-0", localPath: "/workspace/custom", branch: "" }],
|
|
});
|
|
const worktree = worktreeExecutor();
|
|
const local = localExecutor();
|
|
const sel = makeSel({ executors: [worktree, local] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id));
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL));
|
|
});
|
|
|
|
it("ignores a workspace-default worktree executor for explicit local-path tasks", async () => {
|
|
const fs = makeDefaultSelFs({
|
|
executorId: "",
|
|
executorProfileId: "",
|
|
repositories: [{ key: "row-0", localPath: "/workspace/custom", branch: "" }],
|
|
});
|
|
const worktree = worktreeExecutor();
|
|
const local = localExecutor();
|
|
const sel = makeSel({
|
|
executors: [worktree, local],
|
|
workspaceDefaults: {
|
|
default_executor_id: worktree.id,
|
|
} as StoreSelections["workspaceDefaults"],
|
|
});
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id));
|
|
expect(fs.setExecutorId).not.toHaveBeenCalledWith(worktree.id);
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL));
|
|
});
|
|
|
|
it("does not fall back to a worktree profile for explicit local-path tasks", async () => {
|
|
const fs = makeDefaultSelFs({
|
|
executorId: "",
|
|
executorProfileId: "",
|
|
repositories: [{ key: "row-0", localPath: "/workspace/custom", branch: "" }],
|
|
});
|
|
const localWithoutProfiles = { ...localExecutor(), profiles: [] };
|
|
const worktree = worktreeExecutor();
|
|
const docker = dockerExecutor();
|
|
const sel = makeSel({ executors: [worktree, localWithoutProfiles, docker] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_DOCKER));
|
|
expect(fs.setExecutorProfileId).not.toHaveBeenCalledWith(PROFILE_WORKTREE);
|
|
});
|
|
});
|
|
|
|
describe("useDefaultSelectionsEffect - executor profile restoration", () => {
|
|
it("defers executor profile fallback until user settings have loaded or settled", async () => {
|
|
window.localStorage.removeItem(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID);
|
|
const fs = makeDefaultSelFs({ executorProfileId: "", executorId: "" });
|
|
const worktree = worktreeExecutor();
|
|
const selBefore = makeSel({
|
|
executors: [worktree],
|
|
userSettingsLoaded: false,
|
|
});
|
|
|
|
const { rerender } = renderHook(({ sel }) => useDefaultSelectionsEffect(fs, true, sel, []), {
|
|
initialProps: { sel: selBefore },
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(fs.setExecutorId).not.toHaveBeenCalled();
|
|
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
|
|
|
const selAfter = makeSel({
|
|
executors: [worktree],
|
|
userSettingsLoaded: true,
|
|
});
|
|
rerender({ sel: selAfter });
|
|
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE));
|
|
});
|
|
|
|
it("keeps deferring when a valid cached executor profile exists but settings are loading", async () => {
|
|
window.localStorage.setItem(
|
|
STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID,
|
|
JSON.stringify(PROFILE_WORKTREE),
|
|
);
|
|
const fs = makeDefaultSelFs({ executorProfileId: "", executorId: "" });
|
|
const sel = makeSel({
|
|
executors: [worktreeExecutor()],
|
|
userSettingsLoaded: false,
|
|
});
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(fs.setExecutorId).not.toHaveBeenCalled();
|
|
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps deferring when cached executor profile is ineligible for the source mode", async () => {
|
|
window.localStorage.setItem(
|
|
STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID,
|
|
JSON.stringify(PROFILE_WORKTREE),
|
|
);
|
|
const fs = makeDefaultSelFs({ executorProfileId: "", executorId: "", noRepository: true });
|
|
const sel = makeSel({
|
|
executors: [worktreeExecutor(), localExecutor()],
|
|
userSettingsLoaded: false,
|
|
});
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useDefaultSelectionsEffect - executor profile settings restoration", () => {
|
|
it("restores executor profile from store-backed settings when localStorage is not primed", async () => {
|
|
window.localStorage.removeItem(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID);
|
|
const fs = makeDefaultSelFs({ executorProfileId: "", executorId: "" });
|
|
const worktree = worktreeExecutor();
|
|
const sel = makeSel({
|
|
executors: [worktree],
|
|
lastUsedExecutorProfileId: PROFILE_WORKTREE_B,
|
|
userSettingsLoaded: true,
|
|
});
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE_B));
|
|
});
|
|
|
|
it("does not pick a fallback executor id while a valid last-used profile is restoring", async () => {
|
|
window.localStorage.removeItem(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID);
|
|
const local = localExecutor();
|
|
const worktree = worktreeExecutor();
|
|
const sel = makeSel({
|
|
executors: [local, worktree],
|
|
workspaceDefaults: { default_executor_id: local.id } as StoreSelections["workspaceDefaults"],
|
|
lastUsedExecutorProfileId: PROFILE_WORKTREE_B,
|
|
userSettingsLoaded: true,
|
|
});
|
|
|
|
const setExecutorId = vi.fn();
|
|
const setExecutorProfileId = vi.fn();
|
|
const { rerender } = renderHook(
|
|
({ formState }) => useDefaultSelectionsEffect(formState, true, sel, []),
|
|
{
|
|
initialProps: {
|
|
formState: makeDefaultSelFs({
|
|
executorProfileId: "",
|
|
executorId: "",
|
|
setExecutorId,
|
|
setExecutorProfileId,
|
|
}),
|
|
},
|
|
},
|
|
);
|
|
|
|
await waitFor(() => expect(setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE_B));
|
|
rerender({
|
|
formState: makeDefaultSelFs({
|
|
executorProfileId: PROFILE_WORKTREE_B,
|
|
executorId: "",
|
|
setExecutorId,
|
|
setExecutorProfileId,
|
|
}),
|
|
});
|
|
|
|
await waitFor(() => expect(setExecutorId).toHaveBeenCalledWith(worktree.id));
|
|
expect(setExecutorId).not.toHaveBeenCalledWith(local.id);
|
|
});
|
|
});
|
|
|
|
describe("useDefaultSelectionsEffect - multi-repo guard counts Remote rows", () => {
|
|
it("swaps a non-worktree profile to worktree when 2+ Remote URL rows are set", async () => {
|
|
// Regression: the guard used to count only workspace/local rows, so 2
|
|
// Remote rows slipped past it with an already-selected non-worktree profile.
|
|
const fs = makeDefaultSelFs({
|
|
executorId: "exec-docker",
|
|
executorProfileId: PROFILE_DOCKER,
|
|
useRemote: true,
|
|
remoteRepos: [
|
|
{ key: "remote-0", url: "github.com/acme/a", branch: "", source: "paste" },
|
|
{ key: "remote-1", url: "github.com/acme/b", branch: "", source: "paste" },
|
|
] as DialogFormState["remoteRepos"],
|
|
});
|
|
const sel = makeSel({ executors: [dockerExecutor(), worktreeExecutor()] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE));
|
|
});
|
|
|
|
it("leaves a worktree profile alone when 2+ Remote rows are set", async () => {
|
|
const fs = makeDefaultSelFs({
|
|
executorId: "exec-worktree",
|
|
executorProfileId: PROFILE_WORKTREE,
|
|
useRemote: true,
|
|
remoteRepos: [
|
|
{ key: "remote-0", url: "github.com/acme/a", branch: "", source: "paste" },
|
|
{ key: "remote-1", url: "github.com/acme/b", branch: "", source: "paste" },
|
|
] as DialogFormState["remoteRepos"],
|
|
});
|
|
const sel = makeSel({ executors: [worktreeExecutor()] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not swap when only a single Remote row is filled", async () => {
|
|
const fs = makeDefaultSelFs({
|
|
executorId: "exec-docker",
|
|
executorProfileId: PROFILE_DOCKER,
|
|
useRemote: true,
|
|
remoteRepos: [
|
|
{ key: "remote-0", url: "github.com/acme/a", branch: "", source: "paste" },
|
|
{ key: "remote-1", url: "", branch: "", source: "paste" },
|
|
] as DialogFormState["remoteRepos"],
|
|
});
|
|
const sel = makeSel({ executors: [dockerExecutor(), worktreeExecutor()] });
|
|
|
|
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
|
});
|
|
});
|