import { expect, test } from "@playwright/test"; import { selectAppOption } from "./helpers/app-select"; import { stubFlowsPage, primaryModifier, } from "./helpers/flows-pane-keyboard-fixtures"; import type { FlowNode } from "../../lib/types"; const installations = [ { id: "inst-1", installation_id: 101, account_login: "webrenew", account_type: "Organization" as const, repositories: [{ id: "repo-1", full_name: "webrenew/blackbox" }], }, { id: "inst-2", installation_id: 202, account_login: "alex", account_type: "User" as const, repositories: [{ id: "repo-2", full_name: "alex/priority-project" }], }, { id: "inst-3", installation_id: 303, account_login: "Mogplex", account_type: "Organization" as const, repositories: [{ id: "repo-3", full_name: "Mogplex/mogplex" }], }, ]; test("a multi-account PR trigger shows every account and keeps them across edits", async ({ page, }) => { // flows.installation_id is 101, but the trigger routes for 101 and 202. The // editor used to show only "webrenew" and rewrote the filter to [101] on any // edit, silently dropping the other account. const { getFlow } = await stubFlowsPage(page, { installations, startFilter: { scope: "all", installationIds: [101, 202] }, }); const savedFilter = () => (getFlow().draft_graph.nodes as FlowNode[]).find( (node) => node.type === "start" )?.data.filter; await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); const filterBar = page.getByTestId("flow-browser-filters"); await selectAppOption(page.getByTestId("flow-browser-account"), "202"); await expect(filterBar).toContainText("1 of 1 workflows"); await selectAppOption(page.getByTestId("flow-browser-account"), "303"); await expect(filterBar).toContainText("0 of 1 workflows"); await selectAppOption(page.getByTestId("flow-browser-account"), "all"); const startNode = page .locator(".react-flow__node") .filter({ hasText: "PR opened" }); await expect(startNode).toContainText("webrenew, alex · All repositories"); await startNode.click(); const account = page.getByTestId("flow-trigger-account"); await expect(account).toHaveAttribute("data-value", "101,202"); await expect(account).toContainText("webrenew, alex"); await selectAppOption( page.getByTestId("flow-trigger-author-filter"), "exclude_dependabot" ); await page.keyboard.press(`${primaryModifier}+S`); await expect.poll(savedFilter).toEqual({ scope: "all", installationIds: [101, 202], authorFilter: "exclude_dependabot", }); // Unchecking the last account must not widen the trigger to every account. await account.click(); await page.getByTestId("flow-trigger-account-option-202").click(); await expect(account).toHaveAttribute("data-value", "101"); const lastAccount = page.getByTestId("flow-trigger-account-option-101"); await expect(lastAccount.getByRole("checkbox")).toBeDisabled(); await lastAccount.click({ force: true }); await expect(account).toHaveAttribute("data-value", "101"); await expect(startNode).toContainText("webrenew · All repositories"); await page.getByTestId("flow-trigger-account-option-all").click(); await account.click(); await expect(account).toHaveAttribute("data-value", "all"); await expect(startNode).toContainText("All accounts · All repositories"); await page.keyboard.press(`${primaryModifier}+S`); await expect.poll(savedFilter).toEqual({ scope: "all", authorFilter: "exclude_dependabot", }); }); test("an org-scoped PR trigger keeps its account-type scope across edits", async ({ page, }) => { // API-authored filters can narrow by account type. The editor must show it // and keep it; edits used to rewrite scope to "all", widening the trigger. const { getFlow } = await stubFlowsPage(page, { installations, startFilter: { scope: "org" }, }); const savedFilter = () => (getFlow().draft_graph.nodes as FlowNode[]).find( (node) => node.type === "start" )?.data.filter; await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); const filterBar = page.getByTestId("flow-browser-filters"); await selectAppOption(page.getByTestId("flow-browser-account"), "202"); await expect(filterBar).toContainText("0 of 1 workflows"); await selectAppOption(page.getByTestId("flow-browser-account"), "303"); await expect(filterBar).toContainText("1 of 1 workflows"); const startNode = page .locator(".react-flow__node") .filter({ hasText: "PR opened" }); await expect(startNode).toContainText("All organizations"); await startNode.click(); const account = page.getByTestId("flow-trigger-account"); await expect(account).toHaveAttribute("data-value", "all-org"); // The open picker must show the org scope, not an empty selection. await account.click(); const scopeRow = page.getByTestId("flow-trigger-account-option-scope"); await expect(scopeRow).toContainText("All organizations"); await expect(scopeRow.getByRole("checkbox")).toBeChecked(); await expect( page.getByTestId("flow-trigger-account-option-all").getByRole("checkbox") ).not.toBeChecked(); await page.keyboard.press("Escape"); await selectAppOption( page.getByTestId("flow-trigger-author-filter"), "humans_only" ); await page.keyboard.press(`${primaryModifier}+S`); await expect.poll(savedFilter).toEqual({ scope: "org", authorFilter: "humans_only", }); }); test("a single-repository trigger bound to a disconnected account prompts for one", async ({ page, }) => { await stubFlowsPage(page, { installations, startFilter: { scope: "all", installationIds: [999] }, }); await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page .locator(".react-flow__node") .filter({ hasText: "PR opened" }) .click(); await selectAppOption(page.getByTestId("flow-trigger-event"), "schedule"); const account = page.getByTestId("flow-trigger-account"); await expect(account).toHaveAttribute("data-value", ""); await expect(account).toContainText("Select an account"); // The prompt is not a choice; picking it used to be a silent no-op. await account.click(); await expect(page.locator('[role="option"][data-value=""]')).toHaveAttribute( "aria-disabled", "true" ); }); test("a multi-account trigger with a disconnected account stays editable", async ({ page, }) => { // 999 was disconnected after the filter was saved. Unchecking 202 used to // leave [999], which the editor rejects, so the checkbox looked stuck. await stubFlowsPage(page, { installations, startFilter: { scope: "all", installationIds: [202, 999] }, }); await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page .locator(".react-flow__node") .filter({ hasText: "PR opened" }) .click(); const account = page.getByTestId("flow-trigger-account"); await expect(account).toContainText("1 of 3 connected accounts"); await account.click(); // The disconnected account is listed, so the drop below is not a surprise. const disconnected = page.getByTestId("flow-trigger-account-option-999"); await expect(disconnected).toContainText("Installation 999 · Disconnected"); await expect(disconnected.getByRole("checkbox")).toBeChecked(); await expect(disconnected.getByRole("checkbox")).toBeDisabled(); await expect( page.getByTestId("flow-trigger-account-option-202").getByRole("checkbox") ).toBeDisabled(); await page.getByTestId("flow-trigger-account-option-101").click(); await expect(account).toHaveAttribute("data-value", "202,101"); await expect(disconnected).toHaveCount(0); }); test("switching a multi-account trigger to a schedule binds one account", async ({ page, }) => { // Publishing rejects a schedule whose filter lists other installations, so // the switch must not carry the GitHub trigger's account list over. The // org scope is dropped too: it only filters GitHub deliveries. const { getFlow } = await stubFlowsPage(page, { installations, startFilter: { scope: "org", installationIds: [101, 303] }, }); const savedStart = () => (getFlow().draft_graph.nodes as FlowNode[]).find( (node) => node.type === "start" )?.data; await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page .locator(".react-flow__node") .filter({ hasText: "PR opened" }) .click(); await selectAppOption(page.getByTestId("flow-trigger-event"), "schedule"); await expect(page.getByTestId("flow-trigger-account")).toHaveAttribute( "data-value", "101" ); await page.keyboard.press(`${primaryModifier}+S`); await expect.poll(() => savedStart()?.event).toBe("schedule"); expect(savedStart()?.filter).toBeUndefined(); }); test("re-selecting All accounts does not add an undo step", async ({ page, }) => { await stubFlowsPage(page, { installations }); await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page .locator(".react-flow__node") .filter({ hasText: "PR opened" }) .click(); const account = page.getByTestId("flow-trigger-account"); await expect(account).toHaveAttribute("data-value", "all"); await account.click(); await page.getByTestId("flow-trigger-account-option-all").click(); await page.keyboard.press("Escape"); const pane = page.locator(".react-flow__pane"); const bounds = await pane.boundingBox(); if (!bounds) throw new Error("Canvas pane has no bounding box"); await pane.dispatchEvent("contextmenu", { clientX: bounds.x + 40, clientY: bounds.y + bounds.height - 40, button: 2, bubbles: true, cancelable: true, }); await expect(page.getByTestId("flow-context-undo")).toBeDisabled(); });