diff --git a/apps/web/components/task-create-dialog-remote-repo-chip-label.test.ts b/apps/web/components/task-create-dialog-remote-repo-chip-label.test.ts new file mode 100644 index 0000000000..1ba828233a --- /dev/null +++ b/apps/web/components/task-create-dialog-remote-repo-chip-label.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { TaskRemoteRepoRow } from "./task-create-dialog-types"; +import { computeTriggerLabel } from "./task-create-dialog-remote-repo-chip"; + +function row(overrides: Partial = {}): TaskRemoteRepoRow { + return { key: "remote-0", url: "", branch: "", source: "paste", ...overrides }; +} + +describe("computeTriggerLabel", () => { + it("returns the empty-state placeholder when url is empty", () => { + expect(computeTriggerLabel(row())).toBe("Pick or paste a repo"); + }); + + it("returns picker fullName when source is 'picker' and metadata is present", () => { + expect( + computeTriggerLabel( + row({ + url: "https://github.com/octocat/hello-world", + source: "picker", + provider: "github", + fullName: "octocat/hello-world", + }), + ), + ).toBe("octocat/hello-world"); + }); + + it("returns short paste URLs verbatim", () => { + const label = computeTriggerLabel(row({ url: "github.com/x/y", source: "paste" })); + expect(label).toBe("github.com/x/y"); + expect(label).not.toContain("\u2026"); + }); + + it("middle-truncates long paste URLs while preserving first and last chars", () => { + const url = "github.com/some-very-long-org/some-very-long-repo-name"; + const label = computeTriggerLabel(row({ url, source: "paste" })); + expect(label).toContain("\u2026"); + expect(label.length).toBeLessThan(url.length); + expect(label.startsWith(url[0]!)).toBe(true); + expect(label.endsWith(url[url.length - 1]!)).toBe(true); + }); + + it("strips https:// and www. prefixes from paste labels", () => { + expect( + computeTriggerLabel(row({ url: "https://www.github.com/foo/bar", source: "paste" })), + ).toBe("github.com/foo/bar"); + }); +}); diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx index d97c810e46..54b366b114 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx @@ -31,11 +31,13 @@ function makeAccessible( }; } -import { RemoteRepoChip, computeTriggerLabel } from "./task-create-dialog-remote-repo-chip"; +import { RemoteRepoChip } from "./task-create-dialog-remote-repo-chip"; const TRIGGER_TID = "remote-repo-chip-trigger"; +const INPUT_TID = "remote-repo-input"; const FULL_NAME = "acme/site"; const URL_ACME_SITE = "https://github.com/acme/site"; +const URL_FOO_BAR_PR = "https://github.com/foo/bar/pull/42"; afterEach(() => { cleanup(); @@ -87,46 +89,6 @@ describe("RemoteRepoChip — write paths", () => { }); }); - it("paste input writes URL with source=paste (no metadata) on Enter", () => { - const onURLChange = vi.fn(); - renderInProvider( - , - ); - fireEvent.click(screen.getByTestId(TRIGGER_TID)); - const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement; - fireEvent.change(input, { target: { value: "https://github.com/acme/api" } }); - fireEvent.keyDown(input, { key: "Enter" }); - expect(onURLChange).toHaveBeenCalledWith("https://github.com/acme/api", "paste"); - }); - - it("paste input also commits on blur", () => { - const onURLChange = vi.fn(); - renderInProvider( - , - ); - fireEvent.click(screen.getByTestId(TRIGGER_TID)); - const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement; - fireEvent.change(input, { target: { value: "https://github.com/foo/bar" } }); - fireEvent.blur(input); - expect(onURLChange).toHaveBeenCalledWith("https://github.com/foo/bar", "paste"); - }); - it("calls onRemove when the X button is clicked", () => { const onRemove = vi.fn(); renderInProvider( @@ -145,12 +107,138 @@ describe("RemoteRepoChip — write paths", () => { }); }); -describe("RemoteRepoChip — paste/picker race", () => { - it("picker click after typing in paste input does not trigger paste commit", () => { - // Race: user types into paste input, then clicks a picker option. The - // input's onBlur fires first (focus moves to the option button). Without - // the guard, blur would commit the typed value AND close the popover, - // and the subsequent picker click would be dropped. +describe("RemoteRepoChip — URL entry", () => { + it("writes a GitHub URL with source=paste on Enter", () => { + const onURLChange = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: "https://github.com/acme/api" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onURLChange).toHaveBeenCalledWith("https://github.com/acme/api", "paste"); + }); + + it("commits a pasted GitHub URL immediately", () => { + const onURLChange = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.paste(input, { + clipboardData: { getData: () => URL_FOO_BAR_PR }, + }); + expect(onURLChange).toHaveBeenCalledWith(URL_FOO_BAR_PR, "paste"); + }); + + it("commits a typed GitHub URL on blur", () => { + const onURLChange = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: "https://github.com/foo/bar/issues/42" } }); + fireEvent.blur(input); + expect(onURLChange).toHaveBeenCalledWith("https://github.com/foo/bar/issues/42", "paste"); + }); + + it("commits a typed GitHub PR URL when Tab cannot move focus out of the popover", () => { + const onURLChange = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: URL_FOO_BAR_PR } }); + fireEvent.keyDown(input, { key: "Tab" }); + expect(onURLChange).toHaveBeenCalledWith(URL_FOO_BAR_PR, "paste"); + }); + + it("surfaces an inline error when a URL-shaped non-GitHub value is submitted", () => { + const onURLChange = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: "https://gitlab.com/acme/api" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(screen.getByRole("alert").textContent).toContain("Enter a GitHub repository URL"); + expect(onURLChange).not.toHaveBeenCalled(); + }); +}); + +describe("RemoteRepoChip — unified search", () => { + it("uses ordinary text as repository search without committing it on blur", () => { + const onURLChange = vi.fn(); + const search = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: "acme" } }); + fireEvent.blur(input); + expect(search).toHaveBeenLastCalledWith("acme"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(onURLChange).not.toHaveBeenCalled(); + }); + + it("picker click after searching commits only the selected repository", () => { const accessibleRepos = makeAccessible({ repos: [ { @@ -176,10 +264,8 @@ describe("RemoteRepoChip — paste/picker race", () => { />, ); fireEvent.click(screen.getByTestId(TRIGGER_TID)); - const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement; - fireEvent.change(input, { target: { value: "https://github.com/typed/value" } }); - // Simulate focus moving to the picker option before the click lands — - // this is the exact ordering the browser produces (blur → click). + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: "acme" } }); const option = screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement; fireEvent.blur(input, { relatedTarget: option }); fireEvent.click(option); @@ -192,6 +278,48 @@ describe("RemoteRepoChip — paste/picker race", () => { }); }); +describe("RemoteRepoChip — picker focus", () => { + it("does not commit a typed URL on blur when focus moves to a repository option", () => { + const accessibleRepos = makeAccessible({ + repos: [ + { + provider: "github", + owner: "acme", + name: "site", + full_name: FULL_NAME, + default_branch: "main", + private: false, + }, + ], + }); + const onURLChange = vi.fn(); + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: URL_ACME_SITE } }); + const option = screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement; + fireEvent.blur(input, { relatedTarget: option }); + fireEvent.click(option); + + expect(onURLChange).toHaveBeenCalledTimes(1); + expect(onURLChange).toHaveBeenCalledWith(URL_ACME_SITE, "picker", { + provider: "github", + fullName: FULL_NAME, + defaultBranch: "main", + }); + }); +}); + describe("RemoteRepoChip — branch pill", () => { it("is disabled when the URL is empty", () => { renderInProvider( @@ -305,6 +433,7 @@ describe("RemoteRepoChip — picker loading state", () => { fireEvent.click(screen.getByTestId(TRIGGER_TID)); const loadingNode = screen.getByTestId("remote-repo-picker-loading"); expect(loadingNode.textContent).toContain("Loading repositories"); + expect(loadingNode.parentElement?.className).toContain("h-56"); }); it("does NOT render the spinner once repos have loaded (even if loading flips true again later)", () => { @@ -335,9 +464,67 @@ describe("RemoteRepoChip — picker loading state", () => { expect(screen.queryByTestId("remote-repo-picker-loading")).toBeNull(); expect(screen.getByText(FULL_NAME)).toBeTruthy(); }); + + it("does not render a loading spinner with the GitHub connection banner", () => { + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + expect(screen.getByText(/Connect a GitHub account/i)).toBeTruthy(); + expect(screen.queryByTestId("remote-repo-picker-loading")).toBeNull(); + }); + + it("shows only the connection banner for invalid URL input when GitHub is unavailable", () => { + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + fireEvent.change(input, { target: { value: "https://gitlab.com/acme/api" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(screen.getByText(/Connect a GitHub account/i)).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(input.getAttribute("aria-invalid")).toBeNull(); + }); }); describe("RemoteRepoChip — popover content", () => { + it("renders one top-level input for both repository search and GitHub URLs", () => { + renderInProvider( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + const input = screen.getByTestId(INPUT_TID) as HTMLInputElement; + expect(input.placeholder).toBe("Search repositories or paste a GitHub URL"); + expect(screen.queryByTestId("remote-repo-search")).toBeNull(); + expect(screen.queryByTestId("remote-paste-url-input")).toBeNull(); + }); + it("portals and constrains the repo picker so dialog overflow cannot clip it", () => { renderInProvider(
@@ -357,7 +544,7 @@ describe("RemoteRepoChip — popover content", () => { expect(screen.getByTestId("clipping-host").contains(content)).toBe(false); expect(content.className).toContain("max-w-[calc(100vw-2rem)]"); expect(content.className).toContain("max-h-[min(420px,calc(100vh-12rem))]"); - expect(content.className).toContain("overflow-y-auto"); + expect(content.className).toContain("overflow-hidden"); }); it("renders the 'Connect GitHub' banner when accessibleRepos.unavailable=true", () => { @@ -444,44 +631,3 @@ describe("RemoteRepoChip — trigger label", () => { expect(screen.getByTestId(TRIGGER_TID).textContent).toContain("github.com/foo/bar"); }); }); - -describe("computeTriggerLabel", () => { - it("returns the empty-state placeholder when url is empty", () => { - expect(computeTriggerLabel(row())).toBe("Pick or paste a repo"); - }); - - it("returns picker fullName when source is 'picker' and metadata is present", () => { - expect( - computeTriggerLabel( - row({ - url: "https://github.com/octocat/hello-world", - source: "picker", - provider: "github", - fullName: "octocat/hello-world", - }), - ), - ).toBe("octocat/hello-world"); - }); - - it("returns short paste URLs verbatim (no ellipsis under threshold)", () => { - const label = computeTriggerLabel(row({ url: "github.com/x/y", source: "paste" })); - expect(label).toBe("github.com/x/y"); - expect(label).not.toContain("…"); - }); - - it("middle-truncates long paste URLs while preserving first and last chars", () => { - const long = "github.com/some-very-long-org/some-very-long-repo-name"; - const stripped = "github.com/some-very-long-org/some-very-long-repo-name"; // no scheme to strip - const label = computeTriggerLabel(row({ url: long, source: "paste" })); - expect(label).toContain("…"); - expect(label.length).toBeLessThan(stripped.length); - expect(label.startsWith(stripped[0]!)).toBe(true); - expect(label.endsWith(stripped[stripped.length - 1]!)).toBe(true); - }); - - it("strips https:// and www. prefixes from paste labels", () => { - expect( - computeTriggerLabel(row({ url: "https://www.github.com/foo/bar", source: "paste" })), - ).toBe("github.com/foo/bar"); - }); -}); diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.tsx index 05cffb0503..515e69b547 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.tsx @@ -18,7 +18,7 @@ import { import { scoreBranch } from "@/lib/utils/branch-filter"; import type { UseAccessibleReposResult } from "@/hooks/domains/github/use-accessible-repos"; import type { AccessibleRepo } from "@/lib/api/domains/github-api"; -import type { PRInfo } from "@/hooks/domains/github/use-pr-info-by-url"; +import { parseGitHubAnyUrl, type PRInfo } from "@/hooks/domains/github/use-pr-info-by-url"; import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types"; import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container"; @@ -79,8 +79,8 @@ export type RemoteRepoChipProps = { * * [ repo pill ] [ branch pill ] [X] * - * The repo pill opens a custom popover with two sections (an autocomplete - * search over the user's accessible GitHub repos, and a paste-a-URL input). + * The repo pill opens a custom popover with one input that searches the + * user's accessible GitHub repos or accepts a pasted GitHub URL. * The branch pill is the shared `Pill` primitive over the per-URL branches * the parent loads via `branchesByUrl`. */ @@ -220,7 +220,7 @@ function RemoteRepoPill({ @@ -278,70 +278,110 @@ function RemoteRepoPopoverContent({ onPick: (repo: AccessibleRepo) => void; onPaste: (value: string) => void; }) { - const [search, setSearch] = useState(""); - // Destructure the stable `search` callback so the effect's dep array is - // not invalidated on every render of the parent (the hook's result object - // identity changes each render, but `search` is a useCallback). The hook - // itself owns the 250ms debounce so we just forward the latest value. + const [value, setValue] = useState(""); + const [urlError, setUrlError] = useState(null); const { search: triggerSearch } = accessible; useEffect(() => { - triggerSearch(search); - }, [search, triggerSearch]); + triggerSearch(value); + }, [value, triggerSearch]); + + const commitURL = (candidate: string) => { + const trimmed = candidate.trim(); + if (!parseGitHubAnyUrl(trimmed)) { + if (looksLikeURL(trimmed)) { + setUrlError("Enter a GitHub repository URL, such as github.com/owner/repo."); + } + return false; + } + setUrlError(null); + onPaste(trimmed); + return true; + }; + const visibleUrlError = accessible.unavailable ? null : urlError; + return (
- { + setValue(event.target.value); + setUrlError(null); + }} + onBlur={(event) => { + const popoverContent = event.currentTarget.closest('[data-slot="popover-content"]'); + if ( + popoverContent && + event.relatedTarget instanceof Node && + popoverContent.contains(event.relatedTarget) + ) { + return; + } + commitURL(value); + }} + onPaste={(event) => { + const pasted = event.clipboardData.getData("text"); + const isURL = looksLikeURL(pasted.trim()); + if (!commitURL(pasted) && !isURL) return; + event.preventDefault(); + setValue(pasted.trim()); + }} + onKeyDown={(event) => { + if (event.key === "Tab") { + commitURL(value); + return; + } + if (event.key !== "Enter") return; + const isURL = looksLikeURL(value.trim()); + if (commitURL(value) || isURL) event.preventDefault(); + }} + placeholder="Search repositories or paste a GitHub URL" + aria-label="Search repositories or paste a GitHub URL" + aria-invalid={visibleUrlError ? true : undefined} + data-testid="remote-repo-input" + data-legacy-testid="remote-paste-url-input" + className={cn( + "h-11 sm:h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60", + "outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground", + visibleUrlError && "border-destructive focus:border-destructive", + )} /> -
- +
); } -function PickerSection({ - accessible, - search, - onSearchChange, - onPick, -}: { - accessible: UseAccessibleReposResult; - search: string; - onSearchChange: (v: string) => void; - onPick: (repo: AccessibleRepo) => void; -}) { - if (accessible.unavailable) return ; - return ( -
- onSearchChange(e.target.value)} - placeholder="Search your GitHub repos…" - data-testid="remote-repo-search" - className={cn( - "h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60", - "outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground", - )} - /> - -
- ); +function looksLikeURL(value: string): boolean { + if (!value) return false; + const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`; + try { + const parsed = new URL(withScheme); + return parsed.hostname.includes(".") && value.includes("/"); + } catch { + return false; + } } function PickerList({ accessible, onPick, + urlError, }: { accessible: UseAccessibleReposResult; onPick: (repo: AccessibleRepo) => void; + urlError: string | null; }) { const { repos, loading, error } = accessible; return ( -
- {loading && repos.length === 0 ? ( +
+ {urlError ? ( +
+ {urlError} +
+ ) : null} + {accessible.unavailable ? : null} + {!accessible.unavailable && loading && repos.length === 0 ? (
Loading repositories…
) : null} - {!loading && repos.length === 0 && !error ? ( + {!accessible.unavailable && !loading && repos.length === 0 && !error ? (
No repositories found.
) : null} {error ? ( @@ -378,7 +418,7 @@ function RepoOption({ onClick={() => onPick(repo)} data-testid="remote-repo-option" className={cn( - "flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs", + "flex min-h-11 sm:min-h-8 w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs", "hover:bg-muted cursor-pointer text-left", )} > @@ -407,55 +447,6 @@ function ConnectGitHubBanner() { ); } -function PasteSection({ onPaste }: { onPaste: (value: string) => void }) { - const [value, setValue] = useState(""); - const commit = () => { - const trimmed = value.trim(); - if (!trimmed) return; - onPaste(trimmed); - }; - return ( -
- - setValue(e.target.value)} - onBlur={(e) => { - // If focus moved to another element inside this popover (e.g., a - // picker option click), let that handler run instead of committing - // the paste — otherwise the popover would close before the click - // lands and the user's pick would be lost. - const popoverContent = e.currentTarget.closest('[data-slot="popover-content"]'); - if ( - popoverContent && - e.relatedTarget instanceof Node && - popoverContent.contains(e.relatedTarget) - ) { - return; - } - commit(); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commit(); - } - }} - placeholder="github.com/owner/repo, .../pull/123, or .../issues/123" - data-testid="remote-paste-url-input" - className={cn( - "h-8 rounded-md px-2 text-xs bg-muted/30 border border-border/60", - "outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground", - )} - /> -
- ); -} - // --- Branch pill ------------------------------------------------------------- function RemoteBranchPill({ diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts index 1092029703..454e31a764 100644 --- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts +++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts @@ -161,7 +161,7 @@ test.describe("Branch selector behavior with executor types", () => { // Switch to Remote tab and paste via the chip popover. await testPage.getByTestId("source-mode-remote").click(); await testPage.getByTestId("remote-repo-chip-trigger").first().click(); - const pasteInput = testPage.getByTestId("remote-paste-url-input"); + const pasteInput = testPage.getByTestId("remote-repo-input"); await pasteInput.fill("https://github.com/branch-test-owner/branch-test-repo"); await pasteInput.press("Enter"); @@ -433,7 +433,7 @@ test.describe("Fresh-branch flow", () => { // Switch to Remote tab and paste via the chip popover. await testPage.getByTestId("source-mode-remote").click(); await testPage.getByTestId("remote-repo-chip-trigger").first().click(); - const pasteInput = testPage.getByTestId("remote-paste-url-input"); + const pasteInput = testPage.getByTestId("remote-repo-input"); await pasteInput.fill("https://github.com/branch-test-owner/branch-test-repo"); await pasteInput.press("Enter"); await testPage.getByTestId("executor-profile-selector").click(); diff --git a/apps/web/e2e/tests/task/create-task-github-url.spec.ts b/apps/web/e2e/tests/task/create-task-github-url.spec.ts index d9db80e0a5..40db500048 100644 --- a/apps/web/e2e/tests/task/create-task-github-url.spec.ts +++ b/apps/web/e2e/tests/task/create-task-github-url.spec.ts @@ -8,7 +8,7 @@ import { SessionPage } from "../../pages/session-page"; useRegularMode(); /** - * Helper: switch the create-task dialog to the Remote tab and paste a URL + * Helper: switch the create-task dialog to the Remote tab and type a URL * into the (newly nested) chip popover. Replaces the old "click * toggle-github-url + fill github-url-input" pair after Task 5/8 swapped the * top-level URL input for a per-row chip + popover. @@ -17,10 +17,10 @@ async function openRemoteAndPasteURL(testPage: Page, url: string): Promise await testPage.getByTestId("source-mode-remote").click(); // The chip popover holds the paste input. Open the first chip. await testPage.getByTestId("remote-repo-chip-trigger").first().click(); - const pasteInput = testPage.getByTestId("remote-paste-url-input"); - await expect(pasteInput).toBeVisible(); - await pasteInput.fill(url); - await pasteInput.press("Enter"); + const urlInput = testPage.getByTestId("remote-repo-input"); + await expect(urlInput).toBeVisible(); + await urlInput.fill(url); + await urlInput.press("Tab"); } test.describe("Task creation from GitHub URL", () => { diff --git a/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts b/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts index 964b499b8f..96caa56173 100644 --- a/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts +++ b/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts @@ -8,8 +8,8 @@ import { KanbanPage } from "../../pages/kanban-page"; // rows, add/remove rows, the "GitHub not configured" banner, and mode-switch // preservation. Modeled on create-task-github-url.spec.ts but exercises the // new chip-popover UI (testids: source-mode-remote, remote-repo-chip, -// remote-repo-chip-trigger, remote-paste-url-input, remote-add-row, -// remote-chip-remove, remote-repo-search, remote-repo-option). +// remote-repo-chip-trigger, remote-repo-input, remote-add-row, +// remote-chip-remove, remote-repo-option). type TaskRepoAPI = { repositories?: Array<{ @@ -90,7 +90,7 @@ async function pasteUrlInChip(testPage: Page, url: string, chipIndex = 0): Promi // multiple chips have popovers open (or Radix is mid-transition between // opens), the same testid can briefly resolve to more than one element, // so target the last (currently focused) one. - const pasteInput = testPage.getByTestId("remote-paste-url-input").last(); + const pasteInput = testPage.getByTestId("remote-repo-input").last(); await expect(pasteInput).toBeVisible(); await pasteInput.fill(url); await pasteInput.press("Enter"); @@ -117,6 +117,55 @@ test.describe("Task creation from Remote tab (chip picker)", () => { await apiClient.mockGitHubReset(); }); + test("keeps the unified input fixed while repositories load", async ({ testPage, apiClient }) => { + await seedAccessibleRepos(apiClient); + let releaseRepos = () => undefined; + const reposGate = new Promise((resolve) => { + releaseRepos = resolve; + }); + await testPage.route("**/api/v1/github/repos?*", async (route) => { + await reposGate; + await route.continue(); + }); + + const kanban = new KanbanPage(testPage); + await openCreateDialog(testPage, kanban); + await clickRemoteMode(testPage); + await testPage.getByTestId("remote-repo-chip-trigger").first().click(); + + const popover = testPage.getByTestId("remote-repo-popover-content"); + const input = testPage.getByTestId("remote-repo-input"); + await expect(testPage.getByTestId("remote-repo-picker-loading")).toBeVisible(); + // Measure the loading state after the finite popover entrance animation. + // Otherwise its scale transform makes the first box a few pixels smaller + // than the loaded-state box even though the layout itself never shifts. + await popover.evaluate(async (element) => { + await Promise.all( + element.getAnimations().map((animation) => animation.finished.catch(() => undefined)), + ); + }); + const [loadingPopoverBox, loadingInputBox] = await Promise.all([ + popover.boundingBox(), + input.boundingBox(), + ]); + + releaseRepos(); + await expect( + testPage.getByTestId("remote-repo-option").filter({ hasText: "mock-user/alpha" }), + ).toBeVisible(); + const [loadedPopoverBox, loadedInputBox] = await Promise.all([ + popover.boundingBox(), + input.boundingBox(), + ]); + + expect(loadingPopoverBox).not.toBeNull(); + expect(loadingInputBox).not.toBeNull(); + expect(loadedPopoverBox).not.toBeNull(); + expect(loadedInputBox).not.toBeNull(); + expect(Math.abs(loadedPopoverBox!.height - loadingPopoverBox!.height)).toBeLessThanOrEqual(2); + expect(Math.abs(loadedInputBox!.y - loadingInputBox!.y)).toBeLessThanOrEqual(2); + }); + test("scenario 1: picker selection submits one repo", async ({ testPage, apiClient, @@ -238,7 +287,7 @@ test.describe("Task creation from Remote tab (chip picker)", () => { await testPage.getByTestId("remote-repo-chip-trigger").first().click(); await expectPopoverFitsDialog(testPage); - const pasteInput = testPage.getByTestId("remote-paste-url-input").last(); + const pasteInput = testPage.getByTestId("remote-repo-input").last(); await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456"); await pasteInput.press("Enter"); @@ -407,7 +456,7 @@ test.describe("Task creation from Remote tab (chip picker)", () => { await expect(settingsLink).toBeVisible(); // The paste input is still rendered and usable — paste a URL. - const pasteInput = testPage.getByTestId("remote-paste-url-input"); + const pasteInput = testPage.getByTestId("remote-repo-input"); await expect(pasteInput).toBeVisible(); await pasteInput.fill("https://github.com/banner-owner/banner-repo"); await pasteInput.press("Enter"); diff --git a/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts b/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts index 9c7ad9f1f7..cc0ca35cad 100644 --- a/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts +++ b/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts @@ -59,7 +59,7 @@ test.describe("Create-task URL flow - branches after reopen", () => { // inside the chip in Task 5/8). await testPage.getByTestId("source-mode-remote").click(); await testPage.getByTestId("remote-repo-chip-trigger").first().click(); - const pasteInput = testPage.getByTestId("remote-paste-url-input"); + const pasteInput = testPage.getByTestId("remote-repo-input"); await pasteInput.fill(`https://github.com/${repoFullName}`); await pasteInput.press("Enter"); diff --git a/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts index 44f5f1cc0e..bb4f2c72ea 100644 --- a/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts +++ b/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts @@ -13,12 +13,19 @@ async function openRemotePicker(testPage: Page): Promise { async function expectPopoverFitsViewport(testPage: Page): Promise { const viewport = testPage.viewportSize(); - const box = await testPage.getByTestId("remote-repo-popover-content").boundingBox(); + const input = testPage.getByTestId("remote-repo-input"); + const [box, inputBox] = await Promise.all([ + testPage.getByTestId("remote-repo-popover-content").boundingBox(), + input.boundingBox(), + ]); expect(viewport).not.toBeNull(); expect(box).not.toBeNull(); + expect(inputBox).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width); expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height); + expect(inputBox!.y - box!.y).toBeLessThan(16); + await expect(input).toHaveCSS("height", "44px"); } test.describe("Create task Remote repo picker on mobile", () => { @@ -43,7 +50,7 @@ test.describe("Create task Remote repo picker on mobile", () => { await openRemotePicker(testPage); await expectPopoverFitsViewport(testPage); - const pasteInput = testPage.getByTestId("remote-paste-url-input").last(); + const pasteInput = testPage.getByTestId("remote-repo-input").last(); await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456"); await pasteInput.press("Enter"); diff --git a/apps/web/e2e/tests/task/subtask.spec.ts b/apps/web/e2e/tests/task/subtask.spec.ts index d72029f3eb..f385cbb557 100644 --- a/apps/web/e2e/tests/task/subtask.spec.ts +++ b/apps/web/e2e/tests/task/subtask.spec.ts @@ -656,7 +656,7 @@ test.describe("Subtask dialog feature parity", () => { const chipTrigger = testPage.getByTestId("remote-repo-chip-trigger").first(); await expect(chipTrigger).toBeVisible({ timeout: 5_000 }); await chipTrigger.click(); - const urlInput = testPage.getByTestId("remote-paste-url-input").last(); + const urlInput = testPage.getByTestId("remote-repo-input").last(); await expect(urlInput).toBeVisible({ timeout: 5_000 }); await urlInput.fill("https://github.com/subtask-owner/subtask-repo"); await urlInput.press("Enter");