mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Users create tasks with a title and a description. > - A required title adds work when the prompt already explains the request. > - An agent can name the task once it reads that request. > - This pull request accepts prompt-only tasks and starts them with a short prompt slice. > - A scoped title tool lets the assigned agent replace that slice early without changing execution state. > - A live browser eval checks the real agent call, saved title, audit entry, and preservation of user titles. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: task creation, shared contracts, database, server, runner tools, and board UI. **Problem or motivation** Users must currently write a title before they can submit a detailed task prompt. The agent has enough context to write a useful title itself. **Proposed solution** Make the title optional when a description is present. Save the first 120 characters of the normalized prompt as a provisional title. Ask the assigned agent to call `set_task_title` early. Use an atomic provisional-title guard to preserve titles supplied or edited by users. Keep explicit titles supported. Related: #14543 and #14556 concern empty-title submission. This change intentionally enables that submission when a prompt is present, instead of requiring a title. ## What Changed - Add the `titleNeedsGeneration` field with an idempotent migration. Keep existing titles unchanged. - Add `PUT /api/issues/:id/title` and the native and legacy `set_task_title` tool. Enforce company access, active-run ownership, shared, bounded retry receipts across native/HTTP calls, and transactional audit logging. Refresh external-object links after commit, with the same feature gate and plugin detectors as ordinary title edits. - Add early naming guidance in Standard, Ask, and Plan task context. Preserve the description, status, and assignment. - Allow prompt-only root and child task creation, plus draft restoration in the New Task dialog. Keep user titles supported. - Add an opt-in Product E2E suite for prompt-only Standard and Ask tasks, plus an explicit-title control. It checks actual provider calls within the first five tools, persisted state, audit attribution, and the reloaded UI. - Preserve a closed vocabulary of API key maintenance phrases in declared prose while rejecting opaque credential suffixes. Add one bounded naming retry after wording is rejected, without treating the rejected call as a saved title. - Repair the native cleanup receipt check exposed during full verification: accept matching input digests, retain legacy input checks, and reject conflicting receipts. ## Verification - Live Product E2E on `f43478473800e3a46b85c5ee79677efdb15108e7`: **3/3 passed** with native Codex `gpt-5.4-mini`, first attempts only, automatic retries disabled. Standard and Ask each saved “Rotate expired API key” on their first tool call, with matching persisted state and a single same-run audit entry. The explicit-title control retained its user title with zero title writes. All three verified the reloaded browser UI. - Campaign: `local-2026-09-30T21-30-11-021Z`. Earlier failed campaigns are retained separately; they exposed credential-prose handling and prompted the naming recovery fix. No failed result was regraded or deleted. - Reproduce with `pnpm test:e2e:runner -- --id task-titles.runner-codex-mini.local.prompt-title-standard --id task-titles.runner-codex-mini.local.prompt-title-ask --id task-titles.runner-codex-mini.local.preserve-explicit-title --max-automatic-retries 0` and an authorized provider key. - Full `pnpm -r typecheck` and `pnpm build` passed on the latest commit. The runner build used the configured external eval source tree. - Product E2E unit suite: **61 files, 818 tests passed**; E2E typecheck and UI token gates passed. - Title API/native regressions cover prompt-only and explicit child creation, user edits, ownership/company isolation, external reference refresh, cross-surface retry replay, and the 64-key limit without receipt eviction. All passed. Prompt-context coverage: **44 tests passed**. - Rust credential regressions: **35 tests passed**, including benign maintenance qualifiers and opaque credential rejection in every declared prose field. Catalog/report reconciliation: **28 tests passed**. Native recovery: **560 tests passed**. - Broad local `pnpm test:run`: **14,555 tests passed** in the general server group; two suites failed to initialize embedded PostgreSQL and the existing 40,000-file Git streaming stress test exceeded its 300-second macOS timeout. All three suites then passed in isolation (**5 tests passed**) without code or timeout changes. The original full local command exited nonzero and is not being represented as a clean full run. - Latest-head GitHub checks are green: **53 passed, 4 skipped, zero failed or pending**, including all test shards and the canary packaging dry run. Greptile reviewed the same commit at **5/5**, with zero unresolved review threads. ## Risks - The additive database field must reach the server and UI together. The migration uses `IF NOT EXISTS` and defaults existing tasks to a final title. - Title generation depends on the assigned agent running. Tasks without a run keep their provisional title. - Live qualification covers the native Codex path in Standard and Ask modes. API/legacy and Plan behavior have deterministic coverage. - The credential-prose exception validates the entire suffix against a closed maintenance vocabulary. Unknown suffixes, assignments, quoted values, credential prefixes, and diagnostics retain strict checks. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact deployment ID and context window are not exposed in this session. The live eval uses the native Codex `gpt-5.4-mini` profile. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1689 lines
56 KiB
TypeScript
1689 lines
56 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { NewIssueDialog } from "./NewIssueDialog";
|
|
|
|
const dialogState = vi.hoisted(() => ({
|
|
newIssueOpen: true,
|
|
newIssueDefaults: {} as Record<string, unknown>,
|
|
closeNewIssue: vi.fn(),
|
|
}));
|
|
|
|
const dialogContentState = vi.hoisted(() => ({
|
|
onEscapeKeyDown: null as null | ((event: KeyboardEvent) => void),
|
|
onPointerDownOutside: null as null | ((event: {
|
|
detail: { originalEvent: { target: EventTarget | null } };
|
|
preventDefault: () => void;
|
|
}) => void),
|
|
}));
|
|
|
|
const companyState = vi.hoisted(() => ({
|
|
companies: [
|
|
{
|
|
id: "company-1",
|
|
name: "Paperclip",
|
|
status: "active",
|
|
issuePrefix: "PAP",
|
|
},
|
|
],
|
|
selectedCompanyId: "company-1",
|
|
selectedCompany: {
|
|
id: "company-1",
|
|
name: "Paperclip",
|
|
status: "active",
|
|
issuePrefix: "PAP",
|
|
},
|
|
}));
|
|
|
|
const toastState = vi.hoisted(() => ({
|
|
pushToast: vi.fn(),
|
|
}));
|
|
|
|
const mockIssuesApi = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
upsertDocument: vi.fn(),
|
|
uploadAttachment: vi.fn(),
|
|
}));
|
|
|
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
listSummaries: vi.fn(),
|
|
}));
|
|
|
|
const mockProjectsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
adapterModels: vi.fn(),
|
|
}));
|
|
|
|
const mockAuthApi = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
const mockAssetsApi = vi.hoisted(() => ({
|
|
uploadImage: vi.fn(),
|
|
}));
|
|
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
const mockMissingUserSecretsBannerRender = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../context/DialogContext", () => ({
|
|
useDialog: () => dialogState,
|
|
}));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => companyState,
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({
|
|
useToastActions: () => toastState,
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: mockIssuesApi,
|
|
}));
|
|
|
|
vi.mock("../api/execution-workspaces", () => ({
|
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
|
}));
|
|
|
|
vi.mock("../api/projects", () => ({
|
|
projectsApi: mockProjectsApi,
|
|
}));
|
|
|
|
vi.mock("../api/agents", () => ({
|
|
agentsApi: mockAgentsApi,
|
|
}));
|
|
|
|
vi.mock("../api/auth", () => ({
|
|
authApi: mockAuthApi,
|
|
}));
|
|
|
|
vi.mock("../api/assets", () => ({
|
|
assetsApi: mockAssetsApi,
|
|
}));
|
|
|
|
vi.mock("../api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
vi.mock("../pages/secrets/MissingUserSecretsBanner", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
MissingUserSecretsBanner: (props: { definitionKeys?: string[] }) => {
|
|
mockMissingUserSecretsBannerRender(props);
|
|
return React.createElement(
|
|
"div",
|
|
{ "data-testid": "missing-user-secrets-banner" },
|
|
props.definitionKeys?.join(",") ?? "",
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("../hooks/useProjectOrder", () => ({
|
|
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
|
|
orderedProjects: projects,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../lib/recent-assignees", () => ({
|
|
getRecentAssigneeIds: () => [],
|
|
sortAgentsByRecency: (agents: unknown[]) => agents,
|
|
trackRecentAssignee: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../lib/assignees", () => ({
|
|
assigneeValueFromSelection: ({
|
|
assigneeAgentId,
|
|
assigneeUserId,
|
|
}: {
|
|
assigneeAgentId?: string;
|
|
assigneeUserId?: string;
|
|
}) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "",
|
|
currentUserAssigneeOption: () => [],
|
|
parseAssigneeValue: (value: string) => ({
|
|
assigneeAgentId: value.startsWith("agent:") ? value.slice("agent:".length) : null,
|
|
assigneeUserId: value.startsWith("user:") ? value.slice("user:".length) : null,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./MarkdownEditor", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
MarkdownEditor: React.forwardRef<
|
|
{ focus: () => void },
|
|
{ value: string; onChange?: (value: string) => void; placeholder?: string }
|
|
>(function MarkdownEditorMock({ value, onChange, placeholder }, ref) {
|
|
React.useImperativeHandle(ref, () => ({
|
|
focus: () => undefined,
|
|
}));
|
|
return (
|
|
<textarea
|
|
aria-label={placeholder ?? "Description"}
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
/>
|
|
);
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("./InlineEntitySelector", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
InlineEntitySelector: React.forwardRef<
|
|
HTMLButtonElement,
|
|
{
|
|
value: string;
|
|
placeholder?: string;
|
|
className?: string;
|
|
triggerDataSlot?: string;
|
|
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
|
|
}
|
|
>(function InlineEntitySelectorMock({ value, placeholder, className, triggerDataSlot, renderTriggerValue }, ref) {
|
|
return (
|
|
<button ref={ref} type="button" className={className} data-slot={triggerDataSlot}>
|
|
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
|
|
</button>
|
|
);
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("./AgentIconPicker", () => ({
|
|
AgentIcon: () => null,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
|
DialogContent: ({
|
|
children,
|
|
showCloseButton: _showCloseButton,
|
|
onEscapeKeyDown,
|
|
onPointerDownOutside,
|
|
...props
|
|
}: ComponentProps<"div"> & {
|
|
showCloseButton?: boolean;
|
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
|
onPointerDownOutside?: (event: unknown) => void;
|
|
}) => {
|
|
dialogContentState.onEscapeKeyDown = onEscapeKeyDown ?? null;
|
|
dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside;
|
|
return <div {...props}>{children}</div>;
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
|
<button type={type} onClick={onClick} {...props}>{children}</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/toggle-switch", () => ({
|
|
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
|
|
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/popover", () => ({
|
|
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
PopoverContent: ({ children, disablePortal }: { children: ReactNode; disablePortal?: boolean }) => (
|
|
<div data-disable-portal={String(Boolean(disablePortal))}>{children}</div>
|
|
),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void | Promise<void>): void | Promise<void> {
|
|
let result: unknown;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
return result && typeof (result as Promise<void>).then === "function"
|
|
? (result as Promise<void>).then(() => undefined)
|
|
: undefined;
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
async function typeTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
|
await act(async () => {
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
window.HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
valueSetter?.call(textarea, value);
|
|
textarea.dispatchEvent(
|
|
new InputEvent("input", {
|
|
bubbles: true,
|
|
data: value,
|
|
inputType: "insertText",
|
|
}),
|
|
);
|
|
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
}
|
|
|
|
async function waitForAssertion(assertion: () => void, attempts = 20) {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
try {
|
|
assertion();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await flush();
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
function renderDialog(container: HTMLDivElement, hiddenSettings: string[] = []) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
queryClient.setQueryData(queryKeys.health, { hiddenSettings });
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<NewIssueDialog />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
return { root, queryClient };
|
|
}
|
|
|
|
describe("NewIssueDialog", () => {
|
|
let container: HTMLDivElement;
|
|
let originalResizeObserver: typeof ResizeObserver | undefined;
|
|
let originalVisualViewportDescriptor: PropertyDescriptor | undefined;
|
|
let originalInnerHeightDescriptor: PropertyDescriptor | undefined;
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
originalResizeObserver = globalThis.ResizeObserver;
|
|
originalVisualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
|
|
originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
globalThis.ResizeObserver = class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
dialogState.newIssueOpen = true;
|
|
dialogState.newIssueDefaults = {};
|
|
dialogState.closeNewIssue.mockReset();
|
|
dialogContentState.onEscapeKeyDown = null;
|
|
dialogContentState.onPointerDownOutside = null;
|
|
toastState.pushToast.mockReset();
|
|
mockIssuesApi.create.mockReset();
|
|
mockIssuesApi.upsertDocument.mockReset();
|
|
mockIssuesApi.uploadAttachment.mockReset();
|
|
mockExecutionWorkspacesApi.list.mockReset();
|
|
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
},
|
|
]);
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
|
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/uploads/asset.png" });
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
|
mockMissingUserSecretsBannerRender.mockReset();
|
|
localStorage.clear();
|
|
mockIssuesApi.create.mockResolvedValue({
|
|
id: "issue-2",
|
|
companyId: "company-1",
|
|
identifier: "PAP-2",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.ResizeObserver = originalResizeObserver!;
|
|
if (originalVisualViewportDescriptor) {
|
|
Object.defineProperty(window, "visualViewport", originalVisualViewportDescriptor);
|
|
} else {
|
|
Reflect.deleteProperty(window, "visualViewport");
|
|
}
|
|
if (originalInnerHeightDescriptor) {
|
|
Object.defineProperty(window, "innerHeight", originalInnerHeightDescriptor);
|
|
} else {
|
|
Reflect.deleteProperty(window, "innerHeight");
|
|
}
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("shows sub-issue context only when opened from a sub-issue action", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
projectId: "project-1",
|
|
goalId: "goal-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("New sub-task");
|
|
expect(container.textContent).toContain("Sub-task of");
|
|
expect(container.textContent).toContain("PAP-1");
|
|
expect(container.textContent).toContain("Parent issue");
|
|
expect(container.textContent).toContain("Create Sub-Task");
|
|
|
|
act(() => root.unmount());
|
|
|
|
dialogState.newIssueDefaults = {};
|
|
const rerendered = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("New task");
|
|
expect(container.textContent).toContain("Create Task");
|
|
expect(container.textContent).not.toContain("Sub-task of");
|
|
|
|
act(() => rerendered.root.unmount());
|
|
});
|
|
|
|
it("uses the compact composer control proportions for mobile task fields", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const compactControls = Array.from(
|
|
container.querySelectorAll<HTMLElement>('[data-slot="new-issue-compact-control"]'),
|
|
);
|
|
const prefix = compactControls.find((control) => control.textContent === "PAP");
|
|
const assignee = compactControls.find((control) => control.textContent === "Assignee");
|
|
const project = compactControls.find((control) => control.textContent === "Project");
|
|
const status = compactControls.find((control) => control.textContent?.trim() === "Todo");
|
|
const upload = compactControls.find((control) => control.textContent?.trim() === "Upload");
|
|
const mode = compactControls.find((control) => control.hasAttribute("data-issue-work-mode-chip"));
|
|
const more = container.querySelector<HTMLElement>('[data-testid="new-issue-more-menu-trigger"]');
|
|
|
|
expect(prefix?.className).toContain("p-1.5");
|
|
for (const control of [assignee, project, status, upload, mode]) {
|
|
expect(control?.className).toContain("h-8");
|
|
expect(control?.className).toContain("px-2.5");
|
|
}
|
|
expect(more?.className).toContain("size-8");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits parent and goal context for sub-issues", async () => {
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
},
|
|
},
|
|
]);
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
|
{
|
|
id: "workspace-1",
|
|
name: "Parent workspace",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "feature/pap-1",
|
|
cwd: "/tmp/workspace-1",
|
|
projectWorkspaceId: null,
|
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
|
},
|
|
]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
title: "Child issue",
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
goalId: "goal-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
await waitForAssertion(() => {
|
|
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", {
|
|
projectId: "project-1",
|
|
projectWorkspaceId: undefined,
|
|
reuseEligible: true,
|
|
});
|
|
});
|
|
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Sub-Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await waitForAssertion(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Child issue",
|
|
parentId: "issue-1",
|
|
goalId: "goal-1",
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(mockMissingUserSecretsBannerRender).not.toHaveBeenCalled();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("scopes user-secret warnings to selected runnable agent and project env bindings", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Run with scoped secrets",
|
|
assigneeAgentId: "agent-1",
|
|
projectId: "project-1",
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
name: "CodexCoder",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {
|
|
env: {
|
|
AGENT_TOKEN: { type: "user_secret_ref", key: "agent_token", required: true },
|
|
OPTIONAL_TOKEN: { type: "user_secret_ref", key: "optional_token", required: false },
|
|
},
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
env: {
|
|
PROJECT_TOKEN: { type: "user_secret_ref", key: "project_token", required: true },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(mockMissingUserSecretsBannerRender).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
definitionKeys: ["agent_token", "project_token"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("agent_token,project_token");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows Astra-only efforts when a task inherits the agent model", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Use inherited Astra",
|
|
assigneeAgentId: "agent-1",
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
name: "CodexCoder",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: { model: "gpt-6-astra" },
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(container.textContent).toContain("Codex options");
|
|
});
|
|
|
|
const codexOptionsButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Codex options"));
|
|
expect(codexOptionsButton).not.toBeUndefined();
|
|
await act(async () => {
|
|
codexOptionsButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const customLane = Array.from(container.querySelectorAll('button[role="radio"]'))
|
|
.find((button) => button.textContent?.trim() === "Custom");
|
|
expect(customLane).not.toBeUndefined();
|
|
await act(async () => {
|
|
customLane!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(container.textContent).toContain("Ultra");
|
|
expect(container.textContent).toContain("Max");
|
|
expect(container.textContent).not.toContain("Minimal");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("warns when the selected assignee is a paused imported agent", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Compare onboarding flows",
|
|
assigneeAgentId: "agent-1",
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
name: "CEO",
|
|
status: "paused",
|
|
pauseReason: "import",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(container.querySelector('[data-testid="new-issue-paused-assignee-note"]')).not.toBeNull();
|
|
});
|
|
expect(container.textContent).toContain("arrived paused from an organization import");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores the planning mode from dialog defaults", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Planned from defaults",
|
|
workMode: "planning",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const planningButton = container.querySelector('[data-issue-work-mode="planning"]');
|
|
expect(planningButton?.className).toContain("bg-accent");
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Planned from defaults",
|
|
workMode: "planning",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores ask mode from dialog defaults", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Question from defaults",
|
|
workMode: "ask",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const askButton = container.querySelector('[data-issue-work-mode="ask"]');
|
|
expect(askButton?.className).toContain("bg-accent");
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Question from defaults",
|
|
workMode: "ask",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides isolation choices and omits stale workspace draft overrides", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
mockProjectsApi.list.mockResolvedValue([{
|
|
id: "project-1", name: "Alpha", workspaces: [],
|
|
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
|
}]);
|
|
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
|
title: "Draft task", description: "", status: "todo", priority: "medium", assigneeValue: "",
|
|
reviewerValue: "", approverValue: "", projectId: "project-1",
|
|
selectedExecutionWorkspaceId: "stale-workspace", executionWorkspaceMode: "reuse_existing",
|
|
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false, workMode: "standard",
|
|
}));
|
|
const { root } = renderDialog(container, ["workspaces.isolation"]);
|
|
await flush();
|
|
expect(container.textContent).not.toContain("Execution workspace");
|
|
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
|
|
await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Managed task");
|
|
const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Create Task"));
|
|
act(() => create!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
const payload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(payload).not.toHaveProperty("executionWorkspacePreference");
|
|
expect(payload).not.toHaveProperty("executionWorkspaceSettings");
|
|
expect(payload).not.toHaveProperty("executionWorkspaceId");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each([false, true])("keeps explicit workspace launch context when isolation controls are hidden (subtask: %s)", async (subtask) => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
projectId: "project-1", executionWorkspaceId: "workspace-context",
|
|
...(subtask ? { parentId: "parent-task", parentIdentifier: "TEST-1" } : {}),
|
|
};
|
|
const { root } = renderDialog(container, ["workspaces.isolation"]);
|
|
await flush();
|
|
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
|
|
await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Context task");
|
|
const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes(subtask ? "Create Sub-Task" : "Create Task"));
|
|
act(() => create!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({
|
|
executionWorkspaceId: "workspace-context", executionWorkspacePreference: "reuse_existing",
|
|
});
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("applies project and execution workspace defaults for normal new issues", async () => {
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
workspaces: [
|
|
{
|
|
id: "project-workspace-1",
|
|
name: "Primary",
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
id: "project-workspace-2",
|
|
name: "Isolated checkout",
|
|
isPrimary: false,
|
|
},
|
|
],
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
},
|
|
},
|
|
]);
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
|
{
|
|
id: "workspace-1",
|
|
name: "PAP-100",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "feature/pap-100",
|
|
cwd: "/tmp/workspace-1",
|
|
projectWorkspaceId: "project-workspace-2",
|
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
|
},
|
|
]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
title: "Follow-up issue",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "project-workspace-2",
|
|
executionWorkspaceId: "workspace-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("New task");
|
|
expect(container.textContent).not.toContain("New sub-task");
|
|
await waitForAssertion(() => {
|
|
expect(container.textContent).toContain("Reusing PAP-100");
|
|
});
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Follow-up issue",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "project-workspace-2",
|
|
executionWorkspaceId: "workspace-1",
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps the reusable workspace search popover inside the modal", async () => {
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
workspaces: [
|
|
{
|
|
id: "project-workspace-1",
|
|
name: "Primary",
|
|
isPrimary: true,
|
|
},
|
|
],
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
},
|
|
},
|
|
]);
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
|
{
|
|
id: "workspace-1",
|
|
name: "PAP-11446-on-mobile-the-agent-chat",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "PAP-11446-on-mobile-the-agent-chat",
|
|
cwd: "/tmp/workspace-1",
|
|
projectWorkspaceId: "project-workspace-1",
|
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
|
},
|
|
]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
title: "Follow-up issue",
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
await waitForAssertion(() => {
|
|
const workspaceInput = container.querySelector('input[placeholder="Search workspaces..."]');
|
|
expect(workspaceInput?.closest("[data-disable-portal]")?.getAttribute("data-disable-portal")).toBe("true");
|
|
});
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores a description-only draft", async () => {
|
|
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
|
title: "", description: "Keep the request without a title", status: "todo", priority: "medium",
|
|
assigneeValue: "", reviewerValue: "", approverValue: "", projectId: "",
|
|
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false,
|
|
}));
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
await waitForAssertion(() => {
|
|
expect((container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement).value).toBe("Keep the request without a title");
|
|
});
|
|
const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!;
|
|
expect(submit.hasAttribute("disabled")).toBe(false);
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("creates a task from its description without requiring a title", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!;
|
|
expect(submit.hasAttribute("disabled")).toBe(true);
|
|
await typeTextareaValue(container.querySelector('textarea[aria-label="Add description..."]')!, "Investigate the sign-in redirect and fix it");
|
|
await vi.waitFor(() => expect(submit.hasAttribute("disabled")).toBe(false));
|
|
await act(async () => { submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
|
await flush();
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ description: "Investigate the sign-in redirect and fix it" }));
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).not.toHaveProperty("title");
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("submits the latest locally typed title and description", async () => {
|
|
let resolveProjects: (projects: Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
archivedAt: string | null;
|
|
color: string;
|
|
}>) => void = () => undefined;
|
|
mockProjectsApi.list.mockReturnValue(new Promise((resolve) => {
|
|
resolveProjects = resolve;
|
|
}));
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
|
|
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
|
|
expect(titleInput).not.toBeNull();
|
|
expect(descriptionInput).not.toBeNull();
|
|
|
|
await typeTextareaValue(titleInput!, "Typed issue");
|
|
await typeTextareaValue(descriptionInput!, "Typed description");
|
|
|
|
await act(async () => {
|
|
resolveProjects([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
},
|
|
]);
|
|
await Promise.resolve();
|
|
});
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Typed issue",
|
|
description: "Typed description",
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows the create-task loading state only in the submit button", async () => {
|
|
mockIssuesApi.create.mockReturnValue(new Promise(() => undefined));
|
|
dialogState.newIssueDefaults = { title: "Pending task" };
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(submitButton?.textContent).toContain("Creating...");
|
|
expect(submitButton?.getAttribute("aria-busy")).toBe("true");
|
|
expect(container.textContent).not.toContain("Creating issue");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => {
|
|
const title = "验证中文任务";
|
|
const description = [
|
|
"请用中文回复。",
|
|
"日本語: 次の手順を書いてください。",
|
|
"हिन्दी: कृपया स्थिति बताएं।",
|
|
].join("\n");
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
|
|
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
|
|
expect(titleInput).not.toBeNull();
|
|
expect(descriptionInput).not.toBeNull();
|
|
|
|
await typeTextareaValue(titleInput!, title);
|
|
await typeTextareaValue(descriptionInput!, description);
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title,
|
|
description,
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits planning work mode when planning is selected", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
|
|
expect(titleInput).not.toBeNull();
|
|
await typeTextareaValue(titleInput!, "Plan this first");
|
|
|
|
const planningButton = container.querySelector('[data-issue-work-mode="planning"]');
|
|
expect(planningButton).not.toBeNull();
|
|
await act(async () => {
|
|
planningButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Plan this first",
|
|
workMode: "planning",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits ask work mode when ask is selected", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
|
|
expect(titleInput).not.toBeNull();
|
|
await typeTextareaValue(titleInput!, "Answer this first");
|
|
|
|
const askButton = container.querySelector('[data-issue-work-mode="ask"]');
|
|
expect(askButton).not.toBeNull();
|
|
await act(async () => {
|
|
askButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Answer this first",
|
|
workMode: "ask",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cycles work modes with cmd-period", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const modeChip = () => container.querySelector("[data-issue-work-mode-chip]");
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
|
|
expect(modeChip()?.textContent).toContain("Auto mode");
|
|
|
|
await act(async () => {
|
|
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "",
|
|
key: ".",
|
|
metaKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
|
|
expect(modeChip()?.textContent).toContain("Plan mode");
|
|
|
|
await act(async () => {
|
|
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "Period",
|
|
key: ".",
|
|
metaKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("ask");
|
|
expect(modeChip()?.textContent).toContain("Ask mode");
|
|
|
|
await act(async () => {
|
|
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "Period",
|
|
key: ".",
|
|
metaKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cycles work modes when iOS reports cmd-period as Escape", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const modeChip = () => container.querySelector("[data-issue-work-mode-chip]");
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
|
|
expect(dialogContentState.onEscapeKeyDown).not.toBeNull();
|
|
|
|
const commandPeriodAsEscape = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "Escape",
|
|
metaKey: true,
|
|
});
|
|
await act(async () => {
|
|
dialogContentState.onEscapeKeyDown?.(commandPeriodAsEscape);
|
|
});
|
|
|
|
expect(commandPeriodAsEscape.defaultPrevented).toBe(true);
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
|
|
expect(dialogState.closeNewIssue).not.toHaveBeenCalled();
|
|
|
|
const plainEscape = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "Escape",
|
|
});
|
|
await act(async () => {
|
|
dialogContentState.onEscapeKeyDown?.(plainEscape);
|
|
});
|
|
|
|
expect(plainEscape.defaultPrevented).toBe(false);
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
|
|
|
|
const controlEscape = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
ctrlKey: true,
|
|
key: "Escape",
|
|
});
|
|
await act(async () => {
|
|
dialogContentState.onEscapeKeyDown?.(controlEscape);
|
|
});
|
|
|
|
expect(controlEscape.defaultPrevented).toBe(false);
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cycles work modes with ctrl-period", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const modeChip = () => container.querySelector("[data-issue-work-mode-chip]");
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
|
|
|
|
await act(async () => {
|
|
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "Period",
|
|
key: ".",
|
|
ctrlKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
title: "Child issue",
|
|
projectId: "project-1",
|
|
goalId: "goal-1",
|
|
assigneeAgentId: "agent-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Sub-Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Child issue",
|
|
parentId: "issue-1",
|
|
goalId: "goal-1",
|
|
projectId: "project-1",
|
|
assigneeAgentId: "agent-1",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const dialogContent = Array.from(container.querySelectorAll("div")).find((element) =>
|
|
typeof element.className === "string" && element.className.includes("max-h-(--new-issue-dialog-height)"),
|
|
);
|
|
expect(dialogContent?.className).toContain("h-(--new-issue-dialog-height)");
|
|
expect(dialogContent?.className).toContain("overflow-hidden");
|
|
|
|
const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]');
|
|
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
|
|
const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
|
|
typeof element.className === "string" && element.className.includes("overscroll-contain"),
|
|
);
|
|
expect(bodyScrollRegion?.className).toContain("flex-1");
|
|
expect(bodyScrollRegion?.className).toContain("overflow-y-auto");
|
|
expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true);
|
|
expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("tracks the mobile visual viewport and keeps the focused editor visible above the keyboard", async () => {
|
|
const visualViewport = new EventTarget() as EventTarget & {
|
|
height: number;
|
|
offsetTop: number;
|
|
};
|
|
visualViewport.height = 844;
|
|
visualViewport.offsetTop = 0;
|
|
Object.defineProperty(window, "visualViewport", {
|
|
configurable: true,
|
|
value: visualViewport,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 844,
|
|
});
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const dialogContent = Array.from(container.querySelectorAll<HTMLDivElement>("div")).find((element) =>
|
|
element.className.includes("max-h-(--new-issue-dialog-height)"),
|
|
);
|
|
const descriptionInput = container.querySelector<HTMLTextAreaElement>(
|
|
'textarea[aria-label="Add description..."]',
|
|
);
|
|
const scrollIntoView = vi.fn();
|
|
Object.defineProperty(descriptionInput!, "scrollIntoView", {
|
|
configurable: true,
|
|
value: scrollIntoView,
|
|
});
|
|
descriptionInput?.focus();
|
|
|
|
expect(dialogContent?.style.top).toBe("");
|
|
expect(dialogContent?.style.height).toBe("");
|
|
expect(dialogContent?.style.translate).toBe("");
|
|
|
|
visualViewport.height = 420;
|
|
visualViewport.offsetTop = 24;
|
|
await act(async () => {
|
|
visualViewport.dispatchEvent(new Event("resize"));
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-top")).toBe(
|
|
"var(--new-issue-dialog-top-gap)",
|
|
);
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-height")).toBe(
|
|
"calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))",
|
|
);
|
|
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
|
|
expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)");
|
|
expect(dialogContent?.style.translate).toBe("var(--pct-neg-50)");
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("ignores transient invalid visual viewport measurements and keeps the last valid layout", async () => {
|
|
const visualViewport = new EventTarget() as EventTarget & {
|
|
height: number;
|
|
offsetTop: number;
|
|
};
|
|
visualViewport.height = 0;
|
|
visualViewport.offsetTop = 0;
|
|
Object.defineProperty(window, "visualViewport", {
|
|
configurable: true,
|
|
value: visualViewport,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 844,
|
|
});
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const dialogContent = Array.from(container.querySelectorAll<HTMLDivElement>("div")).find((element) =>
|
|
element.className.includes("max-h-(--new-issue-dialog-height)"),
|
|
);
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("");
|
|
expect(dialogContent?.style.top).toBe("");
|
|
expect(dialogContent?.style.height).toBe("");
|
|
expect(dialogContent?.style.translate).toBe("");
|
|
|
|
visualViewport.height = 420;
|
|
visualViewport.offsetTop = 24;
|
|
await act(async () => {
|
|
visualViewport.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
|
|
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
|
|
expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)");
|
|
|
|
visualViewport.height = 0;
|
|
visualViewport.offsetTop = Number.NaN;
|
|
await act(async () => {
|
|
visualViewport.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
|
|
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
|
|
expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides the priority chip and mobile priority option (PAP-411)", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
// PAP-411: priority UI is hidden behind SHOW_TASK_PRIORITY_UI (off). Neither the
|
|
// desktop priority chip nor the mobile overflow priority option should render.
|
|
const priorityChip = container.querySelector('[data-testid="new-issue-priority-chip"]');
|
|
expect(priorityChip).toBeNull();
|
|
|
|
const highPriorityOption = container.querySelector('[data-testid="new-issue-more-priority-high"]');
|
|
expect(highPriorityOption).toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("still submits the default priority when the priority UI is hidden (PAP-411)", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Priority default persists",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
// PAP-411: the priority control is hidden, but the data-model default must survive.
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Priority default persists",
|
|
priority: "medium",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("allows editor autocomplete portal pointer events inside the modal", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const menu = document.createElement("div");
|
|
menu.setAttribute("data-paperclip-floating-ui", "");
|
|
const option = document.createElement("button");
|
|
menu.appendChild(option);
|
|
document.body.appendChild(menu);
|
|
const preventDefault = vi.fn();
|
|
|
|
dialogContentState.onPointerDownOutside?.({
|
|
detail: { originalEvent: { target: option } },
|
|
preventDefault,
|
|
});
|
|
|
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("warns when a sub-issue stops matching the parent workspace", async () => {
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
},
|
|
},
|
|
]);
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
|
{
|
|
id: "workspace-1",
|
|
name: "Parent workspace",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "feature/pap-1",
|
|
cwd: "/tmp/workspace-1",
|
|
projectWorkspaceId: null,
|
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
|
},
|
|
{
|
|
id: "workspace-2",
|
|
name: "Other workspace",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "feature/pap-2",
|
|
cwd: "/tmp/workspace-2",
|
|
projectWorkspaceId: null,
|
|
lastUsedAt: new Date("2026-04-06T16:01:00.000Z"),
|
|
},
|
|
]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
title: "Child issue",
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
parentExecutionWorkspaceLabel: "Parent workspace",
|
|
goalId: "goal-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(container.textContent).not.toContain("will no longer use the parent task workspace");
|
|
|
|
const selects = Array.from(container.querySelectorAll("select"));
|
|
const modeSelect = selects[0] as HTMLSelectElement | undefined;
|
|
expect(modeSelect).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
modeSelect!.value = "shared_workspace";
|
|
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("will no longer use the parent task workspace");
|
|
expect(container.textContent).toContain("Parent workspace");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("reveals the watchdog editor from the overflow menu", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableIsolatedWorkspaces: false,
|
|
});
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
// The watchdog row is hidden until the menu item is toggled on.
|
|
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).toBeNull();
|
|
|
|
const watchdogMenuItem = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.trim() === "Watchdog");
|
|
expect(watchdogMenuItem).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
watchdogMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("Set watchdog");
|
|
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).not.toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits the configured watchdog from a restored draft", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableIsolatedWorkspaces: false,
|
|
});
|
|
localStorage.setItem(
|
|
"paperclip:issue-draft",
|
|
JSON.stringify({
|
|
title: "Watched task",
|
|
description: "",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeValue: "",
|
|
reviewerValue: "",
|
|
approverValue: "",
|
|
watchdogAgentId: "agent-9",
|
|
watchdogInstructions: "Keep it moving",
|
|
projectId: "",
|
|
assigneeModelOverride: "",
|
|
assigneeThinkingEffort: "",
|
|
assigneeChrome: false,
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("Keep it moving");
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.textContent?.includes("Create Task"));
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Watched task",
|
|
watchdog: { agentId: "agent-9", instructions: "Keep it moving" },
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
describe("graduated work-mode labels and status hues", () => {
|
|
function workModeOption(value: string) {
|
|
return container.querySelector(`[data-issue-work-mode="${value}"]`);
|
|
}
|
|
|
|
function statusOptionIconClass(label: string, description?: string) {
|
|
const button = Array.from(container.querySelectorAll("button")).find((candidate) => {
|
|
const text = candidate.textContent ?? "";
|
|
return (
|
|
candidate.querySelector("svg") !== null &&
|
|
text.includes(label) &&
|
|
(description === undefined || text.includes(description))
|
|
);
|
|
});
|
|
return button?.querySelector("svg")?.getAttribute("class") ?? "";
|
|
}
|
|
|
|
it("uses auto-mode labels and brand status hues by default", async () => {
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(workModeOption("standard")?.textContent).toContain("Auto mode");
|
|
});
|
|
|
|
expect(workModeOption("standard")?.textContent).toContain("Auto mode");
|
|
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
|
|
expect(workModeOption("planning")?.textContent).toContain("Plan mode");
|
|
|
|
expect(statusOptionIconClass("Todo", "Executable - assignee will be woken")).toContain("text-amber-600");
|
|
expect(statusOptionIconClass("In Progress")).toContain("text-blue-600");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
});
|
|
|
|
describe("PAP-8501: company badge shows issuePrefix", () => {
|
|
it("displays issuePrefix instead of name-derived prefix", async () => {
|
|
// Override company data to have mismatched name/prefix
|
|
companyState.companies = [
|
|
{
|
|
id: "company-1",
|
|
name: "Acme Labs",
|
|
status: "active",
|
|
issuePrefix: "OPS",
|
|
},
|
|
];
|
|
companyState.selectedCompany = {
|
|
id: "company-1",
|
|
name: "Acme Labs",
|
|
status: "active",
|
|
issuePrefix: "OPS",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
const text = container.textContent ?? "";
|
|
// Should show OPS (issuePrefix), not ACM (name.slice(0,3))
|
|
expect(text).toContain("OPS");
|
|
});
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
});
|
|
});
|