import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { test, expect, type SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { assertLocatorWithinViewportX, assertNoDocumentHorizontalOverflow, } from "../../helpers/layout-assertions"; import { GITLAB_HOST, GITLAB_PROJECT, gitLabMR, seedGitLabReview } from "../../helpers/gitlab"; import { makeGitEnv } from "../../helpers/git-helper"; import { GitLabPage } from "../../pages/gitlab-page"; import { GitLabSettingsPage } from "../../pages/gitlab-settings-page"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; async function expectTouchTarget(locator: ReturnType, label: string) { const box = await locator.boundingBox(); expect(box, `${label} has no bounding box`).not.toBeNull(); if (!box) return; // Chromium can report a CSS 44px box as 43.9999 after device-scale // conversion. Compare whole CSS pixels so floating-point noise does not // turn a conforming touch target into a retry-only failure. expect(Math.round(box.width), `${label} width`).toBeGreaterThanOrEqual(44); expect(Math.round(box.height), `${label} height`).toBeGreaterThanOrEqual(44); } async function seedMultiRepoGitLabTask( apiClient: ApiClient, seedData: SeedData, tmpDir: string, mrIID: number, ) { await seedGitLabReview(apiClient, seedData.workspaceId, mrIID, "Mobile contextual GitLab MR"); await apiClient.updateRepository(seedData.repositoryId, { provider: "gitlab", provider_host: GITLAB_HOST, provider_owner: "platform", provider_name: "kandev", pull_before_worktree: false, }); const secondaryRepoDir = path.join(tmpDir, "repos", "mobile-gitlab-secondary"); fs.mkdirSync(secondaryRepoDir, { recursive: true }); const gitEnv = makeGitEnv(tmpDir); execSync("git init -b main", { cwd: secondaryRepoDir, env: gitEnv }); execSync('git commit --allow-empty -m "init"', { cwd: secondaryRepoDir, env: gitEnv }); const secondaryRepo = await apiClient.createRepository( seedData.workspaceId, secondaryRepoDir, "main", { name: "Mobile GitLab secondary", provider: "gitlab", provider_host: GITLAB_HOST, provider_owner: "platform", provider_name: "docs", pull_before_worktree: false, }, ); return apiClient.createTask(seedData.workspaceId, "Mobile contextual GitLab link", { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId, secondaryRepo.id], }); } test.describe("Mobile GitLab parity", () => { test("opens the exact linked MR selected from a multi-MR topbar", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(180_000); const primaryIID = 109; const selectedIID = 110; const selectedTitle = "Selected mobile GitLab review"; await seedGitLabReview(apiClient, seedData.workspaceId, primaryIID, "Primary mobile review"); await seedGitLabReview(apiClient, seedData.workspaceId, selectedIID, selectedTitle); await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [ gitLabMR(primaryIID, "Primary mobile review"), gitLabMR(selectedIID, selectedTitle), ]); await apiClient.updateRepository(seedData.repositoryId, { provider: "gitlab", provider_host: GITLAB_HOST, provider_owner: "platform", provider_name: "kandev", pull_before_worktree: false, }); const gitlab = new GitLabPage(testPage); await gitlab.goto(); await gitlab.startMRTask(primaryIID); const taskId = new URL(testPage.url()).pathname.match(/^\/t\/([^/]+)$/)?.[1]; if (!taskId) throw new Error(`Expected task detail URL, got ${testPage.url()}`); await apiClient.linkTaskGitLabMR(seedData.workspaceId, { task_id: taskId, repository_id: seedData.repositoryId, mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${selectedIID}`, }); await testPage.reload(); await new SessionPage(testPage).waitForLoad(); await gitlab.openLinkedMR(selectedIID); const panel = testPage.getByTestId("mr-detail-panel").last(); await expect(panel.getByText(selectedTitle, { exact: true })).toBeVisible(); const expectedReviewId = ["gitlab", GITLAB_HOST, seedData.repositoryId, String(selectedIID)] .map(encodeURIComponent) .join(":"); await expect .poll(() => testPage.evaluate( "window.__KANDEV_E2E_STORE__?.getState().mobileSession.reviewItemIdBySessionId[window.__KANDEV_E2E_STORE__?.getState().tasks.activeSessionId ?? '']", ), ) .toBe(expectedReviewId); }); test("browses, quick launches, reviews, subscribes, and unlinks without overflow", async ({ testPage, apiClient, seedData, prCapture, }) => { test.setTimeout(180_000); await seedGitLabReview(apiClient, seedData.workspaceId, 111, "Mobile GitLab review"); await apiClient.updateRepository(seedData.repositoryId, { provider: "gitlab", provider_host: GITLAB_HOST, provider_owner: "platform", provider_name: "kandev", pull_before_worktree: false, }); const gitlab = new GitLabPage(testPage); await gitlab.goto(); await expect(gitlab.mobileFiltersButton).toBeVisible(); await expect(gitlab.mobileSidebar).toBeHidden(); await expectTouchTarget(gitlab.mobileFiltersButton, "GitLab filters button"); await gitlab.mobileFiltersButton.tap(); await expect(gitlab.mobileSidebar).toBeVisible(); await gitlab.mobileSidebar.evaluate(async (element) => { await Promise.allSettled(element.getAnimations().map((animation) => animation.finished)); }); await assertLocatorWithinViewportX(gitlab.mobileSidebar, "GitLab mobile filters"); await gitlab.mobileSidebar.getByRole("button", { name: "Review requested" }).tap(); await expect(gitlab.mobileSidebar).toBeHidden(); await expect(gitlab.mrRow(111)).toContainText("Mobile GitLab review"); await assertNoDocumentHorizontalOverflow(testPage, "GitLab mobile browse"); await gitlab.startMRTask(111); const mrButton = testPage.getByTestId("mr-topbar-button"); await expect(mrButton).toHaveAttribute("data-mr-iid", "111"); await expectTouchTarget(mrButton, "linked MR button"); await gitlab.openLinkedMR(111); const panel = testPage.getByTestId("mr-detail-panel").last(); await expect(panel.getByText("Mobile GitLab review", { exact: true })).toBeVisible(); await assertLocatorWithinViewportX(panel, "mobile MR review panel"); await prCapture.screenshot("mobile-merge-request-review", { caption: "Mobile GitLab merge request review with touch-sized task controls", }); await panel.getByRole("button", { name: "Approve", exact: true }).tap(); await expect(testPage.getByText("Merge request approved", { exact: true })).toBeVisible(); await panel.getByRole("button", { name: "Subscribe to GitLab notifications" }).tap(); await expect( testPage.getByText("Subscribed to GitLab notifications", { exact: true }), ).toBeVisible(); await assertNoDocumentHorizontalOverflow(testPage, "GitLab mobile review"); await panel.getByRole("button", { name: "Unlink merge request" }).tap(); await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0); await expect(testPage.getByTestId("mobile-mr-review-panel")).toHaveCount(0); await expect(testPage.getByTestId("session-chat")).toBeVisible(); await expect(testPage.getByRole("button", { name: "Chat", exact: true })).toHaveClass( /text-primary/, ); await expect .poll(() => testPage.evaluate( "window.__KANDEV_E2E_STORE__?.getState().mobileSession.activePanelBySessionId[window.__KANDEV_E2E_STORE__?.getState().tasks.activeSessionId ?? '']", ), ) .toBe("chat"); }); test("links a GitLab MR from the visible task actions menu", async ({ testPage, apiClient, seedData, backend, }) => { await testPage.setViewportSize({ width: 393, height: 851 }); const mrIID = 113; const title = "Mobile contextual GitLab link"; const task = await seedMultiRepoGitLabTask(apiClient, seedData, backend.tmpDir, mrIID); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0); await testPage.getByTestId("mobile-session-menu").click(); const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ hasText: title }); await expect(taskRow).toBeVisible({ timeout: 10_000 }); const taskActions = taskRow.getByRole("button", { name: "Task actions" }); await expect(taskActions).toBeVisible(); await expectTouchTarget(taskActions, "task actions button"); await taskActions.click(); const linkTrigger = testPage.getByRole("menuitem", { name: "Link", exact: true }); await expect(linkTrigger).toBeVisible(); await linkTrigger.click(); const gitLabItem = testPage.getByRole("menuitem", { name: "GitLab Merge Request" }); await expect(gitLabItem).toBeVisible(); await expectTouchTarget(gitLabItem, "GitLab merge request link action"); const nestedMenu = gitLabItem.locator("xpath=ancestor::*[@role='menu'][1]"); await nestedMenu.evaluate((element) => Promise.all( element .getAnimations({ subtree: true }) .map((animation) => animation.finished.catch(() => undefined)), ), ); const menuBox = await nestedMenu.boundingBox(); const viewport = testPage.viewportSize(); if (!menuBox || !viewport) throw new Error("mobile GitLab link menu has no layout box"); expect(menuBox.x).toBeGreaterThanOrEqual(8); expect(menuBox.x).toBeLessThanOrEqual(18); expect(menuBox.width).toBeGreaterThanOrEqual(viewport.width - 36); expect(viewport.width - (menuBox.x + menuBox.width)).toBeGreaterThanOrEqual(8); expect(viewport.width - (menuBox.x + menuBox.width)).toBeLessThanOrEqual(18); expect(menuBox.y).toBeGreaterThanOrEqual(0); expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(viewport.height); expect(viewport.height - (menuBox.y + menuBox.height)).toBeGreaterThanOrEqual(8); await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab task link menu"); await gitLabItem.click(); const linkDialog = testPage.getByRole("dialog", { name: "Link GitLab merge request" }); await expect(linkDialog).toBeVisible(); await assertLocatorWithinViewportX(linkDialog, "mobile GitLab link dialog"); await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab link dialog"); await expect(linkDialog.getByRole("combobox", { name: "Task repository" })).toContainText( GITLAB_PROJECT, ); await linkDialog .getByLabel("Merge request URL") .fill(`${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${mrIID}`); await linkDialog.getByRole("button", { name: "Link merge request" }).click(); const mrButton = testPage.getByTestId("mr-topbar-button"); await expect(mrButton).toHaveAttribute("data-mr-iid", String(mrIID)); await expect(mrButton).toHaveAttribute("data-mr-state", "opened"); const response = await apiClient.rawRequest("GET", `/api/v1/gitlab/tasks/${task.id}/mrs`); expect(response.ok).toBe(true); const linked = (await response.json()) as { task_mrs: Array<{ repository_id?: string }>; }; expect(linked.task_mrs).toEqual([ expect.objectContaining({ repository_id: seedData.repositoryId }), ]); }); test("watch controls remain touch sized and persist a pause", async ({ testPage, apiClient, seedData, prCapture, }) => { test.setTimeout(120_000); await apiClient.configureGitLab(seedData.workspaceId); const watch = await apiClient.createGitLabReviewWatch({ workspace_id: seedData.workspaceId, workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, agent_profile_id: seedData.agentProfileId, executor_profile_id: seedData.worktreeExecutorProfileId, projects: [{ path: GITLAB_PROJECT }], }); await expect .poll(async () => { const response = await apiClient.rawRequest( "GET", `/api/v1/gitlab/watches/review?workspace_id=${encodeURIComponent(seedData.workspaceId)}`, ); const body = (await response.json()) as { watches: Array<{ id: string; last_polled_at?: string }>; }; return body.watches.find((item) => item.id === watch.id)?.last_polled_at ?? null; }) .not.toBeNull(); await seedGitLabReview(apiClient, seedData.workspaceId, 112, "Mobile watch dispatch MR"); const settings = new GitLabSettingsPage(testPage); await settings.goto(seedData.workspaceId); const mobileList = settings.reviewWatches.getByTestId("gitlab-watch-mobile-list"); await expect(mobileList).toBeVisible(); await expect(mobileList.getByText(GITLAB_PROJECT, { exact: true })).toBeVisible(); const pause = mobileList.getByRole("button", { name: "Pause watch" }); const check = mobileList.getByRole("button", { name: "Check now" }); await expectTouchTarget(pause, "pause watch button"); await expectTouchTarget(check, "check watch button"); await check.tap(); await expect(testPage.getByText(/Found 1 matching merge request/)).toBeVisible(); const taskTitle = `[${GITLAB_PROJECT}!112] Mobile watch dispatch MR`; const kanban = new KanbanPage(testPage); await kanban.goto(); await expect(kanban.taskCardByTitle(taskTitle)).toHaveCount(1, { timeout: 20_000, }); await settings.goto(seedData.workspaceId); await check.tap(); await expect( testPage.getByText("No new merge requests matched", { exact: true }), ).toBeVisible(); await kanban.goto(); await expect(kanban.taskCardByTitle(taskTitle)).toHaveCount(1); await settings.goto(seedData.workspaceId); await pause.tap(); const save = testPage .getByTestId("settings-floating-save") .getByRole("button", { name: /save changes/i }); await expect(save).toBeEnabled(); await save.tap(); await expect(mobileList.getByText("Paused", { exact: true })).toBeVisible(); await expect(check).toBeDisabled(); await prCapture.screenshot("mobile-review-watch-paused", { caption: "Mobile review watch remains paused after saving", }); await assertLocatorWithinViewportX(mobileList, "mobile watch list"); await assertNoDocumentHorizontalOverflow(testPage, "GitLab mobile watch settings"); }); // @covers AC-UI-MOBILE-TASK-CHROME-001.4 test("creates and auto-links an MR with GitLab terminology", async ({ testPage, apiClient, seedData, backend, }) => { test.setTimeout(120_000); const remoteURL = `${backend.baseUrl}/${GITLAB_PROJECT}.git`; await apiClient.configureGitLab(seedData.workspaceId, backend.baseUrl); await apiClient.configureGitLabRepositoryRemote(seedData.repositoryId, remoteURL); await apiClient.updateRepository(seedData.repositoryId, { provider: "gitlab", provider_host: backend.baseUrl, provider_owner: "platform", provider_name: "kandev", pull_before_worktree: false, }); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Create mobile GitLab MR", seedData.agentProfileId, { description: "/e2e:diff-update-setup", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], executor_profile_id: seedData.worktreeExecutorProfileId, }, ); if (!task.session_id) throw new Error("Mobile GitLab creation task did not return a session"); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); await expect( session.chat.getByText("diff-update-setup complete", { exact: false }), ).toBeVisible({ timeout: 45_000, }); await testPage.getByRole("button", { name: "Changes" }).tap(); const changes = testPage.getByTestId("mobile-changes-panel"); const createMR = changes.getByTestId("commits-repo-create-pr"); await expect(createMR).toBeVisible(); await createMR.tap(); const dialog = testPage.getByRole("dialog", { name: "Create merge request" }); await expect(dialog).toBeVisible(); await assertLocatorWithinViewportX(dialog, "mobile create MR dialog"); await dialog .getByRole("textbox", { name: "Merge Request title", exact: true }) .fill("Mobile runtime-created GitLab MR"); await dialog .getByRole("textbox", { name: "Description", exact: true }) .fill("Created from the mobile GitLab flow."); const draft = dialog.getByLabel("Create as draft"); await expect(draft).toBeChecked(); await dialog.getByRole("button", { name: "Create MR", exact: true }).tap(); await expect .poll(async () => { try { return (await apiClient.getGitLabPushRecord(seedData.repositoryId)).args; } catch { return ""; } }) .toBe("push --set-upstream origin HEAD"); const mrButton = testPage.getByTestId("mr-topbar-button"); await expect(mrButton).toHaveAttribute("data-mr-iid", "100", { timeout: 120_000 }); await expectTouchTarget(mrButton, "mobile auto-linked MR"); await testPage.reload(); await expect(testPage.getByTestId("mr-topbar-button")).toHaveAttribute("data-mr-iid", "100", { timeout: 60_000, }); await assertNoDocumentHorizontalOverflow(testPage, "mobile created MR task"); }); });